diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3a8c04e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json,toml}] +indent_size = 2 diff --git a/.github/workflows/integrity-check.yml b/.github/workflows/integrity-check.yml deleted file mode 100644 index 5cbe257..0000000 --- a/.github/workflows/integrity-check.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Python application - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - - name: Set up Python 3.7 - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - - name: Install dependencies - run: | - python3 -m pip install --upgrade pipenv - pipenv install --dev - - - name: Run tests - run: | - pipenv run unit_tests - - - name: Build Yamale Recipe - run: | - pipenv run build_yamale - - - name: Check Yamale Recipe for changes - run: git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run 'python scripts/yamale_build.py'" && exit 1) - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4befab8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,343 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v6 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: true + repository: "jeprecated/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore index 1506eb0..d3d8b63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,32 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Rust +/target/ -# C extensions -*.so +# Devbox local state +/.devbox/ -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py +# Editor/OS noise +.DS_Store +.idea/ +.vscode/ +*.swp +*.swo -# Environments +# Local environment .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +.env.* +!.env.example -# Spyder project settings -.spyderproject -.spyproject +# Nix build outputs +/result +/result-* -# Rope project settings -.ropeproject +# Documentation site generated files +/documentation/.docusaurus/ +/documentation/build/ +/documentation/node_modules/ -# mkdocs documentation -/site +# Local generated/downloaded caches +/.cache/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - - - -.idea/ -.directory \ No newline at end of file +# Local agent task queues +/.frontloop/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cc2154b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Agent Guidance + +## Project Aim + +Brain Brew is a Rust-based, local-first deck federation and round-trip engine for shared Anki-compatible decks. The first milestone is not a web app, SaaS, live sync tool, legacy Python recipe compatibility, or full Ultimate Geography clone. + +Read these before making design changes: + +- `CONTEXT.md` — domain glossary +- `documentation/docs/reference/project-scope.md` — current scope and architecture boundaries +- `documentation/docs/reference/decisions/README.md` — active ADR index + +Use the project skill `skills/federated-deck-extensions/SKILL.md` whenever creating, reviewing, or refactoring Federated Deck source, translation overlays, extension overlays, field fills, or UG-style variant targets. It captures the variable-first/shared-extension workflow and common mistakes to avoid. + +## Development Method + +Use TDD with Red-Green-Refactor: + +1. Add a failing test for the next behavior. +2. Implement the smallest change that passes. +3. Refactor with tests still green. + +Scaffolding can exist without tests, but domain behavior, format behavior, adapter behavior, and CLI behavior should enter through a failing test. + +## Crate Boundaries + +- `brain-brew-core`: pure domain only. No YAML, CrowdAnki, filesystem, terminal, or CLI dependencies. +- `brain-brew-formats`: reusable YAML/CrowdAnki codecs over core types. +- `brainbrew`: thin command-line package in `crates/brain-brew-cli`, filesystem access, prompts, and report rendering. + +## Commands + +Use Devbox: + +```bash +devbox run fmt +devbox run test +devbox run clippy +devbox run ci +``` + +Run `devbox run ci` before committing meaningful code changes. + +## Version Control + +This repo uses Jujutsu. Use `jj status`, `jj diff`, and `jj commit`; do not use direct `git` workflow commands. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0d25890 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## v1.0.0-alpha.1 + +Initial Rust-based Brain Brew preview release. + +- Adds the `brainbrew` CLI for Canonical Deck validation, formatting, composition, semantic diffing, and CrowdAnki import/export. +- Adds Federated Deck manifests, named targets, package-qualified composition, package locks, and CI-friendly `verify` checks. +- Adds media reference validation and release-oracle comparison support for parity reviews. +- Includes Ultimate Geography-style fixtures used to validate translations, variants, and Hardcore Geography extension overlays. +- Ships prebuilt release archives, shell and PowerShell installers, and a Homebrew formula via `cargo-dist`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..7438441 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,239 @@ +# Brain Brew + +Brain Brew exists to help flashcard deck maintainers compose, evolve, and redistribute decks without losing the structure or history that makes those decks useful. + +## Language + +**Brain Brew**: +A local-first deck federation and round-trip system for flashcard decks. +_Avoid_: universal note sync service, SaaS sync platform + +**Deck**: +A shareable flashcard collection including notes, note types, card templates, styling, metadata, and media references. +_Avoid_: note list, CSV file, Anki export + +**Deck Maintainer**: +A person responsible for evolving and publishing a shared flashcard deck. +_Avoid_: learner, reviewer, end user + +**Learner**: +A person who studies with a shared deck and may have private changes to preserve. +_Avoid_: deck maintainer, publisher + +**Shared Deck**: +A flashcard deck intended to be installed, updated, or extended by people other than its maintainer. +_Avoid_: personal deck, private notes + +**Federated Deck**: +A composable source package for a shared deck contribution, containing a base deck, overlays, or both, intended to be composed with other Federated Decks. +_Avoid_: Anki subdeck, resolved deck, full deck copy + +**Canonical Deck**: +The format-independent representation of a deck's notes, note types, card templates, styling, metadata, and media references. +_Avoid_: canonical note list, CrowdAnki JSON + +**Canonical Deck File**: +The maintainer-owned source file containing a canonical deck. +_Avoid_: generated artifact, adapter export + +**Note**: +A deck entity containing field values and tags for one learnable fact or item. +_Avoid_: card, row + +**Note Type**: +A deck entity that defines the fields and card templates shared by a group of notes. +_Avoid_: template, card type + +**Card Template**: +A deck entity that defines how a note becomes a study card. +_Avoid_: note type, card + +**Card**: +A study item produced from a note through a card template. +_Avoid_: note, template + +**Review History**: +A learner's scheduling and study progress for cards in a spaced repetition system. +_Avoid_: deck content, note metadata + +**Media Asset**: +An external file used by deck content or presentation. +_Avoid_: embedded YAML data, field text + +**Media Reference**: +A deck entity that identifies and verifies a media asset. +_Avoid_: raw media file, HTML snippet + +**Deck Entity**: +An identifiable part of a deck, such as a note, note type, card template, media reference, or deck metadata item. +_Avoid_: file, row, JSON object + +**Stable ID**: +A human-readable identifier that says a deck entity is the same entity across source files, overlays, exports, and releases. +_Avoid_: content hash, row number, display name, adapter GUID + +**Adapter ID**: +An identifier used by an external deck format or tool for the same deck entity. +_Avoid_: stable ID, content hash + +**Suggested Stable ID**: +A proposed stable ID generated during import that must be accepted or corrected before becoming canonical. +_Avoid_: stable ID, adapter ID + +**Content Hash**: +A fingerprint of deck content used to detect changes, not to identify entities. +_Avoid_: note ID, canonical ID + +**Overlay**: +A bounded set of changes applied to a base deck without replacing the base deck. +_Avoid_: fork, duplicate deck + +**Translation Overlay**: +An overlay that changes deck language or localized text. +_Avoid_: separate translated deck + +**Extension Overlay**: +An overlay that adds new deck content or structure. +_Avoid_: patch, translation + +**Patch Overlay**: +An overlay that corrects or adjusts existing deck content or structure. +_Avoid_: extension, fork + +**Personal Overlay**: +An overlay containing learner-specific deck content or structure that should survive shared deck updates. +_Avoid_: upstream deck, maintainer patch, study state + +**Overlay Fragment**: +The sparse deck-shaped content of an overlay, containing only the deck entities and properties the overlay changes. +_Avoid_: full deck copy, command script + +**Source Variable**: +A named text value defined on a deck, note type, card template, or note and referenced from source text with `${variable.name}` before adapter export. +_Avoid_: recipe variable, runtime Anki field + +**Translation Dictionary**: +A translation overlay section mapping exact source text, source variables, and adapter IDs to their translated values, with the source key acting as an implicit expected base. +_Avoid_: CSV importer, global localization database + +**Field Fill**: +An overlay shorthand for filling existing blank note fields with new content while requiring the upstream field to still be blank. +_Avoid_: translation addition, field definition addition + +**Change Intent**: +The declared meaning of an overlay change, such as add, merge, replace, remove, or override. +_Avoid_: implicit overwrite, accidental merge + +**Tombstone**: +A record that a deck entity was deliberately removed. +_Avoid_: missing data, accidental deletion + +**Expected Base**: +The prior deck value or fingerprint an overlay declares before making a destructive or conflict-resolving change. +_Avoid_: current value guess, unchecked overwrite + +**Overlay Stack**: +An ordered set of overlays applied to a base deck. +_Avoid_: unordered overlay set, dependency graph + +**Overlay Catalog**: +A named collection of overlays available in a Federated Deck. +_Avoid_: raw file list, package solver + +**Overlay Dependency**: +A requirement that one overlay include and apply another overlay before itself. +_Avoid_: implicit conflict resolution, automatic content merge + +**Build Target**: +A named composition goal that resolves a base deck and selected overlays into a Resolved Deck. +_Avoid_: Anki export, source file, recipe step + +**Resolved Deck**: +The deck produced by applying an overlay stack to a base deck. +_Avoid_: build artifact, export format + +**Compose**: +To produce a resolved deck by applying an overlay stack to a base deck. +_Avoid_: build, export + +**Semantic Diff**: +A comparison of decks by stable IDs and deck entities rather than raw source lines. +_Avoid_: text diff, file diff + +**Federation Conflict**: +A situation where overlays make incompatible changes to the same deck entity. +_Avoid_: validation warning, last write wins + +**Deck Federation**: +The composition of a base deck with translations, extensions, patches, or personal overlays without copying the whole deck. +_Avoid_: fork, duplicate deck, one-off conversion + +**Canonicalized Source**: +A source representation after Brain Brew has applied its deterministic formatting rules. +_Avoid_: arbitrary original bytes, hand-formatted source + +**Round Trip**: +A workflow where deck data can move from source files to a distributable deck format and back without losing intentional information. +_Avoid_: import only, export only, one-way conversion + +## Relationships + +- **Brain Brew** primarily serves **Deck Maintainers**. +- A **Deck Maintainer** publishes one or more **Shared Decks**. +- A **Deck Maintainer** may publish a **Federated Deck** as a composable shared-deck source package. +- A **Federated Deck** may contribute a base **Deck**, **Overlays**, or both. +- **Federated Decks** compose through **Deck Federation** to produce **Resolved Decks**. +- A **Learner** studies a **Shared Deck** and may have a **Personal Overlay**. +- **Brain Brew** works on **Decks**. +- A **Canonical Deck** represents one **Deck** without binding it to a source or distribution format. +- A **Canonical Deck File** is the source of truth for a **Canonical Deck**. +- A **Deck** contains **Deck Entities**. +- A **Note** belongs to one **Note Type**. +- A **Note Type** has one or more **Card Templates**. +- A **Card** is produced from one **Note** and one **Card Template**. +- **Review History** is preserved by stable identity, not stored as **Deck** content. +- A **Media Reference** points to one **Media Asset**. +- A **Stable ID** identifies a **Deck Entity** across a **Round Trip**. +- An **Adapter ID** preserves identity in a specific external format or tool. +- A **Suggested Stable ID** becomes a **Stable ID** only after maintainer review. +- A **Content Hash** describes current content for change detection. +- **Deck Federation** combines one base **Deck** with zero or more **Overlays**. +- An **Overlay** contains an **Overlay Fragment**. +- An **Overlay** may use a **Translation Dictionary** to translate extracted source text without repeating per-field replacement boilerplate. +- An **Overlay** may use **Field Fills** to add content to existing blank note fields without misclassifying that content as a translation. +- A **Source Variable** lets shared card template structure refer to phrase values translated by a **Translation Dictionary**. +- An **Overlay** uses **Change Intents** to change **Deck Entities** by **Stable ID**. +- Replace, remove, and override **Change Intents** require an **Expected Base**. +- A remove **Change Intent** creates a **Tombstone**. +- An **Overlay Catalog** names the overlays available in a **Federated Deck**. +- An **Overlay Dependency** constrains the order of an **Overlay Stack**. +- An **Overlay Stack** applies overlays in declared or dependency-expanded order. +- A **Build Target** selects overlays from an **Overlay Catalog** for composition. +- **Compose** applies an **Overlay Stack** to a base **Deck** to produce a **Resolved Deck**. +- A **Semantic Diff** compares **Decks** through **Deck Entities** and **Stable IDs**. +- A **Federation Conflict** must be resolved explicitly. +- **Translation Overlays**, **Extension Overlays**, **Patch Overlays**, and **Personal Overlays** are kinds of **Overlay**. +- A **Round Trip** preserves a **Deck** across source and distributable forms. +- A **Round Trip** reproduces **Canonicalized Source**, not arbitrary original source bytes. + +## Example dialogue + +> **Dev:** "If a translator adds German text to Ultimate Geography, are they creating a new independent deck?" +> **Domain expert:** "No — they are participating in **Deck Federation** by applying a translation overlay to the base **Deck**." + +## Flagged ambiguities + +- "sync tool" previously meant live bidirectional note-system synchronization; resolved: **Brain Brew** is first a local-first **Deck Federation** and **Round Trip** system. +- "canonical note" previously meant the central federation object; resolved: the central object is the **Canonical Deck**, because a **Deck** includes more than notes. +- "content hash" previously meant identity; resolved: a **Content Hash** detects change, while a **Stable ID** defines sameness. +- "Anki GUID" could mean canonical identity; resolved: Anki/CrowdAnki GUIDs are **Adapter IDs**, while human-readable **Stable IDs** identify canonical deck entities. +- "personal overlay" was used to mean both learner workflow and derivative change; resolved: a **Personal Overlay** is a derivative change, while a full learner workflow is not implied. +- "preserve Anki history" could mean storing review data; resolved: **Review History** remains outside **Canonical Deck** content and is preserved through stable identity. +- "media in the deck file" could mean embedded bytes; resolved: **Canonical Deck** stores **Media References**, while **Media Assets** remain external files. +- "overlay order" could imply last-write-wins; resolved: an **Overlay Stack** is ordered, but conflicting changes fail unless explicitly resolved. +- "byte-for-byte round trip" could mean preserving arbitrary input formatting; resolved: byte stability applies to **Canonicalized Source**. +- "CSV source" previously implied the maintainer source of truth; resolved: the **Canonical Deck File** is the source of truth, while CSV is an adapter format. +- "subdeck" could mean Anki deck hierarchy; resolved: composable source packages in a deck federation are **Federated Decks**, not Anki subdecks. +- "translated deck identity" could mean a separate stable identity per language; resolved: translations use language-neutral **Stable IDs** for the same conceptual **Deck Entities** and language-specific external identities remain **Adapter IDs**. +- "Ultimate Geography support" could mean product-specific application behavior; resolved: Ultimate Geography is a demanding case study and parity fixture for general Brain Brew federation behavior, not a special-purpose application feature. +- "migration import" could mean Brain Brew should convert every legacy source layout; resolved: initial migration means refactoring into **Canonical Deck Files** and proving output parity, not building public legacy source importers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5700b5f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ - -### Install Brain Brew package - -https://pypi.org/project/Brain-Brew/ - -```shell -pipenv install brain-brew -``` - -### Run Local Version - -Fork/Clone this repo onto your computer, then in a different repository you wish to run Brain Brew you can point it to this version in a 2 ways: - -#### Install development folder for live updates - -Point your installation to this folder. Run the following (change the path to match yours): - -```shell -pipenv install -e ../brain-brew -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew", editable = true} -``` - -#### Install a locally built package - -Build Brain Brew using the `scripts/build.bash` script. This will generate dist and build folders. Install the generated wheel by running: - -``` -pip install ../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl"} -``` - -Change to match the wheel version number, which is set in `brain_brew/front_matter.py` if you wish to change it. - - diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ff8c29c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1265 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brain-brew-core" +version = "1.0.0-alpha.1" + +[[package]] +name = "brain-brew-formats" +version = "1.0.0-alpha.1" +dependencies = [ + "brain-brew-core", + "serde", + "serde_json", + "serde_yaml", + "sha2", +] + +[[package]] +name = "brainbrew" +version = "1.0.0-alpha.1" +dependencies = [ + "base64", + "brain-brew-core", + "brain-brew-formats", + "flate2", + "nix-nar", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_executable" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nix-nar" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a60e6f4acddbfaa0c363f8bdc6e4b2188d0b690c567176212f795fe008a30b3" +dependencies = [ + "camino", + "is_executable", + "symlink", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fba5209 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[workspace] +members = [ + "crates/brain-brew-core", + "crates/brain-brew-formats", + "crates/brain-brew-cli", +] +resolver = "3" + +[workspace.package] +version = "1.0.0-alpha.1" +edition = "2024" +rust-version = "1.94" +license = "Unlicense" +authors = ["Brain Brew contributors"] +repository = "https://github.com/jeprecated/brain-brew" + +[workspace.dependencies] +brain-brew-core = { path = "crates/brain-brew-core" } +brain-brew-formats = { path = "crates/brain-brew-formats" } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4d17a07..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include brain_brew/schemas/recipe.yaml diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 3f71eac..0000000 --- a/Pipfile +++ /dev/null @@ -1,24 +0,0 @@ -[[source]] -name = "Brain Brew" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -pytest = "==5.4.1" -twine = "*" -coverage = "==4.5.4" -typing-extensions = "==3.10.0.0" - -[packages] -"ruamel.yaml" = "==0.16.10" -yamale = "==3.0.8" - -[requires] -python_version = "3.7" - -[scripts] -build_yamale = "python scripts/yamale_build.py" -check_for_changes = ''' - git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run `python scripts/yamale_build.py`" && exit 1) -''' -unit_tests = "py.test" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 5415fc1..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,645 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "0d4532926edfd30fdf44d37fea09d682ba1b3bab705b425f29745ec3cee0f7ac" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "Brain Brew", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "pyyaml": { - "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" - ], - "markers": "python_version >= '3.6'", - "version": "==6.0.1" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b", - "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954" - ], - "index": "Brain Brew", - "version": "==0.16.10" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d", - "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001", - "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462", - "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9", - "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe", - "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b", - "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b", - "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615", - "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62", - "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15", - "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b", - "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1", - "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9", - "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675", - "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899", - "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7", - "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7", - "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312", - "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa", - "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91", - "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b", - "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6", - "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3", - "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334", - "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5", - "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3", - "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe", - "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c", - "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed", - "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337", - "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880", - "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f", - "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d", - "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248", - "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d", - "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf", - "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512", - "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069", - "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb", - "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942", - "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d", - "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31", - "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92", - "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5", - "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28", - "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d", - "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1", - "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2", - "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875", - "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.8" - }, - "yamale": { - "hashes": [ - "sha256:1468f90f5019a82a77ff3f101bb001930765ef4b5e8d7fe658c1d2967e775f9a", - "sha256:9e9d6946d2f68926822d0df400dafb5e75b34bc7f482237393db29e697d5bbad" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.6'", - "version": "==3.0.8" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" - ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" - }, - "bleach": { - "hashes": [ - "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", - "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" - ], - "markers": "python_version >= '3.7'", - "version": "==6.0.0" - }, - "certifi": { - "hashes": [ - "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", - "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" - ], - "markers": "python_version >= '3.6'", - "version": "==2024.7.4" - }, - "cffi": { - "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.15.1" - }, - "charset-normalizer": { - "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" - }, - "commonmark": { - "hashes": [ - "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", - "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" - ], - "version": "==0.9.1" - }, - "coverage": { - "hashes": [ - "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", - "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", - "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", - "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", - "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", - "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", - "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", - "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", - "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", - "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", - "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", - "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", - "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", - "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", - "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", - "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", - "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", - "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", - "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", - "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", - "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", - "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", - "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", - "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", - "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", - "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", - "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", - "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", - "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", - "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", - "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", - "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" - ], - "index": "Brain Brew", - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", - "version": "==4.5.4" - }, - "cryptography": { - "hashes": [ - "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709", - "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069", - "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2", - "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b", - "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e", - "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70", - "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778", - "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22", - "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895", - "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf", - "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431", - "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f", - "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947", - "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74", - "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc", - "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66", - "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66", - "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf", - "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f", - "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5", - "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e", - "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f", - "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55", - "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1", - "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47", - "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5", - "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0" - ], - "markers": "python_version >= '3.7'", - "version": "==43.0.0" - }, - "docutils": { - "hashes": [ - "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", - "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" - ], - "markers": "python_version >= '3.7'", - "version": "==0.20.1" - }, - "idna": { - "hashes": [ - "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", - "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" - ], - "markers": "python_version >= '3.6'", - "version": "==3.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", - "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5" - ], - "markers": "python_version < '3.8'", - "version": "==6.7.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", - "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a" - ], - "markers": "python_version < '3.9'", - "version": "==5.12.0" - }, - "jaraco.classes": { - "hashes": [ - "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", - "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" - ], - "markers": "python_version >= '3.7'", - "version": "==3.2.3" - }, - "jeepney": { - "hashes": [ - "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", - "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" - ], - "markers": "sys_platform == 'linux'", - "version": "==0.8.0" - }, - "keyring": { - "hashes": [ - "sha256:3d44a48fa9a254f6c72879d7c88604831ebdaac6ecb0b214308b02953502c510", - "sha256:bc402c5e501053098bcbd149c4ddbf8e36c6809e572c2d098d4961e88d4c270d" - ], - "markers": "python_version >= '3.7'", - "version": "==24.1.1" - }, - "more-itertools": { - "hashes": [ - "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", - "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" - ], - "markers": "python_version >= '3.7'", - "version": "==9.1.0" - }, - "packaging": { - "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" - ], - "markers": "python_version >= '3.7'", - "version": "==24.0" - }, - "pkginfo": { - "hashes": [ - "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", - "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097" - ], - "markers": "python_version >= '3.6'", - "version": "==1.10.0" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.11.0" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "pytest": { - "hashes": [ - "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", - "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.5'", - "version": "==5.4.1" - }, - "readme-renderer": { - "hashes": [ - "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", - "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" - ], - "markers": "python_version >= '3.7'", - "version": "==37.3" - }, - "requests": { - "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" - ], - "markers": "python_version >= '3.7'", - "version": "==2.31.0" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, - "rfc3986": { - "hashes": [ - "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", - "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "rich": { - "hashes": [ - "sha256:3fba9dd15ebe048e2795a02ac19baee79dc12cc50b074ef70f2958cd651b59a9", - "sha256:ce5c714e984a2d185399e4e1dd1f8b2feacb7cecfc576f1522425643a36a57ea" - ], - "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", - "version": "==12.0.1" - }, - "secretstorage": { - "hashes": [ - "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", - "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" - ], - "markers": "sys_platform == 'linux'", - "version": "==3.3.3" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "twine": { - "hashes": [ - "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", - "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.7'", - "version": "==4.0.2" - }, - "typing-extensions": { - "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" - ], - "index": "Brain Brew", - "version": "==3.10.0.0" - }, - "urllib3": { - "hashes": [ - "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", - "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.7" - }, - "wcwidth": { - "hashes": [ - "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", - "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" - ], - "version": "==0.2.13" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "zipp": { - "hashes": [ - "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", - "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" - ], - "markers": "python_version >= '3.7'", - "version": "==3.15.0" - } - } -} diff --git a/README.md b/README.md index 16b7f70..0a61346 100644 --- a/README.md +++ b/README.md @@ -1,254 +1,99 @@ -# Brain-Brew +# Brain Brew - - +Brain Brew is a Rust-based, local-first deck federation and round-trip engine for shared Anki-compatible decks. -Brain Brew is an open-source flashcard manipulation tool designed to allow users to convert their Anki flashcards to/from many different formats to suit their own needs. -The goal is to facilitate collaboration and maximize user choice, with a powerful tool that minimizes effort. -[CrowdAnki](https://github.com/Stvad/CrowdAnki) Exports and Csv(s) are the only supported file types as of now, but there will be more to come. +It continues the established Brain Brew project name while replacing the legacy Python recipe pipeline with canonical deck source, overlays, manifests, and reproducible verification. +It aims to help deck maintainers compose a base deck with translations, extensions, patches, and personal overlays while preserving stable identity for Anki/CrowdAnki round trips. -[Anki Ultimate Geography](https://github.com/axelboc/anki-ultimate-geography/) is currently the best working example of a Flashcard repo using Brain Brew :tada: -See there for inspiration! +## Current Status +Brain Brew now has a working Rust core, reusable format codecs, and a thin CLI for Canonical Deck validation, overlay composition, CrowdAnki import/export, semantic diffing, media checks, authoring helpers, Federated Deck manifests, package-qualified target composition, and locked package inputs. -# Installation +The repository includes two tested fixtures: +- `fixtures/ug-style/` — a small Ultimate Geography-style fixture for fast end-to-end checks. +- `fixtures/ultimate-geography/` — a full Ultimate Geography canonical workspace used as a large parity case study, including Hardcore Geography as an extension overlay. -Install the latest version of [Brain Brew on PyPi.org](https://pypi.org/project/Brain-Brew/) -with `pip install brain-brew`. Virtual environment using `pipenv` is recommended! +Ultimate Geography is a fixture and case study for the general federation workflow; it is not a special product-specific CLI feature. -:exclamation: See the [Brain Brew Starter Project][BrainBrewStarter] for a working clone-able Git repo. -From this repo you can now create a functional Brain Brew setup automatically, -with your own flashcards! Simply by running +## Federated Deck workflow -```bash -brainbrew init [Your CrowdAnki Export Folder] -``` - -This will generate the entire working repo for you, including the recipe files, source files, and build folder. -For bi-directional sync: Anki <-> Source! - -See [the starter repo][BrainBrewStarter] for a step-by-step guide for all of this. +A Federated Deck workspace contains a base Canonical Deck, overlays, and a `brainbrew.yaml` manifest declaring reproducible build targets. -# Usage - -Brain Brew runs from the command line and takes a *Recipe.yaml* file to run. +Common commands: ```bash -brainbrew run source_to_anki.yaml +brainbrew targets --manifest brainbrew.yaml --json +brainbrew targets --package-root ../anki-geo-packages +brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography +brainbrew lock verify +brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/ +brainbrew explain --manifest brainbrew.yaml --target de-extended --json +brainbrew compose --manifest brainbrew.yaml --target de-extended --out build/de-extended.yaml +brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/ +brainbrew diff deck.yaml edited.yaml --as-overlay --id overlay.patch.capitals --kind patch ``` -Full usage help text: -```bash -Brain Brew vx.y.z -usage: brainbrew [-h] {run,init} ... +See the dedicated documentation site in [`documentation/`](documentation/) for manifest, source variable, translation dictionary, overlay, locking, and example workflows. Lock update/verify uses Rust-native fetching and NAR hashing; Nix is only an optional install/build path. -Manage Flashcards by transforming them to various types. +## Install the CLI -positional arguments: - {run,init} Commands that can be run - run Run a recipe file. This will convert some data to another format, based on the instructions in the recipe file. - init Initialise a Brain Brew repository, using a CrowdAnki export as the base data. +The easiest install path is the GitHub Release installer: -optional arguments: - -h, --help show this help message and exit +```bash +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/jeprecated/brain-brew/releases/download/v1.0.0-alpha.1/brainbrew-installer.sh \ + | sh +brainbrew --version ``` +Homebrew users can install from the tap once the preview release is published: -## Recipes - -These are the instructions for how Brain Brew will ~~build~~ *brew* your data into another format. - -What's YAML? See the current spec [here](http://www.yaml.org/spec/1.2/spec.html). - -Run a recipe with `--verify` or `-v` to confirm your recipe is valid, without actually running it. -A dry run of sorts. - -### Tasks - -A recipe is made of many individual tasks, which do specific functions. -Full detailed list coming soon™️, but see the [Yamale recipe schema](https://github.com/jeprecated/brain-brew/blob/master/brain_brew/schemas/recipe.yaml) -(local file: `brain_brew/schemas/recipe.yaml`) in the meantime :+1: - - - - -[//]: <> (Yamale) - -# The Why - -Brain Brew was made in an effort to solve some of the following issues with current collaboration of Anki Flashcards: - -#### Sharing Personal Information or Copyrighted Material - -Have some personal notes on your cards? Used some images randomly taken from the internet? -That usually means you cannot share your deck entirely, without having to go to the effort of removing the offending material and/or managing two separate copies. - -#### Having to Pick Between Source Control or Anki Editing - -Putting your cards into a source control system brings a lot of benefits. -You can see any changes that occur, go back in time should an mistake be discovered, and collaborate with others. - -However the current tools for managing Anki cards in source control -(such as [Anki-DM](https://github.com/OnkelTem/anki-dm), [GenAnki](https://github.com/kerrickstaley/genanki), -and [Remote Decks](https://github.com/c-okelly/anki-remote-decks)) are only one way. -You generate cards from a csv into a file that can *only be imported* into Anki. -There is no way to export them back, meaning a user must manually copy their changes over, or simple not edit their cards anywhere other than in source control. - -This robs the user of two important work flows: -1. Editing/fixing cards in Anki as you review them (on desktop or mobile) -1. The plethora of Anki add-ons that already exist that are amazingly useful. E.g: Image Occlusion, Morphman, AwesomeTTS. - -A user should not have to pick between these fantastic work flows and the usage of source control to structure, manage, and share their cards. - -#### Lack of Formatting Choice - -Csvs are great for editing data, but can only go so far by themselves. Having all the data inside one csv leaves a lot to be desired and can result in eventual problems. -When one gets as many columns as *this* (from [Ultimate Geography](https://github.com/axelboc/anki-ultimate-geography/)) then it becomes a nightmare to manage: - -|guid|Country|Country:de|Country:es|Country:fr|Country:nb|"Country info"|"Country info:de"|"Country info:es"|"Country info:fr"|"Country info:nb"|Capital|Capital:de|Capital:es|Capital:fr|Capital:nb|"Capital info"|"Capital info:de"|"Capital info:es"|"Capital info:fr"|"Capital info:nb"|"Capital hint"|"Capital hint:de"|"Capital hint:es"|"Capital hint:fr"|"Capital hint:nb"|Flag|"Flag similarity"|"Flag similarity:de"|"Flag similarity:es"|"Flag similarity:fr"|"Flag similarity:nb"|Map|tags| -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -|crr.AfnVRi|England|England|Inglaterra|Angleterre|England|"Constituent country of the United Kingdom."|"Landesteil des Vereinigten Königreichs."|"Nación constitutiva del Reino Unido."|"Nation constitutive du Royaume-Uni."|"Land som utgjør en del av Storbritannia."|London|London|Londres|Londres|London| | | | | |"Not a sovereign country"|"Kein souveräner Staat"|"No es un país soberano"|"Pas une nation souveraine"|"Ikke selvstendig land"|""| | | | | |""|UG::Europe| -"h"|"Ireland (orange and green flipped, wider)"|"Irland (Orange und Grün vertauscht, breiter)"|"Irlanda (naranja y verde intercambiados, más ancha)"|"Irlande (orange et vert inversés, plus large)"|"Ireland (byttet plass på oransje og grønt, bredere)"|""|"UG::Africa UG::Sovereign_State UG::West_Africa" - -Then there's having too many rows in one csv for it to be properly managed. - - -# Features of Brain Brew -### Multi-directional Card Syncing -Make changes in your source file and sync those into your Anki collection. - -Make changes inside Anki and pull those back into the source. - -Any user of your shared deck can make a change inside Anki and at some later point export their deck (or just part of it) using CrowdAnki. -Then the source file can be updated with their changes and a new CrowdAnki Export for all users to import can be generated with one run of Brain Brew. - -### Modular Configuration Files -Yaml config files are what drive the conversion of Brain Brew, allowing users to easily change the functionality as they wish. - - - -```Yaml -- generate_guids_in_csv: - source: src/data/words.csv - columns: [ guid ] - -- build_parts: - - note_model_from_yaml_part: - part_id: LL Word - file: src/note_models/LL Word.yaml - - - headers_from_yaml_part: - part_id: default header - file: src/headers/default.yaml - override: # Optional - deck_description_html_file: src/headers/desc.html - - - media_group_from_folder: - part_id: all_media - source: src/media - recursive: true # Optional - - - notes_from_csvs: - part_id: english-to-danish - - note_model_mappings: - - note_models: - - LL Word - columns_to_fields: # Optional - guid: guid - tags: tags - - english: English - danish: Word - picture: Picture - danish audio: Pronunciation (Recording and/or IPA) - - file_mappings: - - file: src/data/words.csv - note_model: LL Word - sort_by_columns: [english] # Optional - reverse_sort: no # Optional +```bash +brew install jeprecated/tap/brainbrew ``` -### Personal Fields -Deck managers can set specific fields to be "Personal", meaning they will not overwrite an existing value on import. - -Working version currently exists, but full PR coming soon to CrowdAnki! - -### Extensibility and Open Source -Free for all to use, modify, or sell this product. - -Further source types are relatively easy to add due to the flexible nature of the backend -Instead of creating a Csv <-> CrowdAnki converter Brain Brew first goes through a middle layer called "Deck Parts". -These consist of Notes, Headers, Note Models, and Media files. - -Each new source type to be added to Brain Brew (such as Markdown) need only be able to convert from Deck Parts <-> itself, and suddenly it can convert to and from all existing source types! +Rust users can install directly from the release tag: -### Smart Csvs - -Csvs only update the rows which have changed. -Meaning a user can import *a subset* of their cards which have changed and still update the source file without deleting the cards they did not include. - -##### Csv Splitting / Derivatives - -Split data into multiple csvs so that your data is neatly organised however you like. - -The two following csv files contain information about England, but split into different csv files: - -###### data-main.csv - -| guid | country | flag | map | tags | -| ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | - -###### data-capital.csv -| country | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | -| England | London | London | Londres | Londres | London | - -Brain Brew can be told that `data-capital` is a derivative of `data-main` in the build config file as such: - -```yaml -- file: src/data/data-main.csv # <---- Main - note_model: Ultimate Geography - derivatives: - - file: src/data/data-country.csv - - file: src/data/data-country-info.csv - - file: src/data/data-capital.csv # <---- Capital - - file: src/data/data-capital-info.csv - - file: src/data/data-capital-hint.csv - # note_model: different_note_model - # derivatives: - # - file: derivative-of-a-derivative.csv - # derivatives: - # - file: infinite-nesting.csv - - file: src/data/data-flag-similarity.csv +```bash +cargo install --git https://github.com/jeprecated/brain-brew --tag v1.0.0-alpha.1 brainbrew --locked ``` -When run Brain Brew will perform the following steps for each derivative: -1. Finds which columns in the derivative csv match the main (only `country` in this case) -1. Go through each row in the derivative and find the row with matching values in the main file -1. Add in the extra columns (`capital` in each language) to that matching row in the main file +Nix remains available as an optional build/install path: -###### Resulting csv data -| guid | country | flag | map | tags | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | London | London | Londres | Londres | London | +```bash +nix run . -- --help +nix build .#brainbrew +``` -##### Note: +See [`documentation/docs/getting-started/install.md`](documentation/docs/getting-started/install.md) for all install options and an edit/export loop for trying changes against a Federated Deck workspace. -1. **Derivatives can also have derivatives**. +## Workspace -1. **Csv splitting works in both directions**, to and from csv. +```text +crates/ + brain-brew-core/ Pure domain model, validation, composition, semantic diffing + brain-brew-formats/ Reusable YAML and CrowdAnki codecs + brain-brew-cli/ Thin `brainbrew` command-line package +``` -1. **Derivatives can be given a Note Model**, which overrides their parent's note model for all the matched rows. +## Development -See the [Brain Brew Starter Project][BrainBrewStarter] for an example of Csv Derivatives working. +This project uses Devbox: +```bash +devbox run fmt +devbox run test +devbox run clippy +devbox run ci +``` +Useful docs: -[BrainBrewStarter]: https://github.com/jeprecated/brain-brew-starter +- Agent guidance: [`AGENTS.md`](AGENTS.md) +- Documentation site source: [`documentation/`](documentation/) +- Start here: [`documentation/docs/intro.md`](documentation/docs/intro.md) +- Domain glossary: [`documentation/docs/reference/glossary.md`](documentation/docs/reference/glossary.md) +- Project scope: [`documentation/docs/reference/project-scope.md`](documentation/docs/reference/project-scope.md) +- Active ADRs: [`documentation/docs/reference/decisions/`](documentation/docs/reference/decisions/) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 1dbfcb7..0000000 --- a/TODO.md +++ /dev/null @@ -1,9 +0,0 @@ -# Todos - -- [ ] Better error messages - - No stack trace, unless they add -v -- [ ] Save note model to yaml - - Respect the current positions of the child files - - Be able to do it for multiple note models at a time, while checking their shared components are the same -- [ ] Save headers build task - - Remove the save_to_file diff --git a/brain_brew/__init__.py b/brain_brew/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/__init__.py b/brain_brew/build_tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/__init__.py b/brain_brew/build_tasks/crowd_anki/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py b/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py deleted file mode 100644 index b9d99e2..0000000 --- a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py +++ /dev/null @@ -1,81 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper - - -@dataclass -class CrowdAnkiGenerate(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - folder: str() - headers: str() - notes: include('{NotesToCrowdAnki.task_name()}') - note_models: include('{NoteModelsToCrowdAnki.task_name()}') - media: include('{MediaGroupToCrowdAnki.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesToCrowdAnki, NoteModelsToCrowdAnki, MediaGroupToCrowdAnki} - - @dataclass - class Representation(RepresentationBase): - folder: str - notes: dict - note_models: dict - headers: dict - media: Optional[dict] = field(default_factory=lambda: dict()) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowd_anki_export=CrowdAnkiExport.create_or_get(rep.folder), - notes_transform=NotesToCrowdAnki.from_repr(rep.notes), - note_model_transform=NoteModelsToCrowdAnki.from_repr(rep.note_models), - headers_transform=HeadersToCrowdAnki.from_repr(rep.headers), - media_transform=MediaGroupToCrowdAnki.from_repr(rep.media) if rep.media else None - ) - - rep: Representation - crowd_anki_export: CrowdAnkiExport - notes_transform: NotesToCrowdAnki - note_model_transform: NoteModelsToCrowdAnki - headers_transform: HeadersToCrowdAnki - media_transform: Optional[MediaGroupToCrowdAnki] - - def execute(self): - headers = self.headers_transform.execute() - ca_wrapper = CrowdAnkiJsonWrapper(headers) - - note_models: List[dict] = self.note_model_transform.execute() - - nm_name_to_id: dict = {model.part_id: model.part.id for model in self.note_model_transform.note_models} - notes = self.notes_transform.execute(nm_name_to_id) - - media_files: Set[MediaFile] = set() - if self.media_transform: - media_files = self.media_transform.execute(self.crowd_anki_export.media_loc) - - ca_wrapper.media_files = sorted([m.filename for m in media_files]) - ca_wrapper.name = self.headers_transform.headers.name - ca_wrapper.note_models = note_models - ca_wrapper.notes = notes - - # Set to CrowdAnkiExport - self.crowd_anki_export.write_to_files(ca_wrapper.data) diff --git a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py b/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py deleted file mode 100644 index 3f36ba7..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py +++ /dev/null @@ -1,69 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES, \ - CA_CHILDREN, CA_TYPE -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.headers import Headers - -headers_skip_keys = [CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES] -headers_default_values = { - CA_TYPE: "Deck", - CA_CHILDREN: [], -} - - -@dataclass -class HeadersFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'headers_from_crowd_anki' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - headers = Headers(self.crowd_anki_to_headers(ca_wrapper.data)) - - return PartHolder.override_or_create(self.part_id, self.save_to_file, headers) - - @staticmethod - def crowd_anki_to_headers(ca_data: dict): - return {key: value for key, value in ca_data.items() - if key not in headers_skip_keys and key not in headers_default_values.keys()} diff --git a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py deleted file mode 100644 index 9a85a7e..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import headers_default_values -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersToCrowdAnki: - @dataclass - class Representation(RepresentationBase): - part_id: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support single string being passed in - - return cls( - rep=rep, - headers=PartHolder.from_file_manager(rep.part_id).part - ) - - rep: Representation - headers: Headers - - def execute(self) -> dict: - headers = self.headers_to_crowd_anki(self.headers.data_without_name) - - return headers - - @staticmethod - def headers_to_crowd_anki(headers_data: dict): - return {**headers_default_values, **headers_data} - diff --git a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py deleted file mode 100644 index 29cf421..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py +++ /dev/null @@ -1,39 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromCrowdAnki(MediaGroupFromFolder): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_crowd_anki" - - @classmethod - def from_repr(cls, data: Union[MediaGroupFromFolder.Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - cae: CrowdAnkiExport = CrowdAnkiExport.create_or_get(rep.source) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(cae.media_loc, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - ) - ) - - rep: MediaGroupFromFolder.Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py deleted file mode 100644 index 705d3a0..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Union, List, Set - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class MediaGroupToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'media_group_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)) - ) - - rep: Representation - parts: List[MediaGroup] - - def execute(self, ca_media_folder: str) -> Set[MediaFile]: - return save_media_groups_to_location(self.parts, ca_media_folder, True, False) diff --git a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py deleted file mode 100644 index 583ad5f..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelSingleFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - model_name: str - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - if self.model_name not in note_models_dict: - raise ReferenceError(f"Missing Note Model '{self.model_name}' in CrowdAnki file") - - part = NoteModel.from_crowdanki(note_models_dict[self.model_name]) - return PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py deleted file mode 100644 index 17e2950..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsAllFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_all_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: str() - ''' - - @dataclass - class Representation(RepresentationBase): - source: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source) - ) - - rep: Representation - ca_export: CrowdAnkiExport - - def execute(self) -> List[PartHolder[NoteModel]]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - parts = [] - for name, model in note_models_dict.items(): - parts.append(PartHolder.override_or_create(name, None, NoteModel.from_crowdanki(model))) - - logging.info(f"Found {len(parts)} note model{'s' if len(parts) > 1 else ''} in CrowdAnki Export: '" - + "', '".join(note_models_dict.keys()) + "'") - - return parts diff --git a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py deleted file mode 100644 index a08e613..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(include('{cls.NoteModelListItem.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {cls.NoteModelListItem} - - @dataclass - class NoteModelListItem(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki_item' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support string - - return cls( - rep=rep, - part_to_read=rep.part_id - ) - - def get_note_model(self) -> PartHolder[NoteModel]: - self.part = PartHolder.from_file_manager(self.part_to_read) - return self.part # Todo: add filters in here - - rep: Representation - part: PartHolder[NoteModel] = field(init=False) - part_to_read: str - - @dataclass - class Representation(RepresentationBase): - parts: List[Union[dict, str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, List[str]]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(parts=data) # Support list of Note Models - - note_model_items = list(map(cls.NoteModelListItem.from_repr, rep.parts)) - return cls( - rep=rep, - note_models=[nm.get_note_model() for nm in note_model_items] - ) - - rep: Representation - note_models: List[PartHolder[NoteModel]] - - def execute(self) -> List[dict]: - return [model.part.encode_as_crowdanki() for model in self.note_models] diff --git a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py deleted file mode 100644 index d38fc27..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Union, Optional, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper, CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note - - -@dataclass -class NotesFromCrowdAnki(SharedBaseNotes, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - sort_order: Optional[List[str]] - reverse_sort: Optional[bool] - save_to_file: Optional[str] - - def execute(self) -> PartHolder[Notes]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - if ca_wrapper.children: - logging.warning("Child Decks / Sub-decks are not currently supported.") - - ca_models = self.ca_export.note_models - ca_notes = ca_wrapper.notes - - nm_id_to_name: dict = {model.id: model.name for model in ca_models} - note_list = [self.ca_note_to_note(note, nm_id_to_name) for note in ca_notes] - - notes = Notes.from_list_of_notes(note_list) # TODO: pass in sort method - return PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def ca_note_to_note(note: dict, nm_id_to_name: dict) -> Note: - wrapper = CrowdAnkiNoteWrapper(note) - - return Note( - note_model=nm_id_to_name[wrapper.note_model], - tags=wrapper.tags, - guid=wrapper.guid, - fields=wrapper.fields, - flags=wrapper.flags - ) diff --git a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py deleted file mode 100644 index 3a249ce..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py +++ /dev/null @@ -1,96 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.build_tasks.overrides.notes_override import NotesOverride -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.utils import blank_str_if_none - - -@dataclass -class NotesToCrowdAnki(YamlRepr, SharedBaseNotes): - @classmethod - def task_name(cls) -> str: - return r'notes_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('{NotesOverride.task_name()}', required=False) - case_insensitive_sort: bool(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesOverride} - - @dataclass - class Representation(RepresentationBase): - part_id: str - additional_items_to_add: Optional[dict] = field(default_factory=lambda: None) - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[dict] = field(default_factory=lambda: None) - case_insensitive_sort: Optional[bool] = field(default_factory=lambda: None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.part_id).part, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - additional_items_to_add=rep.additional_items_to_add or {}, - override=NotesOverride.from_repr(rep.override) if rep.override else None, - case_insensitive_sort=rep.case_insensitive_sort or True - ) - - rep: Representation - notes: Notes - additional_items_to_add: dict - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[NotesOverride] = field(default_factory=lambda: None) - case_insensitive_sort: bool = field(default=True) - - def execute(self, nm_name_to_id: dict) -> List[dict]: - - notes = self.notes.get_sorted_notes_copy( - sort_by_keys=self.sort_order, - reverse_sort=self.reverse_sort, - case_insensitive_sort=self.case_insensitive_sort - ) - - if self.override: - notes = [self.override.override(note) for note in notes] - - note_dicts = [self.note_to_ca_note(note, nm_name_to_id, self.additional_items_to_add) for note in notes] - - return note_dicts - - @staticmethod - def note_to_ca_note(note: Note, nm_name_to_id: dict, additional_items_to_add: dict) -> dict: - wrapper = CrowdAnkiNoteWrapper({ - "__type__": "Note", - "data": "" - }) - - for key, value in additional_items_to_add.items(): - wrapper.data[key] = blank_str_if_none(value) - - wrapper.fields = note.fields - wrapper.flags = note.flags - wrapper.guid = note.guid - wrapper.note_model = nm_name_to_id[note.note_model] - wrapper.tags = note.tags - - return wrapper.data diff --git a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py b/brain_brew/build_tasks/crowd_anki/shared_base_notes.py deleted file mode 100644 index 9ad016f..0000000 --- a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union, List - - -@dataclass -class SharedBaseNotes: - @staticmethod - def _get_sort_order(sort_order: Optional[Union[str, List[str]]]): - if isinstance(sort_order, list): - return sort_order - elif isinstance(sort_order, str): - return [sort_order] - return [] - - @staticmethod - def _get_reverse_sort(reverse_sort: Optional[bool]): - return reverse_sort or False - - # sort_order: Optional[List[str]] - # reverse_sort: Optional[bool] diff --git a/brain_brew/build_tasks/csvs/__init__.py b/brain_brew/build_tasks/csvs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/csvs/csvs_generate.py b/brain_brew/build_tasks/csvs/csvs_generate.py deleted file mode 100644 index 3cdfba1..0000000 --- a/brain_brew/build_tasks/csvs/csvs_generate.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import dataclass -import logging -from typing import List, Dict, Union - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import join_tags - - -@dataclass -class CsvsGenerate(SharedBaseCsvs, TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_csvs?' - - @classmethod - def yamale_schema(cls) -> str: # TODO: Use NotesOverride here, just as in NotesToCrowdAnki - return f'''\ - notes: str() - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - notes: str - - def encode(self): - return { - "notes": self.notes, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.notes), - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - notes: PartHolder[Notes] # TODO: Accept Multiple Note Parts - - def execute(self): - self.verify_contents() - - notes: List[Note] = self.notes.part.get_sorted_notes_copy( - sort_by_keys=[], - reverse_sort=False, - case_insensitive_sort=True - ) - self.verify_notes_match_note_model_mappings(notes) - - if not self.file_mappings[0].csv_file.column_headers: - logging.warning("Empty top level csv found. Populating headers automatically.") - model_name = self.file_mappings[0].note_model - self.file_mappings[0].csv_file.set_data_from_superset({}, column_header_override=list(f.value for f in self.note_model_mappings[model_name].columns_manually_mapped)) - - for fm in self.file_mappings: - csv_data: List[dict] = [self.note_to_csv_row(note, self.note_model_mappings) for note in notes - if note.note_model in fm.get_used_note_model_names()] - rows_by_guid = {row["guid"]: row for row in csv_data} - - fm.compile_data() - fm.set_relevant_data(rows_by_guid) - fm.write_file_on_close() - - def verify_notes_match_note_model_mappings(self, notes: List[Note]): - note_models_used = {note.note_model for note in notes} - errors = [TypeError(f"Unknown note model type '{model}' in deck part '{self.notes.part_id}'. " - f"Add mapping for that model.") - for model in note_models_used if model not in self.note_model_mappings.keys()] - - if errors: - raise Exception(errors) - - @staticmethod - def note_to_csv_row(note: Note, note_model_mappings: Dict[str, NoteModelMapping]) -> dict: - nm_name = note.note_model - row = note_model_mappings[nm_name].note_models[nm_name].part.zip_field_to_data(note.fields) - row["guid"] = note.guid - row["tags"] = join_tags(note.tags) - # TODO: Flags? - - return note_model_mappings[nm_name].note_fields_map_to_csv_row(row) diff --git a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py b/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py deleted file mode 100644 index 493a3e1..0000000 --- a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.utils import single_item_to_list, generate_anki_guid - - -@dataclass -class GenerateGuidsInCsvs(TopLevelBuildTask): - execute_immediately = True - - @classmethod - def task_name(cls) -> str: - return r'generate_guids_in_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_guids_in_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - source: Union[str, List[str]] - columns: Union[str, List[str]] - delimiter: Optional[str] = field(default=None) - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and all(CsvFile.delimiter_matches_file_type(value, f) for f in single_item_to_list(self.source)): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv_files = [CsvFile.create_or_get(csv) for csv in single_item_to_list(rep.source)] - for c in csv_files: - c.set_delimiter(rep.delimiter) - c.read_file() - return cls( - rep=rep, - sources=csv_files, - columns=rep.columns - ) - - rep: Representation - sources: List[CsvFile] - columns: List[str] - - def execute(self): - logging.info("Attempting to generate Guids") - - errors = [] - - # Make sure the columns exist on all - for source in self.sources: - missing = [c for c in self.columns if c not in source.column_headers] - if any(missing): - errors.append(f"Csv '{source.file_location}' does not contain all the specified columns: {missing}") - - if errors: - raise KeyError(errors) - - for source in self.sources: - guids_generated = 0 - data = source.get_data() - for row in data: - for column_name in row.keys(): - if column_name in self.columns and not row[column_name]: - row[column_name] = generate_anki_guid() - guids_generated += 1 - if guids_generated > 0: - logging.info(f"Generated {guids_generated} guids in csv '{source.file_location}'") - source.set_data(data) - source.write_file() - - logging.info("Generate guids complete") diff --git a/brain_brew/build_tasks/csvs/notes_from_csvs.py b/brain_brew/build_tasks/csvs/notes_from_csvs.py deleted file mode 100644 index bd2beb4..0000000 --- a/brain_brew/build_tasks/csvs/notes_from_csvs.py +++ /dev/null @@ -1,92 +0,0 @@ -from dataclasses import dataclass, field -from typing import Dict, List, Union, Optional - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Note, Notes -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import split_tags - - -@dataclass -class NotesFromCsvs(SharedBaseCsvs, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'notes_from_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - part_id: str - save_to_file: Optional[str] = field(default=None) - - def encode(self): - return { - "part_id": self.part_id, - "save_to_file": self.save_to_file, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - save_to_file=rep.save_to_file, - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - part_id: str - save_to_file: Optional[str] - - def execute(self): - self.verify_contents() - - csv_data_by_guid: Dict[str, dict] = {} - for csv_map in self.file_mappings: - csv_map.compile_data() - csv_data_by_guid = {**csv_data_by_guid, **csv_map.compiled_data} - csv_map.write_file_on_close() - csv_rows: List[dict] = list(csv_data_by_guid.values()) - - notes_part: List[Note] = [self.csv_row_to_note(row, self.note_model_mappings) for row in csv_rows] - - notes = Notes.from_list_of_notes(notes_part) - PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def csv_row_to_note(row: dict, note_model_mappings: Dict[str, NoteModelMapping]) -> Note: - note_model_name = row["note_model"] # TODO: Use object - row_nm: NoteModelMapping = note_model_mappings[note_model_name] - - filtered_fields = row_nm.csv_row_map_to_note_fields(row) - - guid = filtered_fields.pop("guid") - tags = split_tags(filtered_fields.pop("tags")) - flags = filtered_fields.pop("flags") if "flags" in filtered_fields else 0 - - fields = row_nm.field_values_in_note_model_order(note_model_name, filtered_fields) - - return Note(guid=guid, tags=tags, note_model=note_model_name, fields=fields, flags=flags) diff --git a/brain_brew/build_tasks/csvs/shared_base_csvs.py b/brain_brew/build_tasks/csvs/shared_base_csvs.py deleted file mode 100644 index 916b61d..0000000 --- a/brain_brew/build_tasks/csvs/shared_base_csvs.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import List, Dict - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping - - -@dataclass -class SharedBaseCsvs: - @dataclass(init=False) - class Representation(RepresentationBase): - file_mappings: List[FileMapping.Representation] - note_model_mappings: List[NoteModelMapping.Representation] - - def __init__(self, file_mappings, note_model_mappings): - self.file_mappings = list(map(FileMapping.Representation.from_dict, file_mappings)) - self.note_model_mappings = list(map(NoteModelMapping.Representation.from_dict, note_model_mappings)) - - def get_file_mappings(self) -> List[FileMapping]: - return list(map(FileMapping.from_repr, self.file_mappings)) - - file_mappings: List[FileMapping] - note_model_mappings: Dict[str, NoteModelMapping] - - @classmethod - def map_nmm(cls, nmm_to_map): - nmm = NoteModelMapping.from_repr(nmm_to_map) - return nmm.get_note_model_mapping_dict() - - def verify_contents(self): - errors = [] - - for nm in self.note_model_mappings.values(): - try: - nm.verify_contents() - except KeyError as e: - errors.append(e) - - # Check all referenced note models have a mapping - for csv_map in self.file_mappings: - for nm in csv_map.get_used_note_model_names(): - if nm not in self.note_model_mappings.keys(): - errors.append(f"Missing Note Model Map for {nm}") - - # Check each of the Csvs (or their derivatives) contain all the necessary columns for their stated note model - for cfm in self.file_mappings: - note_model_names = cfm.get_used_note_model_names() - available_columns = cfm.get_available_columns() - - referenced_note_models_maps = [value for key, value in self.note_model_mappings.items() - if key in note_model_names] - for nm_map in referenced_note_models_maps: - for holder in nm_map.note_models.values(): - if holder.part.name in note_model_names: - missing_columns = [col for col in holder.part.field_names_lowercase if - col not in nm_map.csv_headers_map_to_note_fields(available_columns)] - if missing_columns: - logging.warning(f"Csvs are missing columns from {holder.part_id} {missing_columns}") - - if errors: - raise Exception(errors) diff --git a/brain_brew/build_tasks/deck_parts/__init__.py b/brain_brew/build_tasks/deck_parts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/deck_parts/from_yaml_part.py b/brain_brew/build_tasks/deck_parts/from_yaml_part.py deleted file mode 100644 index a87b54d..0000000 --- a/brain_brew/build_tasks/deck_parts/from_yaml_part.py +++ /dev/null @@ -1,61 +0,0 @@ -from abc import ABCMeta -from dataclasses import dataclass -from typing import Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.representation.yaml.notes import Notes -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class FromYamlPartBase(BuildPartTask, metaclass=ABCMeta): - part_type = None - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=cls.part_type.from_yaml_file(rep.file)) - ) - - def execute(self): - pass - - rep: Representation - part: YamlObject - - -@dataclass -class NotesFromYamlPart(FromYamlPartBase): - @classmethod - def task_name(cls) -> str: - return r'notes_from_yaml_part' - - part_type = Notes - - -@dataclass -class MediaGroupFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'media_group_from_yaml_part' - - part_type = MediaGroup diff --git a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py deleted file mode 100644 index b58c16f..0000000 --- a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py +++ /dev/null @@ -1,67 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.build_tasks.overrides.headers_override import HeadersOverride -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersFromYamlPart(BuildPartTask): - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - override: include('{HeadersOverride.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {HeadersOverride} - - @classmethod - def task_name(cls) -> str: - return r'headers_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_yaml_part' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - override: Optional[dict] = field(default_factory=lambda: None) - - def encode(self): - d = { - "part_id": self.part_id, - "file": self.file - } - if self.override: - d.setdefault("override", self.override) - return d - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - headers=PartHolder.override_or_create( - part_id=rep.part_id, - save_to_file=None, - part=Headers.from_yaml_file(rep.file) - ).part, - override=HeadersOverride.from_repr(rep.override) if rep.override else None - ) - - rep: Representation - headers: Headers - override: Optional[HeadersOverride] - - def execute(self): - if self.override: - self.headers = self.override.override(self.headers) diff --git a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py b/brain_brew/build_tasks/deck_parts/media_group_from_folder.py deleted file mode 100644 index 4df8644..0000000 --- a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py +++ /dev/null @@ -1,58 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromFolder(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - filter_blacklist_from_parts: List[str] = field(default_factory=list) - filter_whitelist_from_parts: List[str] = field(default_factory=list) - recursive: Optional[bool] = field(default=True) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(rep.source, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - # match criteria - ) - ) - - rep: Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py b/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py deleted file mode 100644 index cff5da2..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py +++ /dev/null @@ -1,78 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template - - -@dataclass -class NoteModelFromHTMLParts(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_html_parts' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - model_id: str() - css_file: str() - fields: list(include('{Field.task_name()}')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field} - - @dataclass - class Representation(RepresentationBase): - part_id: str - model_id: str - css_file: str - fields: List[dict] - templates: List[dict] - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - model_id=rep.model_id, - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=True), - fields=list(map(Field.from_dict, rep.fields)), - templates=list(holder.part for holder in map(PartHolder.from_file_manager, rep.templates)), - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - model_id: str - css: str - fields: List[Field] - templates: List[Template] - model_name: str - save_to_file: Optional[str] - - def execute(self): - part = NoteModel( - name=self.model_name, - id=self.model_id, - css=self.css, - fields=self.fields, - templates=self.templates, - required_fields_per_template=[] - ) - - PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py deleted file mode 100644 index bdf5679..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.from_yaml_part import FromYamlPartBase -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'note_models?_from_yaml_part' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - # TODO: Overrides - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=NoteModel.from_yaml_file(rep.file)) - ) - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py b/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py deleted file mode 100644 index 4b1d707..0000000 --- a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class SaveMediaGroupsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_media_groups_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_media_groups?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_folder: Optional[bool] = field(default=None) - recursive: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_folder=rep.clear_folder or False, - recursive=rep.recursive or False - ) - - rep: Representation - parts: List[MediaGroup] - folder: str - clear_folder: bool - recursive: bool - - def execute(self): - save_media_groups_to_location(self.parts, self.folder, self.clear_folder, self.recursive) diff --git a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py b/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py deleted file mode 100644 index a05ab04..0000000 --- a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional, Dict - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.transformers.save_note_model_to_location import save_note_model_to_location - - -@dataclass -class SaveNoteModelsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_note_models_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_note_models?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_existing: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_existing=rep.clear_existing or False, - ) - - rep: Representation - parts: List[NoteModel] - folder: str - clear_existing: bool - - def execute(self) -> Dict[str, str]: - model_yaml_files: Dict[str, str] = {} - for model in self.parts: - model_yaml_files.setdefault( - model.name, - save_note_model_to_location(model, self.folder, self.clear_existing) - ) - return model_yaml_files diff --git a/brain_brew/build_tasks/overrides/__init__.py b/brain_brew/build_tasks/overrides/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/overrides/headers_override.py b/brain_brew/build_tasks/overrides/headers_override.py deleted file mode 100644 index 3f0b436..0000000 --- a/brain_brew/build_tasks/overrides/headers_override.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"headers_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - crowdanki_uuid: Optional[str] = field(default=None) - deck_description_html_file: Optional[str] = field(default=None) - name: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowdanki_uuid=rep.crowdanki_uuid, - deck_desc_html_file=HTMLFile.create_or_get(rep.deck_description_html_file), - name=rep.name - ) - - rep: Representation - crowdanki_uuid: Optional[str] - deck_desc_html_file: Optional[HTMLFile] - name: Optional[str] - - def override(self, header: Headers): - if self.deck_desc_html_file: - header.description = self.deck_desc_html_file.get_data(deep_copy=True) - - if self.crowdanki_uuid: - header.crowdanki_uuid = self.crowdanki_uuid - - if self.name: - header.name = self.name - - return header diff --git a/brain_brew/build_tasks/overrides/notes_override.py b/brain_brew/build_tasks/overrides/notes_override.py deleted file mode 100644 index 8ce4604..0000000 --- a/brain_brew/build_tasks/overrides/notes_override.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.notes import Note - - -@dataclass -class NotesOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"notes_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_model: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_model: Optional[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - note_model=rep.note_model - ) - - rep: Representation - note_model: Optional[str] - - def override(self, note: Note): - if self.note_model: - note.note_model = self.note_model - - return note diff --git a/brain_brew/commands/__init__.py b/brain_brew/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/argument_reader.py b/brain_brew/commands/argument_reader.py deleted file mode 100644 index 88d1371..0000000 --- a/brain_brew/commands/argument_reader.py +++ /dev/null @@ -1,117 +0,0 @@ -from enum import Enum - -import sys -from argparse import ArgumentParser - -from brain_brew.front_matter import latest_version_number -from brain_brew.commands.init_repo.init_repo import InitRepo -from brain_brew.commands.run_recipe.run_recipe import RunRecipe -from brain_brew.interfaces.command import Command - - -class Commands(Enum): - RUN_RECIPE = "run" - INIT_REPO = "init" - - -class BBArgumentReader(ArgumentParser): - def __init__(self, test_mode=False): - super().__init__( - prog="brainbrew", - description='Manage Flashcards by transforming them to various types.' - ) - - self._set_parser_arguments() - - if not test_mode and len(sys.argv) == 1: - self.print_help(sys.stderr) - sys.exit(1) - - def _set_parser_arguments(self): - - subparsers = self.add_subparsers(parser_class=ArgumentParser, help='Commands that can be run', dest="command") - - parser_run = subparsers.add_parser( - Commands.RUN_RECIPE.value, - help="Run a recipe file. This will convert some data to another format, based on the instructions in the recipe file." - ) - parser_run.add_argument( - "recipe", - metavar="recipe", - type=str, - help="Yaml file to use as the recipe" - ) - parser_run.add_argument( - "--verify", "-v", - action="store_true", - dest="verify_only", - default=False, - help="Only verify the recipe contents, without running it." - ) - - parser_init = subparsers.add_parser( - Commands.INIT_REPO.value, - help="Initialise a Brain Brew repository, using a CrowdAnki export as the base data." - ) - parser_init.add_argument( - "crowdanki_folder", - metavar="crowdanki_folder", - type=str, - help="The folder that stores the CrowdAnki files to build this repo from" - ) - parser_init.add_argument( - '--delimiter', - dest='delimiter', - action='store', - help="Set the delimiter for Csv files to specific character", - type=str - ) - parser_init.add_argument( - "--delimitertab", "--tab", - action="store_true", - dest="delimiter_tab", - default=False, - help="Use tabs as the delimiter for Csv files" - ) - - def get_parsed(self, override_args=None) -> Command: - parsed_args = self.parse_args(args=override_args) - - if parsed_args.command == Commands.RUN_RECIPE.value: - # Required - recipe = self.error_if_blank(parsed_args.recipe) - - # Optional - verify_only = parsed_args.verify_only - - return RunRecipe( - recipe_file_name=recipe, - verify_only=verify_only - ) - - if parsed_args.command == Commands.INIT_REPO.value: - # Required - crowdanki_folder = parsed_args.crowdanki_folder - delimiter = parsed_args.delimiter - delimiter_tab = parsed_args.delimiter_tab - - return InitRepo( - crowdanki_folder=crowdanki_folder, - delimiter="\t" if delimiter_tab else delimiter - ) - - raise KeyError("Unknown Command") - - def error_if_blank(self, arg): - if arg == "" or arg is None: - self.error("Required argument missing") - return arg - - def error(self, message): - sys.stderr.write('error: %s\n' % message) - self.print_help() - sys.exit(2) - - def print_help(self, message=None): - print(f"Brain Brew v{latest_version_number()}") - super().print_help(message) diff --git a/brain_brew/commands/init_repo/__init__.py b/brain_brew/commands/init_repo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/init_repo/init_repo.py b/brain_brew/commands/init_repo/init_repo.py deleted file mode 100644 index 3963376..0000000 --- a/brain_brew/commands/init_repo/init_repo.py +++ /dev/null @@ -1,208 +0,0 @@ -import os -from dataclasses import dataclass -from typing import List - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask, BuildPartTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.interfaces.command import Command -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import create_path_if_not_exists, filename_from_full_path, folder_name_from_full_path - -RECIPE_MEDIA = "deck_media" -RECIPE_HEADERS = "deck_headers" -RECIPE_NOTES = "deck_notes" - -LOC_RECIPES = "recipes/" -LOC_BUILD = "build/" -LOC_DATA = "src/data/" -LOC_HEADERS = "src/headers/" -LOC_NOTE_MODELS = "src/note_models/" -LOC_MEDIA = "src/media/" - - -@dataclass -class InitRepo(Command): - crowdanki_folder: str - delimiter: str - - def execute(self): - self.setup_repo_structure() - - # Create the Deck Parts used - headers_ca, note_models_all_ca, notes_ca, media_group_ca = self.parts_from_crowdanki(self.crowdanki_folder) - - headers = headers_ca.execute().part - headers_name = LOC_HEADERS + "header1.yaml" - headers.dump_to_yaml(headers_name) - # TODO: desc file - - note_models = [m.part for m in note_models_all_ca.execute()] - - notes = notes_ca.execute().part - used_note_models_in_notes = notes.get_all_known_note_model_names() - - media_group_ca.execute() - - note_model_mappings = [NoteModelMapping.Representation([model.name for model in note_models])] - file_mappings: List[FileMapping.Representation] = [] - - csv_files = [] - - for model in note_models: - if model.name in used_note_models_in_notes: - csv_file_path = os.path.join(LOC_DATA, CsvFile.to_filename_csv(model.name, self.delimiter)) - column_headers = ["guid"] + model.field_names_lowercase + ["tags"] - CsvFile.create_file_with_headers(csv_file_path, column_headers, delimiter=self.delimiter) - - file_mappings.append(FileMapping.Representation( - file=csv_file_path, - note_model=model.name, - delimiter=self.delimiter - )) - - csv_files.append(csv_file_path) - - deck_path = os.path.join(LOC_BUILD, folder_name_from_full_path(self.crowdanki_folder)) - - # Generate the Source files that will be kept in the repo - save_note_models_to_folder = SaveNoteModelsToFolder.from_repr(SaveNoteModelsToFolder.Representation( - [m.name for m in note_models], LOC_NOTE_MODELS, True - )) - model_name_to_file_dict = save_note_models_to_folder.execute() - - save_media_to_folder = SaveMediaGroupsToFolder.from_repr(SaveMediaGroupsToFolder.Representation( - parts=[RECIPE_MEDIA], folder=LOC_MEDIA, recursive=True, clear_folder=True - )) - save_media_to_folder.execute() - - generate_csvs = CsvsGenerate.from_repr({ - 'notes': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - generate_csvs.execute() - - # Create Recipes - - # Anki to Source - headers_recipe, note_models_all_recipe, notes_recipe, media_group_recipe = self.parts_from_crowdanki(deck_path) - - build_part_tasks: List[BuildPartTask] = [ - headers_recipe, - notes_recipe, - note_models_all_recipe, - media_group_recipe, - ] - dp_builder = PartsBuilder(build_part_tasks) - - top_level_tasks: List[TopLevelBuildTask] = [dp_builder, save_media_to_folder, generate_csvs] - self.create_yaml_from_top_level(top_level_tasks, os.path.join(LOC_RECIPES, "anki_to_source")) - - # Source to Anki - note_models_from_yaml = [ - NoteModelsFromYamlPart.from_repr(NoteModelsFromYamlPart.Representation(name, file)) - for name, file in model_name_to_file_dict.items() - ] - - media_group_from_folder = MediaGroupFromFolder.from_repr(MediaGroupFromFolder.Representation( - part_id=RECIPE_MEDIA, source=LOC_MEDIA, recursive=True - )) - - headers_from_yaml = HeadersFromYamlPart.from_repr(HeadersFromYamlPart.Representation( - part_id=RECIPE_HEADERS, file=headers_name - )) - - notes_from_csv = NotesFromCsvs.from_repr({ - 'part_id': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - - build_part_tasks: List[BuildPartTask] = note_models_from_yaml + [ - headers_from_yaml, - notes_from_csv, - media_group_from_folder, - ] - dp_builder = PartsBuilder(build_part_tasks) - - generate_guids_in_csv = GenerateGuidsInCsvs.from_repr(GenerateGuidsInCsvs.Representation( - source=csv_files, columns=["guid"], delimiter=self.delimiter - )) - - generate_crowdanki = CrowdAnkiGenerate.from_repr(CrowdAnkiGenerate.Representation( - folder=deck_path, - notes=NotesToCrowdAnki.Representation( - part_id=RECIPE_NOTES - ).encode(), - headers=RECIPE_HEADERS, - media=MediaGroupToCrowdAnki.Representation( - parts=[RECIPE_MEDIA] - ).encode(), - note_models=NoteModelsToCrowdAnki.Representation( - parts=[NoteModelsToCrowdAnki.NoteModelListItem.Representation(name).encode() - for name, file in model_name_to_file_dict.items()] - ).encode() - )) - - top_level_tasks: List[TopLevelBuildTask] = [generate_guids_in_csv, dp_builder, generate_crowdanki] - source_to_anki_path = os.path.join(LOC_RECIPES, "source_to_anki.yaml") - self.create_yaml_from_top_level(top_level_tasks, source_to_anki_path) - - print(f"\nRepo Init complete. You should now run `brainbrew run {source_to_anki_path}`") - - @staticmethod - def create_yaml_from_top_level(top_tasks: List[TopLevelBuildTask], filepath: str): - tl_builder = TopLevelBuilder(top_tasks) - - encoded_top_level_tasks = tl_builder.encode() - # print(encoded_top_level_tasks) - - model_yaml_file_name = YamlObject.to_filename_yaml(filepath) - YamlObject.dump_to_yaml_file(model_yaml_file_name, encoded_top_level_tasks) - - @staticmethod - def parts_from_crowdanki(folder: str): - headers_ca = HeadersFromCrowdAnki.from_repr(HeadersFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_HEADERS - )) - note_models_all_ca = NoteModelsAllFromCrowdAnki.from_repr(NoteModelsAllFromCrowdAnki.Representation( - source=folder - )) - notes_ca = NotesFromCrowdAnki.from_repr(NotesFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_NOTES - )) - media_group_ca = MediaGroupFromCrowdAnki.from_repr(MediaGroupFromFolder.Representation( - source=folder, part_id=RECIPE_MEDIA - )) - return headers_ca, note_models_all_ca, notes_ca, media_group_ca - - @staticmethod - def setup_repo_structure(): - create_path_if_not_exists(LOC_RECIPES) - create_path_if_not_exists(LOC_BUILD) - create_path_if_not_exists(LOC_DATA) - create_path_if_not_exists(LOC_HEADERS) - create_path_if_not_exists(LOC_NOTE_MODELS) - create_path_if_not_exists(LOC_MEDIA) diff --git a/brain_brew/commands/run_recipe/__init__.py b/brain_brew/commands/run_recipe/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/run_recipe/build_task.py b/brain_brew/commands/run_recipe/build_task.py deleted file mode 100644 index a95d128..0000000 --- a/brain_brew/commands/run_recipe/build_task.py +++ /dev/null @@ -1,45 +0,0 @@ -from abc import ABCMeta, abstractmethod -from typing import Dict, Type, Set, Optional - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class BuildTask(YamlRepr, object, metaclass=ABCMeta): - execute_immediately: bool = False - accepts_list_of_self: bool = True - rep: Optional[RepresentationBase] - - def encode_rep(self) -> Dict[str, any]: - return self.rep.encode() - - @abstractmethod - def execute(self): - pass - - @classmethod - def task_regex(cls) -> str: - return cls.task_name() - - @classmethod - def get_all_task_regex(cls, subclasses: Set[Type['BuildTask']]) -> Dict[str, Type['BuildTask']]: - task_regex_matches: Dict[str, Type[BuildTask]] = {} - - for sc in subclasses: - if sc.task_regex in task_regex_matches: - raise KeyError(f"Multiple instances of task regex '{sc.task_regex}'") - elif sc.task_regex == "" or sc.task_regex is None: - raise KeyError(f"Unknown task regex in {sc.__name__}") - - task_regex_matches.setdefault(sc.task_regex(), sc) - - # logging.debug(f"Known build tasks: {known_build_tasks}") - return task_regex_matches - - -class TopLevelBuildTask(BuildTask, metaclass=ABCMeta): - pass - - -class BuildPartTask(BuildTask, metaclass=ABCMeta): - execute_immediately: bool = True diff --git a/brain_brew/commands/run_recipe/parts_builder.py b/brain_brew/commands/run_recipe/parts_builder.py deleted file mode 100644 index 0b3fa23..0000000 --- a/brain_brew/commands/run_recipe/parts_builder.py +++ /dev/null @@ -1,64 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_model_single_from_crowd_anki import NoteModelSingleFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.from_yaml_part import NotesFromYamlPart, MediaGroupFromYamlPart -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_html_parts import NoteModelFromHTMLParts -from brain_brew.commands.run_recipe.build_task import BuildTask, BuildPartTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder - - -@dataclass -class PartsBuilder(RecipeBuilder, TopLevelBuildTask): - tasks: List[BuildPartTask] - accepts_list_of_self: bool = False - - @classmethod - def task_name(cls) -> str: - return r'build_parts' - - @classmethod - def task_regex(cls) -> str: - return r'build_parts?' - - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - return BuildPartTask.get_all_task_regex(cls.yamale_dependencies()) - - @classmethod - def from_repr(cls, data: List[dict]): - if not isinstance(data, list): - raise TypeError(f"PartsBuilder needs a list") - return cls.from_list(data) - - def encode(self) -> dict: - pass - - def encode_rep(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_schema(cls) -> str: - return cls.build_yamale_root_node(cls.yamale_dependencies()) - - @classmethod - def yamale_dependencies(cls) -> Set[Type[BuildPartTask]]: - return { - NotesFromCsvs, - NotesFromYamlPart, HeadersFromYamlPart, NoteModelsFromYamlPart, MediaGroupFromYamlPart, - MediaGroupFromFolder, - NoteModelFromHTMLParts, NoteModelsFromYamlPart, NoteModelSingleFromCrowdAnki, NoteModelsAllFromCrowdAnki, - HeadersFromCrowdAnki, MediaGroupFromCrowdAnki, NotesFromCrowdAnki - } diff --git a/brain_brew/commands/run_recipe/recipe_builder.py b/brain_brew/commands/run_recipe/recipe_builder.py deleted file mode 100644 index a4d2f87..0000000 --- a/brain_brew/commands/run_recipe/recipe_builder.py +++ /dev/null @@ -1,82 +0,0 @@ -import re -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from textwrap import indent -from typing import Dict, List, Type, Set - -from brain_brew.commands.run_recipe.build_task import BuildTask -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class RecipeBuilder(YamlObject, metaclass=ABCMeta): - tasks: List[BuildTask] - - def tasks_to_encoded(self) -> list: - return [{task.task_name(): task.encode_rep()} for task in self.tasks] - - @classmethod - def from_list(cls, data: List[dict]): - tasks = cls.read_tasks(data) - return cls( - tasks=tasks - ) - - @classmethod - @abstractmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - pass - - @classmethod - def build_yamale_root_node(cls, subclasses: Set[Type['BuildTask']]) -> str: - task_list = [] - for c in sorted(subclasses, key=lambda x: x.task_name()): - task_command = f"any(include('{c.task_name()}'), list(include('{c.task_name()}')))"\ - if c.accepts_list_of_self else f"include('{c.task_name()}')" - task_list.append(f"map({task_command}, key=regex('{c.task_regex()}', ignore_case=True))") - - final_tasks: str = "list(\n" + indent(",\n".join(task_list), ' ') + "\n)\n" - - return final_tasks - - @classmethod - def read_tasks(cls, tasks: List[dict]) -> list: - task_regex_matches = cls.known_task_dict() - build_tasks = [] - - def find_matching_task(task_n): - for regex, task_to_run in task_regex_matches.items(): - if re.match(regex, task_n, re.RegexFlag.IGNORECASE): - return task_to_run - return None - - # Tasks - for task in tasks: - task_keys = list(task.keys()) - if len(task_keys) != 1: - raise KeyError(f"Task should only contain 1 entry, but contains {task_keys} instead. " - f"Missing list separator '-'?", task) - - task_name = task_keys[0] - task_arguments = task[task_keys[0]] - - matching_task = find_matching_task(task_name) - if matching_task is not None: - if matching_task.accepts_list_of_self and isinstance(task_arguments, list): - task_or_tasks = [matching_task.from_repr(t_arg) for t_arg in task_arguments] - else: - task_or_tasks = [matching_task.from_repr(task_arguments)] - - for inner_task in task_or_tasks: - build_tasks.append(inner_task) - if inner_task.execute_immediately: - inner_task.execute() - else: - raise KeyError(f"Unknown task '{task_name}'") # TODO: check this first on all and return all errors - - return build_tasks - - def execute(self): - for task in self.tasks: - if not task.execute_immediately: - task.execute() diff --git a/brain_brew/commands/run_recipe/run_recipe.py b/brain_brew/commands/run_recipe/run_recipe.py deleted file mode 100644 index 0ed18f7..0000000 --- a/brain_brew/commands/run_recipe/run_recipe.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from brain_brew.interfaces.command import Command -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.configuration.yaml_verifier import YamlVerifier - - -@dataclass -class RunRecipe(Command): - recipe_file_name: str - verify_only: bool - - def execute(self): - # Parse Build Config File - YamlVerifier() - recipe = TopLevelBuilder.parse_and_read(self.recipe_file_name, self.verify_only) - - if not self.verify_only: - recipe.execute() diff --git a/brain_brew/commands/run_recipe/top_level_builder.py b/brain_brew/commands/run_recipe/top_level_builder.py deleted file mode 100644 index 4650f54..0000000 --- a/brain_brew/commands/run_recipe/top_level_builder.py +++ /dev/null @@ -1,87 +0,0 @@ -from textwrap import indent, dedent -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import BuildTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class TopLevelBuilder(YamlRepr, RecipeBuilder): - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - values = TopLevelBuildTask.get_all_task_regex(cls.yamale_dependencies()) - return values - - @classmethod - def build_yamale(cls): - separator = '\n---\n' - top_level = cls.yamale_dependencies() - - builder: List[str] = [cls.build_yamale_root_node(top_level), separator] - - def to_sorted_yamale_string(lines: Set[Type[BuildTask]]): - return [f'''{line.task_name()}:\n{indent(dedent(line.yamale_schema()), ' ')}''' - for line in sorted(lines, key=lambda x: x.task_name())] - - # Schema - builder += to_sorted_yamale_string(top_level) - - builder.append(separator) - - # Dependencies - def resolve_dependencies(deps: Set[Type[BuildTask]]) -> Set[Type[BuildTask]]: - result = set() - for d in deps: - result.add(d) - result = result.union(resolve_dependencies(d.yamale_dependencies())) - return result - - children = resolve_dependencies(cls.yamale_dependencies()) - builder += to_sorted_yamale_string({dep for dep in children if dep not in top_level}) - - return '\n'.join(builder) - - @classmethod - def parse_and_read(cls, filename, verify_only: bool) -> 'TopLevelBuilder': - recipe_data = cls.read_to_dict(filename) - - from brain_brew.configuration.yaml_verifier import YamlVerifier - YamlVerifier.get_instance().verify_recipe(filename) - - if verify_only: - return None - - return cls.from_list(recipe_data) - - @classmethod - def task_name(cls) -> str: - pass - - @classmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def from_repr(cls, data: dict): - pass - - def encode(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_dependencies(cls) -> Set[Type[TopLevelBuildTask]]: - return { - PartsBuilder, - CrowdAnkiGenerate, CsvsGenerate, - GenerateGuidsInCsvs, SaveMediaGroupsToFolder, SaveNoteModelsToFolder - } diff --git a/brain_brew/configuration/__init__.py b/brain_brew/configuration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/configuration/anki_field.py b/brain_brew/configuration/anki_field.py deleted file mode 100644 index 878e0ac..0000000 --- a/brain_brew/configuration/anki_field.py +++ /dev/null @@ -1,16 +0,0 @@ -class AnkiField: - name: str - anki_name: str - default_value: any - - def __init__(self, anki_name, name=None, default_value=None): - self.anki_name = anki_name - self.name = name if name is not None else anki_name - self.default_value = default_value - - def append_name_if_differs(self, dict_to_add_to: dict, value): - if value != self.default_value: - dict_to_add_to.setdefault(self.name, value) - - def does_differ(self, value): - return value != self.default_value diff --git a/brain_brew/configuration/file_manager.py b/brain_brew/configuration/file_manager.py deleted file mode 100644 index f99e31f..0000000 --- a/brain_brew/configuration/file_manager.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Dict, Union - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.yaml.yaml_object import YamlObject - - -class FileManager: - __instance = None - - known_files_dict: Dict[str, SourceFile] - known_parts: Dict[str, PartHolder[YamlObject]] - - def __init__(self): - if FileManager.__instance is None: - FileManager.__instance = self - else: - raise Exception("Multiple FileManagers created") - - self.known_files_dict = {} - self.known_parts = {} - - @staticmethod - def get_instance() -> 'FileManager': - return FileManager.__instance - - @staticmethod - def clear_instance(): - if FileManager.__instance: - FileManager.__instance = None - - # Source Files - - def register_file(self, full_path, file): - if full_path in self.known_files_dict: - raise FileExistsError(f"File already known to FileManager, cannot be registered twice: {full_path}") - self.known_files_dict.setdefault(full_path, file) - - def file_if_exists(self, file_location) -> Union[SourceFile, None]: - if file_location in self.known_files_dict.keys(): - return self.known_files_dict[file_location] - return None - - # Deck Parts - - def register_part(self, dp: PartHolder) -> PartHolder: - if dp.part_id in self.known_parts: - raise KeyError(f"Cannot use same name '{dp.part_id}' for multiple Deck Parts") - self.known_parts.setdefault(dp.part_id, dp) - return dp - - def get_part_if_exists(self, dp_name) -> Union[PartHolder[YamlObject], None]: - return self.known_parts.get(dp_name) - - def get_part(self, name: str): - if name not in self.known_parts: - raise KeyError(f"Cannot find Deck Part '{name}'") - return self.known_parts[name] diff --git a/brain_brew/configuration/part_holder.py b/brain_brew/configuration/part_holder.py deleted file mode 100644 index 5140849..0000000 --- a/brain_brew/configuration/part_holder.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional, TypeVar, Generic - -T = TypeVar('T') - - -@dataclass -class PartHolder(Generic[T]): - part_id: str - save_to_file: Optional[str] - part: T - - file_manager = None - - @classmethod - def get_file_manager(cls): - if not cls.file_manager: - from brain_brew.configuration.file_manager import FileManager - cls.file_manager = FileManager.get_instance() - return cls.file_manager - - @classmethod - def from_file_manager(cls, part_id: str) -> T: - return cls.get_file_manager().get_part(part_id) - - @classmethod - def override_or_create(cls, part_id: str, save_to_file: Optional[str], part: T): - fm = cls.get_file_manager() - - dp = fm.get_part_if_exists(part_id) - if dp is None: - dp = fm.register_part(PartHolder(part_id, save_to_file, part)) - else: - logging.warning(f"Overwriting existing Deck Part '{part_id}'") - dp.part = part - dp.save_to_file = save_to_file - - dp.write_to_file() - - return dp - - def write_to_file(self): - if self.save_to_file is not None: - self.part.dump_to_yaml(self.save_to_file) diff --git a/brain_brew/configuration/representation_base.py b/brain_brew/configuration/representation_base.py deleted file mode 100644 index 08453c9..0000000 --- a/brain_brew/configuration/representation_base.py +++ /dev/null @@ -1,28 +0,0 @@ -import inspect -import logging - - -class RepresentationBase: - @classmethod - def from_dict(cls, data: dict): - expected_values = { - k: v for k, v in data.items() - if k in inspect.signature(cls).parameters - } - - if len(expected_values) != len(data): - logging.warning(f"Unexpected values found when creating '{cls.__name__}': " - f"{[k for k, v in data.items() if k not in list(expected_values.keys())]}" - "\n!!! Please report this error if it seems strange") - - return cls(**expected_values) - - def encode(self): - return {key: value for key, value in self.__dict__.items() if self.encode_filter(key, value)} - - def encode_filter(self, key, value): - if value is None: - return False - if not value: - return False - return True diff --git a/brain_brew/configuration/yaml_verifier.py b/brain_brew/configuration/yaml_verifier.py deleted file mode 100644 index 89f1bbf..0000000 --- a/brain_brew/configuration/yaml_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import os - -import yamale -from yamale import YamaleError -from yamale.schema import Schema -from yamale.validators import DefaultValidators - -validators = DefaultValidators.copy() - - -class YamlVerifier: - __instance = None - recipe_schema: Schema - - def __init__(self): - if YamlVerifier.__instance is None: - YamlVerifier.__instance = self - else: - raise Exception("Multiple YamlVerifiers created") - - path = os.path.join(os.path.dirname(__file__), "../schemas/recipe.yaml") - self.recipe_schema = yamale.make_schema(path, parser='ruamel', validators=validators) - - @staticmethod - def get_instance() -> 'YamlVerifier': - return YamlVerifier.__instance - - def verify_recipe(self, filename): - data = yamale.make_data(filename) - try: - yamale.validate(self.recipe_schema, data) - except YamaleError as e: - print('Validation failed!\n') - for result in e.results: - print("Error validating data '%s' with '%s'\n\t" % (result.data, result.schema)) - for error in result.errors: - print('\t%s' % error) - exit(1) - logging.info(f"Builder file {filename} is ✔ good") diff --git a/brain_brew/front_matter.py b/brain_brew/front_matter.py deleted file mode 100644 index e7b0e27..0000000 --- a/brain_brew/front_matter.py +++ /dev/null @@ -1,2 +0,0 @@ -def latest_version_number(): - return "0.3.11" diff --git a/brain_brew/interfaces/__init__.py b/brain_brew/interfaces/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/interfaces/command.py b/brain_brew/interfaces/command.py deleted file mode 100644 index 3c25a23..0000000 --- a/brain_brew/interfaces/command.py +++ /dev/null @@ -1,7 +0,0 @@ -from abc import ABC, abstractmethod - - -class Command(ABC): - @abstractmethod - def execute(self): - pass diff --git a/brain_brew/interfaces/media_container.py b/brain_brew/interfaces/media_container.py deleted file mode 100644 index 11c7acc..0000000 --- a/brain_brew/interfaces/media_container.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Set - - -class MediaContainer(ABC): - @abstractmethod - def get_all_media_references(self) -> Set[str]: - pass diff --git a/brain_brew/interfaces/yamale_verifyable.py b/brain_brew/interfaces/yamale_verifyable.py deleted file mode 100644 index fdf99eb..0000000 --- a/brain_brew/interfaces/yamale_verifyable.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod - - -class YamlRepr(ABC): - @classmethod - @abstractmethod - def task_name(cls) -> str: - pass - - @classmethod - @abstractmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def yamale_dependencies(cls) -> set: - return set() - - @classmethod - @abstractmethod - def from_repr(cls, data: dict): - pass diff --git a/brain_brew/main.py b/brain_brew/main.py deleted file mode 100644 index 5a36fd8..0000000 --- a/brain_brew/main.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from brain_brew.commands.argument_reader import BBArgumentReader -# sys.path.append(os.path.join(os.path.dirname(__file__), "dist")) -# sys.path.append(os.path.dirname(__file__)) -from brain_brew.configuration.file_manager import FileManager - - -def main(): - logging.basicConfig(level=logging.DEBUG) - - # Read in Arguments - argument_reader = BBArgumentReader() - command = argument_reader.get_parsed() - - # Create Singleton FileManager - FileManager() - - command.execute() - - -if __name__ == "__main__": - main() diff --git a/brain_brew/representation/__init__.py b/brain_brew/representation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/__init__.py b/brain_brew/representation/generic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/csv_file.py b/brain_brew/representation/generic/csv_file.py deleted file mode 100644 index 1f0633d..0000000 --- a/brain_brew/representation/generic/csv_file.py +++ /dev/null @@ -1,116 +0,0 @@ -import csv -from pathlib import Path -import re -import logging -from enum import Enum -from typing import List, Optional - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import create_path_if_not_exists, list_of_str_to_lowercase, sort_dict - -_encoding = "utf-8" - - -class CsvKeys(Enum): - GUID = "guid" - TAGS = "tags" - - -class CsvFile(SourceFile): - file_location: str = "" - _data: List[dict] = [] - column_headers: list = [] - delimiter: str = ',' - - def __init__(self, file, delimiter=None): - self.file_location = file - self.set_delimiter(delimiter) - - def set_delimiter(self, delimiter: str): - if delimiter: - self.delimiter = delimiter - elif re.match(r'.*\.tsv', self.file_location, re.RegexFlag.IGNORECASE): - self.delimiter = '\t' - - @classmethod - def from_file_loc(cls, file_loc) -> 'CsvFile': - return cls(file_loc) - - def read_file(self, create_if_not_exists: Optional[bool] = True): - self._data = [] - - if create_if_not_exists: - create_path_if_not_exists(self.file_location) - Path(self.file_location).touch() - - with open(self.file_location, mode='r', newline='', encoding=_encoding) as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter) - - self.column_headers = list_of_str_to_lowercase(csv_reader.fieldnames) - - for row in csv_reader: - self._data.append({key.lower(): row[key] for key in row}) - - def write_file(self): - logging.info(f"Writing to Csv '{self.file_location}'") - with open(self.file_location, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=self.column_headers, lineterminator='\n', delimiter=self.delimiter) - - csv_writer.writeheader() - - for row in self._data: - csv_writer.writerow(row) - - def set_data(self, data_override): - self._data = data_override - self.column_headers = list(data_override[0].keys()) if data_override else [] - - def set_data_from_superset(self, superset: List[dict], column_header_override=None): - if column_header_override: - self.column_headers = column_header_override - - data_to_set: List[dict] = [] - for row in superset: - if not all(column in row for column in self.column_headers): - continue - new_row = {} - for column in self.column_headers: - new_row[column] = row[column] - data_to_set.append(new_row) - - self._data = data_to_set - - - def get_data(self, deep_copy=False) -> List[dict]: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def to_filename_csv(filename: str, delimiter: str = None) -> str: - if not re.match(r'.*\.(csv|tsv)', filename, re.RegexFlag.IGNORECASE): - if delimiter == '\t': - return filename + '.tsv' - else: - return filename + ".csv" - return filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_csv(location) - - def sort_data(self, sort_by_keys, reverse_sort, case_insensitive_sort): - self._data = sort_dict(self._data, sort_by_keys, reverse_sort, case_insensitive_sort) - - @classmethod - def create_file_with_headers(cls, filepath: str, headers: List[str], delimiter: str = None): - with open(filepath, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=headers, lineterminator='\n', delimiter=delimiter or ",") - - csv_writer.writeheader() - - @staticmethod - def delimiter_matches_file_type(delimiter: str, filename: str) -> bool: - if delimiter == '\t' and re.match(r'.*\.tsv', filename, re.RegexFlag.IGNORECASE): - return True - if delimiter == ',' and re.match(r'.*\.csv', filename, re.RegexFlag.IGNORECASE): - return True - return False diff --git a/brain_brew/representation/generic/html_file.py b/brain_brew/representation/generic/html_file.py deleted file mode 100644 index 7db12e3..0000000 --- a/brain_brew/representation/generic/html_file.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.generic.source_file import SourceFile - -_encoding = "utf-8" - -@dataclass -class HTMLFile(SourceFile): - file_location: str - _data: str - - def __init__(self, file): - self.file_location = file - self.read_file() - - @classmethod - def from_file_loc(cls, file_loc) -> 'HTMLFile': - return cls(file_loc) - - def read_file(self): - r = open(self.file_location, 'r', encoding=_encoding) - self._data = r.read() - - def get_data(self, deep_copy=False) -> str: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def write_file(file_location, data): - with open(file_location, "w+", encoding=_encoding) as file: - file.write(data) - - @staticmethod - def to_filename_html(filename: str) -> str: - return filename + ".html" if not filename.endswith(".html") else filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_html(location) diff --git a/brain_brew/representation/generic/media_file.py b/brain_brew/representation/generic/media_file.py deleted file mode 100644 index d1e71e9..0000000 --- a/brain_brew/representation/generic/media_file.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import shutil -from dataclasses import dataclass, field - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import filename_from_full_path - - -@dataclass -class MediaFile(SourceFile): - file_path: str - filename: str = field(init=False) - - def __post_init__(self): - self.filename = filename_from_full_path(self.file_path) - - @classmethod - def from_file_loc(cls, file_loc) -> 'MediaFile': - return cls(file_loc) - - def __repr__(self): - return f"MediaFile({self.file_path})" - - def __hash__(self): - return hash(self.__repr__()) - - def copy_self_to_target(self, target: str): - shutil.copy2(self.file_path, target) - - def delete_self(self): - os.remove(self.file_path) diff --git a/brain_brew/representation/generic/source_file.py b/brain_brew/representation/generic/source_file.py deleted file mode 100644 index 96231eb..0000000 --- a/brain_brew/representation/generic/source_file.py +++ /dev/null @@ -1,41 +0,0 @@ -import copy -from pathlib import Path - - -class SourceFile(object): - @classmethod - def from_file_loc(cls, file_loc) -> 'SourceFile': - pass - - @classmethod - def is_file(cls, filename: str): - return Path(filename).is_file() - - @classmethod - def is_dir(cls, folder_name: str): - return Path(folder_name).is_dir() - - @classmethod - def get_deep_copy(cls, data): - return copy.deepcopy(data) - - @classmethod - def create_or_get(cls, location): - from brain_brew.configuration.file_manager import FileManager - _file_manager = FileManager.get_instance() - formatted_location = cls.formatted_file_location(location) - file = _file_manager.file_if_exists(formatted_location) - - if file is not None: - return file - - # if not cls.is_file(location) and not cls.is_dir(location): - # raise FileNotFoundError(f"No file or folder '{location}' exists") - - file = cls.from_file_loc(location) - _file_manager.register_file(formatted_location, file) - return file - - @classmethod - def formatted_file_location(cls, location): - return location diff --git a/brain_brew/representation/json/__init__.py b/brain_brew/representation/json/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/json/crowd_anki_export.py b/brain_brew/representation/json/crowd_anki_export.py deleted file mode 100644 index 5652049..0000000 --- a/brain_brew/representation/json/crowd_anki_export.py +++ /dev/null @@ -1,63 +0,0 @@ -import glob -import logging -from typing import List - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.json.json_file import JsonFile -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.utils import create_path_if_not_exists - - -class CrowdAnkiExport(SourceFile): - folder_location: str - json_file_location: str - # import_config: CrowdAnkiImportConfig # TODO: Make this - json_data: CrowdAnkiJsonWrapper - note_models: List[NoteModel] - - media_loc: str - - def __init__(self, folder_location): - self.folder_location = folder_location - if self.folder_location[-1] != "/": - self.folder_location = self.folder_location + "/" - - create_path_if_not_exists(self.folder_location) - - self.json_file_location = self.find_json_file_in_folder() - self._read_json_file() - - self.media_loc = self.folder_location + "media/" - - if not self.is_dir(self.media_loc): - create_path_if_not_exists(self.media_loc) - return - - @classmethod - def from_file_loc(cls, file_loc) -> 'CrowdAnkiExport': - return cls(file_loc) - - def find_json_file_in_folder(self): - files = glob.glob(f"{glob.escape(self.folder_location)}*.json") - - if len(files) == 1: - return files[0] - elif not files: - file_loc = self.folder_location + "deck.json" - logging.warning(f"Creating missing json file '{file_loc}'") - return file_loc - else: - logging.error(f"Multiple json files found in '{self.folder_location}': {files}") - raise FileExistsError() - - def write_to_files(self, json_data): # import_config_data - JsonFile.write_file(self.json_file_location, json_data) - - def _read_json_file(self): - if SourceFile.is_file(self.json_file_location): - self.json_data = CrowdAnkiJsonWrapper(JsonFile.read_file(self.json_file_location)) - self.note_models = list(map(NoteModel.from_crowdanki, self.json_data.note_models)) - else: - self.write_to_files({}) - self.json_data = CrowdAnkiJsonWrapper({}) diff --git a/brain_brew/representation/json/json_file.py b/brain_brew/representation/json/json_file.py deleted file mode 100644 index c1ccfba..0000000 --- a/brain_brew/representation/json/json_file.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -_encoding = "utf-8" - - -class JsonFile: - @staticmethod - def pretty_print(data): - return json.dumps(data, indent=4) - - @staticmethod - def to_filename_json(filename: str): - if filename[-5:] != ".json": - return filename + ".json" - return filename - - @staticmethod - def read_file(file_location): - with open(JsonFile.to_filename_json(file_location), "r", encoding=_encoding) as read_file: - return json.load(read_file) - - @staticmethod - def write_file(file_location, data): - with open(JsonFile.to_filename_json(file_location), "w+", encoding=_encoding) as write_file: - json.dump(data, write_file, indent=4, sort_keys=False, ensure_ascii=False) diff --git a/brain_brew/representation/json/wrappers_for_crowd_anki.py b/brain_brew/representation/json/wrappers_for_crowd_anki.py deleted file mode 100644 index 26c6e5b..0000000 --- a/brain_brew/representation/json/wrappers_for_crowd_anki.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import List - - -CA_NOTE_MODELS = "note_models" -CA_NOTES = "notes" -CA_MEDIA_FILES = "media_files" -CA_CHILDREN = "children" -CA_TYPE = "__type__" -CA_NAME = "name" -CA_DESCRIPTION = "desc" -CA_UUID = "crowdanki_uuid" - -NOTE_MODEL = "note_model_uuid" -FLAGS = "flags" -GUID = "guid" -TAGS = "tags" -FIELDS = "fields" - - -class CrowdAnkiJsonWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def children(self) -> list: - return self.data.get(CA_CHILDREN, []) - - @property - def note_models(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTE_MODELS) - - - @note_models.setter - def note_models(self, value: list): - self.data[CA_NOTE_MODELS] = value - - @property - def notes(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTES) - - @notes.setter - def notes(self, value: list): - self.data[CA_NOTES] = value - - @property - def media_files(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_MEDIA_FILES) - - @media_files.setter - def media_files(self, value: list): - self.data[CA_MEDIA_FILES] = value - - @property - def name(self) -> list: - return self.data.get(CA_NAME, []) - - @name.setter - def name(self, value: list): - self.data[CA_NAME] = value - - @staticmethod - def get_from_self_and_children_recursively(data: dict, running_data: list, key_name: str): - running_data += data.get(key_name, []) - children = data.get(CA_CHILDREN, []) - if isinstance(children, list): - for child in children: - running_data = CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(child, running_data, key_name) - return running_data - - -class CrowdAnkiNoteWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def note_model(self) -> str: - return self.data.get(NOTE_MODEL) - - @note_model.setter - def note_model(self, value: str): - self.data[NOTE_MODEL] = value - - @property - def flags(self) -> int: - return self.data.get(FLAGS) - - @flags.setter - def flags(self, value: int): - self.data[FLAGS] = value - - @property - def guid(self) -> str: - return self.data.get(GUID) - - @guid.setter - def guid(self, value: str): - self.data[GUID] = value - - @property - def tags(self) -> list: - return self.data.get(TAGS, []) - - @tags.setter - def tags(self, value: list): - self.data[TAGS] = value - - @property - def fields(self) -> List[str]: - return self.data.get(FIELDS, []) - - @fields.setter - def fields(self, value: List[str]): - self.data[FIELDS] = value diff --git a/brain_brew/representation/yaml/__init__.py b/brain_brew/representation/yaml/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/yaml/headers.py b/brain_brew/representation/yaml/headers.py deleted file mode 100644 index dd27e7b..0000000 --- a/brain_brew/representation/yaml/headers.py +++ /dev/null @@ -1,44 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NAME, CA_DESCRIPTION, CA_UUID -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class Headers(YamlObject): - data: dict - - @classmethod - def from_yaml_file(cls, filename: str): - return cls(data=cls.read_to_dict(filename)) - - def encode(self) -> dict: - return self.data - - @property - def name(self) -> str: - return self.data[CA_NAME] - - @name.setter - def name(self, desc: str): - self.data[CA_NAME] = desc - - @property - def description(self) -> str: - return self.data.get(CA_DESCRIPTION, "") - - @description.setter - def description(self, desc: str): - self.data[CA_DESCRIPTION] = desc - - @property - def crowdanki_uuid(self) -> str: - return self.data.get(CA_UUID, "") - - @crowdanki_uuid.setter - def crowdanki_uuid(self, desc: str): - self.data[CA_UUID] = desc - - @property - def data_without_name(self) -> dict: - return {k: v for k, v in sorted(self.data.items()) if k != CA_NAME} diff --git a/brain_brew/representation/yaml/media_group.py b/brain_brew/representation/yaml/media_group.py deleted file mode 100644 index eb26334..0000000 --- a/brain_brew/representation/yaml/media_group.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass -from typing import Set, Dict, List, Tuple - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_all_files_in_directory - - -@dataclass -class MediaGroup(YamlObject): - media_files: Dict[str, MediaFile] - - def encode(self) -> list: - return list(m.file_path for m in self.media_files.values()) # TODO: Use relative path for directory? - - @classmethod - def from_yaml_file(cls, filename: str) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(cls.read_to_dict(filename))) - - @classmethod - def from_directory(cls, directory: str, recursive: bool) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(find_all_files_in_directory(directory, recursive=recursive))) - - @classmethod - def from_many(cls, groups: List['MediaGroup']) -> 'MediaGroup': - files = list(set(file.file_path for group in groups for file in group.media_files.values())) - return cls(media_files=cls.from_full_path_list(files)) - - @staticmethod - def from_full_path_list(known_files: list): - files: Dict[str, MediaFile] = dict() - - for full_path in known_files: - file = MediaFile.create_or_get(full_path) - if file.filename not in files.keys(): - files[file.filename] = file - else: - raise NameError(f"Duplicate files with same filename '{file.filename}' in group") - - return files - - def remove_by_filename(self, filename: str): - self.media_files.pop(filename, None) - - def filter_by_filenames(self, filenames: List[str], should_match: bool): - for media_filename in self.media_files.keys(): - is_match = media_filename in filenames - if is_match != should_match: - self.remove_by_filename(media_filename) - # TODO: Find all missing files - - def compare(self, other: 'MediaGroup') -> Tuple[Set[str], Set[str], Set[str]]: - - self_set = set(self.media_files) - other_set = set(other.media_files) - - return self_set.intersection(other_set), self_set - other_set, other_set - self_set diff --git a/brain_brew/representation/yaml/note_model.py b/brain_brew/representation/yaml/note_model.py deleted file mode 100644 index 74c5ee9..0000000 --- a/brain_brew/representation/yaml/note_model.py +++ /dev/null @@ -1,270 +0,0 @@ -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import List, Union, Dict, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import list_of_str_to_lowercase - -# CrowdAnki -CROWDANKI_ID = AnkiField("crowdanki_uuid", "id") -CROWDANKI_TYPE = AnkiField("__type__", default_value="NoteModel") - -# Shared -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") - -# Note Model -CSS = AnkiField("css") -LATEX_PRE = AnkiField("latexPre", "latex_pre", - default_value="\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{" - "amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{" - "document}\n") -LATEX_POST = AnkiField("latexPost", "latex_post", default_value="\\end{document}") -LATEX_SVG = AnkiField("latexsvg", "latex_svg", default_value=False) -REQUIRED_FIELDS_PER_TEMPLATE = AnkiField("req", "required_fields_per_template", default_value=[]) -FIELDS = AnkiField("flds", "fields") -TEMPLATES = AnkiField("tmpls", "templates") -TAGS = AnkiField("tags", default_value=[]) -SORT_FIELD_NUM = AnkiField("sortf", "sort_field_num", default_value=0) -IS_CLOZE = AnkiField("type", "is_cloze", default_value=False) -VERSION = AnkiField("vers", "version", default_value=[]) - -# Field -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - -# Template -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) - - -CSS_FILE = AnkiField("css_file") - - -@dataclass -class NoteModel(YamlObject, YamlRepr, MediaContainer): - @classmethod - def task_name(cls) -> str: - return r"note_model_from_yaml_repr_inner" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - {NAME.name}: str() - {CROWDANKI_ID.name}: str() - {CSS_FILE.name}: str() - {FIELDS.name}: include({Field.task_name()}, required=False) - {TEMPLATES.name}: include({Template.task_name()}, required=False) - {REQUIRED_FIELDS_PER_TEMPLATE.name}: list(required=False) - {LATEX_POST.name}: str(required=False) - {LATEX_PRE.name}: str(required=False) - {SORT_FIELD_NUM.name}: int(required=False) - {IS_CLOZE.name}: bool(required=False) - {CROWDANKI_TYPE.name}: str(required=False) - {TAGS.name}: str(required=False) - {VERSION.name}: list(required=False) - """ - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field, Template} - - @dataclass - class Representation(RepresentationBase): - name: str - id: str - css_file: str - fields: List[dict] - templates: List[dict] - - required_fields_per_template: List[list] = field(default_factory=lambda: []) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - fields=[Field.from_repr(f) for f in rep.fields], - templates=[Template.from_html_files(t) for t in rep.templates], - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=False), - - name=rep.name, is_cloze=bool(rep.is_cloze), - latex_pre=rep.latex_pre, latex_post=rep.latex_post, latex_svg=rep.latex_svg, - required_fields_per_template=rep.required_fields_per_template, - tags=rep.tags, sort_field_num=rep.sort_field_num, version=rep.version, - id=rep.id, crowdanki_type=rep.crowdanki_type - ) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - crowdanki_uuid: str - css: str - flds: List[dict] - tmpls: List[dict] - req: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latexPre: str = field(default=LATEX_PRE.default_value) - latexPost: str = field(default=LATEX_POST.default_value) - latexsvg: bool = field(default=LATEX_SVG.default_value) # TODO: Fix lowercase here in CrowdAnki - __type__: str = field(default=CROWDANKI_TYPE.default_value) - tags: List[str] = field(default_factory=lambda: TAGS.default_value) - sortf: int = field(default=SORT_FIELD_NUM.default_value) - type: int = field(default=0) # Is_Cloze Manually set to 0 - vers: list = field(default_factory=lambda: VERSION.default_value) - - rep: Union[Representation, CrowdAnki] - - name: str - id: str - css: str - fields: List[Field] - templates: List[Template] - - required_fields_per_template: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_yaml_file(cls, filename: str): - data = cls.read_to_dict(filename) - return cls.from_repr(data) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): # TODO: field_whitelist, note_model_whitelist - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - rep=ca, - fields=[Field.from_crowd_anki(f) for f in ca.flds], - templates=[Template.from_crowdanki(t) for t in ca.tmpls], - is_cloze=bool(ca.type), - name=ca.name, css=ca.css, latex_pre=ca.latexPre, latex_post=ca.latexPost, latex_svg=ca.latexsvg, - required_fields_per_template=ca.req, tags=ca.tags, sort_field_num=ca.sortf, version=ca.vers, - id=ca.crowdanki_uuid, crowdanki_type=ca.__type__ - ) - - def encode_as_crowdanki(self) -> dict: - data_dict = { - NAME.anki_name: self.name, - CROWDANKI_ID.anki_name: self.id, - CSS.anki_name: self.css, - REQUIRED_FIELDS_PER_TEMPLATE.anki_name: self.required_fields_per_template, - LATEX_PRE.anki_name: self.latex_pre, - LATEX_POST.anki_name: self.latex_post, - LATEX_SVG.anki_name: self.latex_svg, - SORT_FIELD_NUM.anki_name: self.sort_field_num, - CROWDANKI_TYPE.anki_name: self.crowdanki_type, - TAGS.anki_name: self.tags, - VERSION.anki_name: self.version, - IS_CLOZE.anki_name: 1 if self.is_cloze else 0 - } - - data_dict.setdefault(FIELDS.anki_name, [f.encode_as_crowdanki(num) for num, f in enumerate(self.fields)]) - data_dict.setdefault(TEMPLATES.anki_name, [t.encode_as_crowdanki(num) for num, t in enumerate(self.templates)]) - - return OrderedDict(sorted(data_dict.items())) - - def encode_as_part_with_empty_file_references(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS_FILE.name: "" - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode_as_part() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - REQUIRED_FIELDS_PER_TEMPLATE.append_name_if_differs(data_dict, self.required_fields_per_template) - - return data_dict - - def encode(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS.name: self.css - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - data_dict.setdefault(REQUIRED_FIELDS_PER_TEMPLATE.name, self.required_fields_per_template) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for template in self.templates: - all_media = all_media.union(template.get_all_media_references()) - - return all_media - - @property - def field_names_lowercase(self): - return list_of_str_to_lowercase([f.name for f in self.fields]) - - def check_field_overlap(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - missing = [f for f in self.field_names_lowercase if f not in fields_to_check] - - return missing - - def check_field_extra(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - return [f for f in fields_to_check if f not in self.field_names_lowercase] - - def zip_field_to_data(self, data: List[str]) -> dict: - if len(self.fields) != len(data): - raise Exception( - f"Data of length {len(data)} cannot map to fields of length {len(self.field_names_lowercase)}", data, self.field_names_lowercase) - return dict(zip(self.field_names_lowercase, data)) - - diff --git a/brain_brew/representation/yaml/note_model_field.py b/brain_brew/representation/yaml/note_model_field.py deleted file mode 100644 index eebf277..0000000 --- a/brain_brew/representation/yaml/note_model_field.py +++ /dev/null @@ -1,86 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - - -@dataclass -class Field(RepresentationBase, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"note_model_field" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - """ - - @classmethod - def from_repr(cls, data: dict): - return cls.from_dict(data) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - ord: int = field(default=None) - font: str = field(default=FONT.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) - rtl: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - size: int = field(default=FONT_SIZE.default_value) - sticky: bool = field(default=IS_STICKY.default_value) - - name: str - font: str = field(default=FONT.default_value) - is_right_to_left: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - font_size: int = field(default=FONT_SIZE.default_value) - is_sticky: bool = field(default=IS_STICKY.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) # Unused in Anki - - @classmethod - def from_crowd_anki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, font=ca.font, media=ca.media, - is_right_to_left=ca.rtl, font_size=ca.size, is_sticky=ca.sticky - ) - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - FONT.anki_name: self.font, - MEDIA.anki_name: self.media, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - IS_RIGHT_TO_LEFT.anki_name: self.is_right_to_left, - FONT_SIZE.anki_name: self.font_size, - IS_STICKY.anki_name: self.is_sticky - } - - return data_dict - - def encode_as_part(self) -> dict: - data_dict = { - NAME.name: self.name - } - - FONT.append_name_if_differs(data_dict, self.font) - MEDIA.append_name_if_differs(data_dict, self.media) - IS_RIGHT_TO_LEFT.append_name_if_differs(data_dict, self.is_right_to_left) - FONT_SIZE.append_name_if_differs(data_dict, self.font_size) - IS_STICKY.append_name_if_differs(data_dict, self.is_sticky) - - return data_dict diff --git a/brain_brew/representation/yaml/note_model_template.py b/brain_brew/representation/yaml/note_model_template.py deleted file mode 100644 index 2fa1fb8..0000000 --- a/brain_brew/representation/yaml/note_model_template.py +++ /dev/null @@ -1,196 +0,0 @@ -import os -from dataclasses import dataclass, field -from typing import Optional, Union, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field, split_by_regex - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) -BROWSER_FONT = AnkiField("bfont", "browser_font", default_value="") -BROWSER_FONT_SIZE = AnkiField("bsize", "browser_font_size", default_value=0) -SCRATCH_PAD = AnkiField("scratchPad", "scratch_pad", default_value=0) - -HTML_FILE = AnkiField("html_file") -BROWSER_HTML_FILE = AnkiField("browser_html_file", default_value=None) - -html_separator_regex = r'(?:\r\n|\r|\n){1,}[-]{1,}(?:\r\n|\r|\n){1,}' - - -@dataclass -class Template(RepresentationBase, YamlObject, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_template_from_html' - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - html_file: str() - browser_html_file: str(required=False) - deck_override_id: int(required=False) - """ - - @dataclass - class HTML(RepresentationBase): - name: str - html_file: str - browser_html_file: Optional[str] = field(default=None) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - @classmethod - def from_repr(cls, data: Union[HTML, dict]): - rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - return cls.from_html_files(rep) - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Template': - return cls.from_dict(cls.read_to_dict(filename)) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - qfmt: str - afmt: str - bqfmt: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - bafmt: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - bfont: str = field(default=BROWSER_FONT.default_value) - bsize: int = field(default=BROWSER_FONT_SIZE.default_value) - ord: int = field(default=None) - did: Optional[int] = field(default=None) - scratchPad: int = field(default=SCRATCH_PAD.default_value) - - name: str - question_format: str - answer_format: str - question_format_in_browser: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - answer_format_in_browser: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - html_file: Optional[str] = field(default="") - browser_html_file: Optional[str] = field(default="") - - @classmethod - def from_html_files(cls, data: Union[HTML, dict]): - html_rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - - html_file = HTMLFile.create_or_get(html_rep.html_file) - browser_html_file = HTMLFile.create_or_get(html_rep.browser_html_file) if html_rep.browser_html_file else None - - main_data = html_file.get_data(deep_copy=True) - browser_data = browser_html_file.get_data(deep_copy=True) if browser_html_file else None - - def split_template(the_data, file): - split = split_by_regex(the_data, html_separator_regex) - if len(split) != 2: - raise ValueError(f"Cannot find" if len(split) < 2 else "More than one" - f" separator '---' in html file '{file.file_location}'") - return split[0], split[1] - - front, back = split_template(main_data, html_file) - browser_front, browser_back = split_template(browser_data, browser_html_file) if browser_data else ("", "") - - return cls( - name=html_rep.name, - question_format=front, - answer_format=back, - question_format_in_browser=browser_front, - answer_format_in_browser=browser_back, - deck_override_id=html_rep.deck_override_id, - html_file=html_rep.html_file, - browser_html_file=html_rep.browser_html_file, - browser_font=html_rep.browser_font, - browser_font_size=html_rep.browser_font_size, - scratch_pad=html_rep.scratch_pad, - ) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, question_format=ca.qfmt, answer_format=ca.afmt, - question_format_in_browser=ca.bqfmt, answer_format_in_browser=ca.bafmt, - deck_override_id=ca.did, browser_font=ca.bfont, browser_font_size=ca.bsize, scratch_pad=ca.scratchPad, - ) - - def encode_as_part(self): - data_dict = { - NAME.name: self.name, - HTML_FILE.name: "" - } - - if self.has_browser_template(): - data_dict.setdefault(BROWSER_HTML_FILE.name, "") - - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - ANSWER_FORMAT.anki_name: self.answer_format, - BROWSER_ANSWER_FORMAT.anki_name: self.answer_format_in_browser, - BROWSER_FONT.anki_name: self.browser_font, - BROWSER_QUESTION_FORMAT.anki_name: self.question_format_in_browser, - BROWSER_FONT_SIZE.anki_name: self.browser_font_size, - DECK_OVERRIDE_ID.anki_name: self.deck_override_id, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - QUESTION_FORMAT.anki_name: self.question_format, - SCRATCH_PAD.anki_name: self.scratch_pad, - } - - return data_dict - - def encode(self) -> dict: - data_dict = { - NAME.name: self.name, - QUESTION_FORMAT.name: self.question_format, - ANSWER_FORMAT.name: self.answer_format - } - - BROWSER_QUESTION_FORMAT.append_name_if_differs(data_dict, self.question_format_in_browser) - BROWSER_ANSWER_FORMAT.append_name_if_differs(data_dict, self.answer_format_in_browser) - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() \ - .union(find_media_in_field(self.question_format)) \ - .union(find_media_in_field(self.answer_format)) \ - .union(find_media_in_field(self.question_format_in_browser)) \ - .union(find_media_in_field(self.answer_format_in_browser)) - return all_media - - def has_browser_template(self): - return BROWSER_QUESTION_FORMAT.does_differ(self.question_format_in_browser) \ - or BROWSER_ANSWER_FORMAT.does_differ(self.answer_format_in_browser) - - def get_template_files_data(self): - template = f"{self.question_format}\n\n--\n\n{self.answer_format}" - browser_template = f"{self.question_format}\n\n--\n\n{self.answer_format}" if self.has_browser_template() else None - - return template, browser_template diff --git a/brain_brew/representation/yaml/notes.py b/brain_brew/representation/yaml/notes.py deleted file mode 100644 index ae90dcd..0000000 --- a/brain_brew/representation/yaml/notes.py +++ /dev/null @@ -1,184 +0,0 @@ -import logging -from abc import ABCMeta -from dataclasses import dataclass -from typing import List, Optional, Dict, Set - -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field - -FIELDS = 'fields' -GUID = 'guid' -TAGS = 'tags' -NOTE_MODEL = 'note_model' -FLAGS = "flags" -NOTES = "notes" -NOTE_GROUPINGS = "note_groupings" -MEDIA_REFERENCES = "media_references" - - -@dataclass -class GroupableNoteData(YamlObject, MediaContainer, metaclass=ABCMeta): - note_model: Optional[str] - tags: Optional[List[str]] - - def encode_groupable(self, data_dict): - if self.note_model is not None: - data_dict.setdefault(NOTE_MODEL, self.note_model) - if self.tags is not None and self.tags != []: - data_dict.setdefault(TAGS, self.tags) - return data_dict - - -@dataclass -class Note(GroupableNoteData): - @classmethod - def from_yaml_file(cls, filename: str) -> 'Note': - return cls.from_dict(cls.read_to_dict(filename)) - - fields: List[str] - guid: str - flags: int - # media_references: Optional[Set[str]] - - @classmethod - def from_dict(cls, data: dict): - return cls( - fields=data.get(FIELDS), - guid=data.get(GUID), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None), - flags=data.get(FLAGS, 0) - ) - - def encode(self) -> dict: - data_dict: Dict[str, any] = {FIELDS: self.fields, GUID: self.guid} - if self.flags is not None and self.flags != 0: - data_dict.setdefault(FLAGS, self.flags) - super().encode_groupable(data_dict) - return data_dict - - def get_all_media_references(self) -> Set[str]: - return {entry for field in self.fields for entry in find_media_in_field(field)} - - -@dataclass -class NoteGrouping(GroupableNoteData): - notes: List[Note] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'NoteGrouping': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls( - notes=list(map(Note.from_dict, data.get(NOTES))), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None) - ) - - def encode(self) -> dict: - data_dict = {} - super().encode_groupable(data_dict) - data_dict.setdefault(NOTES, [note.encode() for note in self.notes]) - return data_dict - - # TODO: Extract Shared Tags and Note Models - - def verify_groupings(self): - errors = [] - if self.note_model is not None: - if any([note.note_model for note in self.notes]): - errors.append(ValueError(f"NoteGrouping for 'note_model' {self.note_model} has notes with 'note_model'." - f" Please remove one of these.")) - return errors - - def get_all_known_note_model_names(self) -> set: - return {self.note_model} if self.note_model else {note.note_model for note in self.notes} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note in self.notes: - media = note.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes(self, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - def sort_method(i: Note): - def get_sort_tuple(attr_or_field): - if attr_or_field in [GUID, FLAGS, NOTE_MODEL, TAGS]: - value = getattr(i, attr_or_field) - elif isinstance(attr_or_field, int) and attr_or_field < len(i.fields): - value = i.fields[attr_or_field] - else: - value = "" - logging.warning(f"No known sort value for {attr_or_field}") - - if not isinstance(value, str): - return True, False - return (value == "", value.lower()) if case_insensitive_sort else (value == "", value) - - return tuple(get_sort_tuple(column) for column in sort_by_keys) - - return sorted(self.notes, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(self.notes)) - - return self.notes - - def get_all_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort) -> List[Note]: - def join_tags(n_tags): - if self.tags is None and n_tags is None: - return [] - elif self.tags is None: - return n_tags - elif n_tags is None: - return self.tags - else: - return [*n_tags, *self.tags] - - return [Note( - note_model=self.note_model or n.note_model, - tags=join_tags(n.tags), - fields=n.fields, - guid=n.guid, - flags=n.flags - # media_references=n.media_references or n.get_media_references() - ) for n in self.get_sorted_notes(sort_by_keys, reverse_sort, case_insensitive_sort)] - - -@dataclass -class Notes(YamlObject, MediaContainer): - note_groupings: List[NoteGrouping] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Notes': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls(note_groupings=list(map(NoteGrouping.from_dict, data.get(NOTE_GROUPINGS)))) - - @classmethod - def from_list_of_notes(cls, notes: List[Note]): - return cls(note_groupings=[NoteGrouping(note_model=None, tags=None, notes=notes)]) # TODO: Check grouping here - - def encode(self) -> dict: - data_dict = {NOTE_GROUPINGS: [note_grouping.encode() for note_grouping in self.note_groupings]} - return data_dict - - def get_all_known_note_model_names(self): - return {nms for group in self.note_groupings for nms in group.get_all_known_note_model_names()} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note_group in self.note_groupings: - media = note_group.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort): - return [note for group in self.note_groupings - for note in group.get_all_notes_copy(sort_by_keys, reverse_sort, case_insensitive_sort)] diff --git a/brain_brew/representation/yaml/yaml_object.py b/brain_brew/representation/yaml/yaml_object.py deleted file mode 100644 index 3a05198..0000000 --- a/brain_brew/representation/yaml/yaml_object.py +++ /dev/null @@ -1,55 +0,0 @@ -from abc import ABC, abstractmethod -from pathlib import Path - -from ruamel.yaml import YAML - -from brain_brew.utils import create_path_if_not_exists - -yaml_load = YAML(typ='safe') - - -yaml_dump = YAML() -yaml_dump.preserve_quotes = False -yaml_dump.indent(mapping=2, sequence=2, offset=0) -yaml_dump.representer.ignore_aliases = lambda *data: True -# yaml.sort_base_mapping_type_on_output = False - - -class YamlObject(ABC): - @staticmethod - def read_to_dict(filename: str): - filename = YamlObject.to_filename_yaml(filename) - - if not Path(filename).is_file(): - raise FileNotFoundError(filename) - - with open(filename) as file: - return yaml_load.load(file) - - @staticmethod - def to_filename_yaml(filename: str): - if filename[-5:] != ".yaml" and filename[-4:] != ".yml": - return filename + ".yaml" - return filename - - @abstractmethod - def encode(self) -> dict: - pass - - @classmethod - @abstractmethod - def from_yaml_file(cls, filename: str) -> 'YamlObject': - pass - - def dump_to_yaml(self, filepath): - self.dump_to_yaml_file(filepath, self.encode()) - - @classmethod - def dump_to_yaml_file(cls, filepath, data): - filepath = YamlObject.to_filename_yaml(filepath) - - create_path_if_not_exists(filepath) - - with open(filepath, 'w') as fp: - yaml_dump.dump(data, fp) - diff --git a/brain_brew/schemas/__init__.py b/brain_brew/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/schemas/recipe.yaml b/brain_brew/schemas/recipe.yaml deleted file mode 100644 index 6cf5ca7..0000000 --- a/brain_brew/schemas/recipe.yaml +++ /dev/null @@ -1,173 +0,0 @@ -list( - map(include('build_parts'), key=regex('build_parts?', ignore_case=True)), - map(any(include('generate_crowd_anki'), list(include('generate_crowd_anki'))), key=regex('generate_crowd_anki', ignore_case=True)), - map(any(include('generate_csvs'), list(include('generate_csvs'))), key=regex('generate_csvs?', ignore_case=True)), - map(any(include('generate_guids_in_csvs'), list(include('generate_guids_in_csvs'))), key=regex('generate_guids_in_csvs?', ignore_case=True)), - map(any(include('save_media_groups_to_folder'), list(include('save_media_groups_to_folder'))), key=regex('save_media_groups?_to_folder', ignore_case=True)), - map(any(include('save_note_models_to_folder'), list(include('save_note_models_to_folder'))), key=regex('save_note_models?_to_folder', ignore_case=True)) -) - - ---- - -build_parts: - list( - map(any(include('headers_from_crowd_anki'), list(include('headers_from_crowd_anki'))), key=regex('headers?_from_crowd_anki', ignore_case=True)), - map(any(include('headers_from_yaml_part'), list(include('headers_from_yaml_part'))), key=regex('headers?_from_yaml_part', ignore_case=True)), - map(any(include('media_group_from_crowd_anki'), list(include('media_group_from_crowd_anki'))), key=regex('media_group_from_crowd_anki', ignore_case=True)), - map(any(include('media_group_from_folder'), list(include('media_group_from_folder'))), key=regex('media_group_from_folder', ignore_case=True)), - map(any(include('media_group_from_yaml_part'), list(include('media_group_from_yaml_part'))), key=regex('media_group_from_yaml_part', ignore_case=True)), - map(any(include('note_model_from_crowd_anki'), list(include('note_model_from_crowd_anki'))), key=regex('note_model_from_crowd_anki', ignore_case=True)), - map(any(include('note_model_from_html_parts'), list(include('note_model_from_html_parts'))), key=regex('note_model_from_html_parts', ignore_case=True)), - map(any(include('note_models_all_from_crowd_anki'), list(include('note_models_all_from_crowd_anki'))), key=regex('note_models_all_from_crowd_anki', ignore_case=True)), - map(any(include('note_models_from_yaml_part'), list(include('note_models_from_yaml_part'))), key=regex('note_models?_from_yaml_part', ignore_case=True)), - map(any(include('notes_from_crowd_anki'), list(include('notes_from_crowd_anki'))), key=regex('notes_from_crowd_anki', ignore_case=True)), - map(any(include('notes_from_csvs'), list(include('notes_from_csvs'))), key=regex('notes_from_csvs?', ignore_case=True)), - map(any(include('notes_from_yaml_part'), list(include('notes_from_yaml_part'))), key=regex('notes_from_yaml_part', ignore_case=True)) - ) - -generate_crowd_anki: - folder: str() - headers: str() - notes: include('notes_to_crowd_anki') - note_models: include('note_models_to_crowd_anki') - media: include('media_group_to_crowd_anki', required=False) - -generate_csvs: - notes: str() - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -generate_guids_in_csvs: - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - -save_media_groups_to_folder: - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - -save_note_models_to_folder: - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - - ---- - -file_mapping: - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('file_mapping'), required=False) - delimiter: str(required=False) - -headers_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - -headers_from_yaml_part: - part_id: str() - file: str() - override: include('headers_override', required=False) - -headers_override: - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - -media_group_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_folder: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_yaml_part: - part_id: str() - file: str() - -media_group_to_crowd_anki: - parts: list(str()) - -note_model_field: - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - -note_model_from_crowd_anki: - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_from_html_parts: - part_id: str() - model_id: str() - css_file: str() - fields: list(include('note_model_field')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_mapping: - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - -note_models_all_from_crowd_anki: - source: str() - -note_models_from_yaml_part: - part_id: str() - file: str() - -note_models_to_crowd_anki: - parts: list(include('note_models_to_crowd_anki_item')) - -note_models_to_crowd_anki_item: - part_id: str() - -notes_from_crowd_anki: - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - -notes_from_csvs: - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -notes_from_yaml_part: - part_id: str() - file: str() - -notes_override: - note_model: str(required=False) - -notes_to_crowd_anki: - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('notes_override', required=False) - case_insensitive_sort: bool(required=False) diff --git a/brain_brew/transformers/__init__.py b/brain_brew/transformers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/transformers/create_media_group_from_location.py b/brain_brew/transformers/create_media_group_from_location.py deleted file mode 100644 index ee09946..0000000 --- a/brain_brew/transformers/create_media_group_from_location.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.media_group import MediaGroup - - -def create_media_group_from_location( - part_id: str, - save_to_file: str, - media_group: MediaGroup, - groups_to_blacklist: List[MediaContainer], - groups_to_whitelist: List[MediaContainer] -) -> MediaGroup: - if groups_to_whitelist: - white = list(set.union(*[container.get_all_media_references() for container in groups_to_whitelist])) - media_group.filter_by_filenames(white, should_match=True) - - if groups_to_blacklist: - black = list(set.union(*[container.get_all_media_references() for container in groups_to_blacklist])) - media_group.filter_by_filenames(black, should_match=False) - - holder = PartHolder.override_or_create(part_id, save_to_file, media_group) - return holder.part diff --git a/brain_brew/transformers/file_mapping.py b/brain_brew/transformers/file_mapping.py deleted file mode 100644 index 644dd9d..0000000 --- a/brain_brew/transformers/file_mapping.py +++ /dev/null @@ -1,194 +0,0 @@ -import re - -import logging -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.csv_file import CsvFile, CsvKeys -from brain_brew.utils import single_item_to_list - -FILE = "csv_file" -NOTE_MODEL = "note_model" -SORT_BY_COLUMNS = "sort_by_columns" -REVERSE_SORT = "reverse_sort" -DERIVATIVES = "derivatives" - - -@dataclass -class FileMappingDerivative(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'file_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('{cls.task_name()}'), required=False) - delimiter: str(required=False) - ''' - - @dataclass(init=False) - class Representation(RepresentationBase): - file: str - note_model: Optional[str] - sort_by_columns: Optional[Union[list, str]] - reverse_sort: Optional[bool] - derivatives: Optional[List['FileMappingDerivative.Representation']] - delimiter: Optional[str] - - def __init__(self, file, note_model=None, sort_by_columns=None, reverse_sort=None, case_insensitive_sort=None, derivatives=None, delimiter=None): - self.file = file - self.note_model = note_model - self.sort_by_columns = sort_by_columns - self.reverse_sort = reverse_sort - self.case_insensitive_sort = case_insensitive_sort - self.derivatives = list(map(FileMappingDerivative.Representation.from_dict, derivatives)) \ - if derivatives is not None else [] - self.delimiter = delimiter - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and CsvFile.delimiter_matches_file_type(value, self.file): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv = CsvFile.create_or_get(rep.file) - csv.set_delimiter(rep.delimiter) - csv.read_file() - return cls( - rep=rep, - csv_file=csv, - note_model=rep.note_model.strip() if rep.note_model else None, - sort_by_columns=single_item_to_list(rep.sort_by_columns), - reverse_sort=rep.reverse_sort or False, - case_insensitive_sort=rep.case_insensitive_sort or True, - derivatives=list(map(cls.from_repr, rep.derivatives)) if rep.derivatives is not None else [], - ) - - rep: Representation - - compiled_data: Dict[str, dict] = field(init=False) - - csv_file: CsvFile - - note_model: Optional[str] - sort_by_columns: list - reverse_sort: bool - case_insensitive_sort: bool - derivatives: Optional[List['FileMappingDerivative']] - - def get_available_columns(self): - return self.csv_file.column_headers + [col for der in self.derivatives for col in der.get_available_columns()] - - def get_used_note_model_names(self) -> List[str]: - nm = [self.note_model] if self.note_model is not None else [] - return nm + [name for der in self.derivatives for name in der.get_used_note_model_names()] - - def _build_data_recursive(self) -> List[dict]: - data_in_progress = self.csv_file.get_data(deep_copy=True) - - new_columns_seen_so_far = self.csv_file.column_headers.copy() - for der in self.derivatives: - der_cols = der.get_available_columns() - overlapping_cols = [col for col in der_cols if col in self.csv_file.column_headers] - der_cols = [col for col in der_cols if col not in overlapping_cols] - - if not overlapping_cols: - raise KeyError("No column overlap for derivative") - - column_repeat_errors = [KeyError(f"Derivative column {c} in multiple derivative lines") - for c in der_cols if c in new_columns_seen_so_far] - if column_repeat_errors: - raise Exception(column_repeat_errors) - new_columns_seen_so_far += der_cols - - der_match_errors = [] - for der_row in der._build_data_recursive(): - # Find matching row to pair data with - found_match = False - for row in data_in_progress: - if all([der_row[c] == row[c] for c in overlapping_cols]): - for der_col in der_cols: - row[der_col] = der_row[der_col] - found_match = True - # Set Note Model to matching Derivative Note Model - if der.note_model is not None: - row.setdefault(NOTE_MODEL, der.note_model) - break - if not found_match: - der_match_errors.append(ValueError(f"Cannot match derivative row {der_row} to parent")) - - if der_match_errors: - raise Exception(der_match_errors) - - return data_in_progress - - def write_to_csv(self, data_to_set): - self.csv_file.set_data_from_superset(data_to_set) - self.csv_file.sort_data(self.sort_by_columns, self.reverse_sort, self.case_insensitive_sort) - self.csv_file.write_file() - - for der in self.derivatives: - der.write_to_csv(data_to_set) - - -@dataclass -class FileMapping(FileMappingDerivative): - note_model: str # Override Optional on Children - - data_set_has_changed: bool = field(init=False, default=False) - - def compile_data(self): - self.compiled_data = {} - self.data_set_has_changed = False - - data_in_progress = self._build_data_recursive() - - # Set Note Model if not already set - if self.note_model is not None: - for row in data_in_progress: - row.setdefault(NOTE_MODEL, self.note_model) - - for row in data_in_progress: - guid = row[CsvKeys.GUID.value] - if not guid: - raise KeyError("Some rows are missing guids") - self.compiled_data.setdefault(guid, {key.lower(): row[key] for key in row}) - - def set_relevant_data(self, data_set: Dict[str, dict]): - unchanged, changed, added = 0, 0, 0 - for guid in data_set: - if guid in self.compiled_data.keys(): - changed_row = False - for key in data_set[guid]: - if key in self.compiled_data[guid] and self.compiled_data[guid][key] != data_set[guid][key]: - self.compiled_data[guid][key] = data_set[guid][key] - changed_row = True - if changed_row: - changed += 1 - else: - unchanged += 1 - else: - added += 1 - self.compiled_data.setdefault(guid, data_set[guid]) - - if changed > 0 or added > 0: - self.data_set_has_changed = True - - logging.info(f"Set {self.csv_file.file_location} data; changed {changed}, " - f"added {added}, while {unchanged} were identical") - - def write_file_on_close(self): - if self.data_set_has_changed: - self.write_to_csv(list(self.compiled_data.values())) diff --git a/brain_brew/transformers/note_model_mapping.py b/brain_brew/transformers/note_model_mapping.py deleted file mode 100644 index f27dce8..0000000 --- a/brain_brew/transformers/note_model_mapping.py +++ /dev/null @@ -1,178 +0,0 @@ -from dataclasses import dataclass, field -from enum import Enum -from typing import List, Union, Dict, Optional - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.notes import GUID, TAGS -from brain_brew.utils import single_item_to_list - - -class FieldMapping: - class FieldMappingType(Enum): - COLUMN = "column" - PERSONAL_FIELD = "personal_field" - DEFAULT = "default" - - @classmethod - def values(cls): - return set(it.value for it in cls) - - type: FieldMappingType - value: str - field_name: str - - def __init__(self, field_type: FieldMappingType, field_name: str, value: str): - self.type = field_type - self.field_name = field_name.lower() - - if self.type == FieldMapping.FieldMappingType.COLUMN: - self.value = value.lower() - else: - self.value = value - - -@dataclass -class NoteModelMapping(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_models: Union[str, list] - columns_to_fields: Optional[Dict[str, str]] = field(default=None) - personal_fields: List[str] = field(default_factory=lambda: []) - - note_models: Dict[str, PartHolder[NoteModel]] - columns_manually_mapped: List[FieldMapping] - personal_fields: List[FieldMapping] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - note_models = [PartHolder.from_file_manager(model) for model in single_item_to_list(rep.note_models)] - - return cls( - columns_manually_mapped=[FieldMapping( - field_type=FieldMapping.FieldMappingType.COLUMN, - field_name=f, - value=key) for key, f in rep.columns_to_fields.items()] - if rep.columns_to_fields else [], - personal_fields=[FieldMapping( - field_type=FieldMapping.FieldMappingType.PERSONAL_FIELD, - field_name=f, - value="") for f in rep.personal_fields], - note_models=dict(map(lambda nm: (nm.part_id, nm), note_models)) - ) - - def get_note_model_mapping_dict(self): - return {model: self for model in self.note_models} - - def verify_contents(self): - if not self.columns_manually_mapped: # No check needed if no manual mapping is performed - return - - errors = [] - required_field_definitions = [GUID, TAGS] - - extra_fields = [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - - for holder in self.note_models.values(): - model: NoteModel = holder.part - - # Check for Required Fields - missing = [] - for req in required_field_definitions: - if req not in [field.field_name for field in self.columns_manually_mapped]: - missing.append(req) - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Check Fields Align with Note Type - missing = model.check_field_overlap( - [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - ) - missing = [m for m in missing if m not in [field.field_name for field in self.personal_fields]] - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Find mappings which do not exist on any note models - if extra_fields: - extra_fields = model.check_field_extra(extra_fields) - - if extra_fields: - errors.append( - KeyError(f"""Error in note_model_mappings part. \ - Field(s) '{extra_fields}' are defined as mappings, but match no Note Model fields""")) - - if errors: - raise Exception(errors) - - def csv_row_map_to_note_fields(self, row: dict) -> dict: - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - for pf in self.personal_fields: # Add in Personal Fields - relevant_row_data.setdefault(pf.field_name, False) - for column in self.columns_manually_mapped: # Rename from Csv Column to Note Type Field - if column.value in relevant_row_data: - relevant_row_data[column.field_name] = relevant_row_data.pop(column.value) - - return relevant_row_data - - def csv_headers_map_to_note_fields(self, row: list) -> list: - return list(self.csv_row_map_to_note_fields({row_name: "" for row_name in row}).keys()) - - def note_fields_map_to_csv_row(self, row): - for column in self.columns_manually_mapped: # Rename from Note Type Field to Csv Column - if column.field_name in row: - row[column.value] = row.pop(column.field_name) - for pf in self.personal_fields: # Remove Personal Fields - if pf.field_name in row: - del row[pf.field_name] - - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - return relevant_row_data - - def filter_data_row_by_relevant_columns(self, row): - cols = list(row.keys()) - - relevant_columns = [f.value for f in self.columns_manually_mapped] - if not relevant_columns: - return row - - # errors = [KeyError(f"Missing column {rel_col}") for rel_col in relevant_columns if rel_col not in cols] - # if errors: - # raise Exception(errors) - - irrelevant_columns = [column for column in cols if column not in relevant_columns] - if not irrelevant_columns: - return row - - relevant_data = {key: row[key] for key in row if key not in irrelevant_columns} - - return relevant_data - - def field_values_in_note_model_order(self, note_model_name, fields_from_csv): - return [fields_from_csv[f] if f in fields_from_csv else "" - for f in self.note_models[note_model_name].part.field_names_lowercase - ] diff --git a/brain_brew/transformers/save_media_group_to_location.py b/brain_brew/transformers/save_media_group_to_location.py deleted file mode 100644 index fb5e8fb..0000000 --- a/brain_brew/transformers/save_media_group_to_location.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging -from typing import List, Set - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.utils import create_path_if_not_exists - - -def save_media_groups_to_location( - parts: List[MediaGroup], - folder: str, - clear_folder: bool, - recursive: bool -) -> Set[MediaFile]: - - create_path_if_not_exists(folder, is_path_override=True) - - existing_media_group = MediaGroup.from_directory(folder, recursive) - all_media_group = MediaGroup.from_many(parts) - - in_both, to_move, to_delete = all_media_group.compare(existing_media_group) - - for filename, media_file in all_media_group.media_files.items(): - if filename in in_both: - media_file.copy_self_to_target(existing_media_group.media_files[filename].file_path) - # TODO: Check if copying is needed? - elif filename in to_move: - media_file.copy_self_to_target(folder) - - if clear_folder and to_delete: - deleted = '\n'.join(to_delete) - logging.warning(f"Deleting extra files in media folder '{folder}':\n{'-'*20}\n{deleted}\n{'-'*20}") - for delete_name in to_delete: - existing_media_group.media_files[delete_name].delete_self() - - return set(all_media_group.media_files.values()) diff --git a/brain_brew/transformers/save_note_model_to_location.py b/brain_brew/transformers/save_note_model_to_location.py deleted file mode 100644 index 9a7b367..0000000 --- a/brain_brew/transformers/save_note_model_to_location.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging -import os -from typing import List - -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel, CSS_FILE, TEMPLATES -from brain_brew.representation.yaml.note_model_template import HTML_FILE as TEMPLATE_HTML_FILE, NAME as TEMPLATE_NAME, BROWSER_HTML_FILE as TEMPLATE_BROWSER_HTML_FILE -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import create_path_if_not_exists, clear_contents_of_folder - - -def save_note_model_to_location( - model: NoteModel, - folder: str, - clear_folder: bool -) -> str: - - nm_folder = os.path.join(folder, model.name + '/') - create_path_if_not_exists(nm_folder) - - if clear_folder: - clear_contents_of_folder(nm_folder) - - model_encoded = model.encode_as_part_with_empty_file_references() - - model_encoded[CSS_FILE.name] = os.path.join(nm_folder, "style.css") - HTMLFile.write_file(model_encoded[CSS_FILE.name], model.css) - - templates_dict = {t.name: t for t in model.templates} - - for template_data in model_encoded[TEMPLATES.name]: - name = template_data[TEMPLATE_NAME.name] - template = templates_dict[name] - t_data, b_t_data = template.get_template_files_data() - - template_data[TEMPLATE_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name)) - HTMLFile.write_file(template_data[TEMPLATE_HTML_FILE.name], t_data) - - if TEMPLATE_BROWSER_HTML_FILE.name in template_data and b_t_data is not None: - template_data[TEMPLATE_BROWSER_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name + "_browser")) - HTMLFile.write_file(template_data[TEMPLATE_BROWSER_HTML_FILE.name], b_t_data) - - model_yaml_file_name = YamlObject.to_filename_yaml(os.path.join(nm_folder, model.name)) - YamlObject.dump_to_yaml_file(model_yaml_file_name, model_encoded) - - return model_yaml_file_name diff --git a/brain_brew/utils.py b/brain_brew/utils.py deleted file mode 100644 index 2b85f39..0000000 --- a/brain_brew/utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -import os -import random -import re -import shutil -import string -from pathlib import Path -from typing import List - - -def blank_str_if_none(s): - return '' if s is None else s - - -def list_of_str_to_lowercase(list_of_strings): - if not list_of_strings: - return [] - return [entry.lower() for entry in list_of_strings] - - -def single_item_to_list(item): - if isinstance(item, list): - return item - if item is None: - return [] - return [item] - - -def str_to_lowercase_no_separators(str_to_tidy: str): - return re.sub(r'[\s+_-]+', '', str_to_tidy.lower()) - - -def filename_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+$', full_path)[0] - - -def folder_name_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+[/]?$', full_path)[0] - - -def split_by_regex(str_to_split: str, pattern: str) -> List[str]: - return re.split(pattern, str_to_split) - - -def find_media_in_field(field_value: str) -> List[str]: - if not field_value: - return [] - - images = re.findall(r'<\s*?img.*?src="(.*?)"[^>]*?>', field_value) - audio = re.findall(r'\[sound:(.*?)]', field_value) - - return images + audio - - -def find_all_files_in_directory(directory, recursive=False): - found_files = [] - for path, dirs, files in os.walk(directory): - for file in files: - found_files.append(os.path.join(path, file)) - if not recursive: - return found_files - return found_files - - -def create_path_if_not_exists(path, is_path_override=False): - dir_name = os.path.dirname(path) if not is_path_override else path - if not Path(dir_name).is_dir(): - logging.warning(f"Creating missing filepath '{dir_name}'") - os.makedirs(dir_name, exist_ok=True) - - -def clear_contents_of_folder(path): - for filename in os.listdir(path): - file_path = os.path.join(path, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - print('Failed to delete %s. Reason: %s' % (file_path, e)) - - -def split_tags(tags_value: str) -> list: - split = [entry.strip() for entry in re.split(r';\s*|,\s*|\s+', tags_value)] - while "" in split: - split.remove("") - return split - - -def join_tags(tags_list: list) -> str: - return ", ".join(tags_list) # TODO: Make configurable - - -def generate_anki_guid() -> str: - """Return a base91-encoded 64bit random number.""" - - def base62(num: int, extra: str = "") -> str: - s = string - table = s.ascii_letters + s.digits + extra - buf = "" - while num: - num, i = divmod(num, len(table)) - buf = table[i] + buf - return buf - - _base91_extra_chars = "!#$%&()*+,-./:;<=>?@[]^_`{|}~" - - def base91(num: int) -> str: - # all printable characters minus quotes, backslash and separators - return base62(num, _base91_extra_chars) - - return base91(random.randint(0, 2 ** 64 - 1)) - - -def sort_dict(data, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - if case_insensitive_sort: - def sort_method(i): - return tuple((i[column] == "", i[column].lower()) for column in sort_by_keys) - else: - def sort_method(i): - return tuple((i[column] == "", i[column]) for column in sort_by_keys) - - return sorted(data, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(data)) - - return data diff --git a/crates/brain-brew-cli/Cargo.toml b/crates/brain-brew-cli/Cargo.toml new file mode 100644 index 0000000..6c6ae7d --- /dev/null +++ b/crates/brain-brew-cli/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "brainbrew" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false +description = "Rust-based Brain Brew CLI for local-first Anki-compatible deck federation" +homepage = "https://github.com/jeprecated/brain-brew" + +[package.metadata.dist] +dist = true +formula = "brainbrew" + +[[bin]] +name = "brainbrew" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +flate2 = "1" +nix-nar = "0.4" +brain-brew-core.workspace = true +brain-brew-formats.workspace = true +serde_json = "1" +sha2 = "0.10" +tar = "0.4" +tempfile = "3" +ureq = "2" + +[lints] +workspace = true diff --git a/crates/brain-brew-cli/src/args.rs b/crates/brain-brew-cli/src/args.rs new file mode 100644 index 0000000..2fb20f2 --- /dev/null +++ b/crates/brain-brew-cli/src/args.rs @@ -0,0 +1,335 @@ +use std::path::PathBuf; + +use brain_brew_core::{OverlayKind, StableId}; + +pub(crate) struct ManifestTargetArgs { + pub(crate) manifest_path: PathBuf, + pub(crate) target: String, + pub(crate) out_path: Option, + pub(crate) media_root: Option, + pub(crate) include_paths: Vec, + pub(crate) package_roots: Vec, +} + +pub(crate) struct VerifyArgs { + pub(crate) manifest_path: PathBuf, + pub(crate) target: Option, + pub(crate) all_targets: bool, + pub(crate) media_root: Option, + pub(crate) include_paths: Vec, + pub(crate) package_roots: Vec, +} + +pub(crate) struct ExportArgs { + pub(crate) overlay_paths: Vec, + pub(crate) out_path: Option, + pub(crate) media_root: Option, +} + +pub(crate) struct DiffOverlayArgs { + pub(crate) left_path: PathBuf, + pub(crate) right_path: PathBuf, + pub(crate) id: StableId, + pub(crate) kind: OverlayKind, +} + +pub(crate) struct TargetsArgs { + pub(crate) manifest_paths: Vec, + pub(crate) package_roots: Vec, +} + +pub(crate) fn split_json_flag(args: &[String]) -> (bool, Vec) { + let mut json_output = false; + let mut rest = Vec::new(); + for arg in args { + if arg == "--json" { + json_output = true; + } else { + rest.push(arg.clone()); + } + } + (json_output, rest) +} + +pub(crate) fn parse_targets_args(args: &[String]) -> Result { + let mut manifest_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" | "--include" => { + let Some(path) = args.get(index + 1) else { + return Err(format!("{} requires a path", args[index])); + }; + manifest_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + other => return Err(format!("unexpected targets argument {other:?}")), + } + } + if manifest_paths.is_empty() && package_roots.is_empty() { + manifest_paths.push(PathBuf::from("brainbrew.yaml")); + } + Ok(TargetsArgs { + manifest_paths, + package_roots, + }) +} + +pub(crate) fn parse_manifest_target_args(args: &[String]) -> Result { + let mut manifest_path = None; + let mut target = None; + let mut out_path = None; + let mut media_root = None; + let mut include_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" => { + let Some(path) = args.get(index + 1) else { + return Err("--manifest requires a path".to_owned()); + }; + manifest_path = Some(PathBuf::from(path)); + index += 2; + } + "--target" => { + let Some(name) = args.get(index + 1) else { + return Err("--target requires a name".to_owned()); + }; + target = Some(name.clone()); + index += 2; + } + "--out" => { + let Some(path) = args.get(index + 1) else { + return Err("--out requires a path".to_owned()); + }; + out_path = Some(PathBuf::from(path)); + index += 2; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a path".to_owned()); + }; + media_root = Some(PathBuf::from(path)); + index += 2; + } + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + include_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + let Some(target) = target else { + return Err("missing --target".to_owned()); + }; + Ok(ManifestTargetArgs { + manifest_path: manifest_path.unwrap_or_else(|| PathBuf::from("brainbrew.yaml")), + target, + out_path, + media_root, + include_paths, + package_roots, + }) +} + +pub(crate) fn parse_verify_args(args: &[String]) -> Result { + let mut manifest_path = None; + let mut target = None; + let mut all_targets = false; + let mut media_root = None; + let mut include_paths = Vec::new(); + let mut package_roots = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--manifest" => { + let Some(path) = args.get(index + 1) else { + return Err("--manifest requires a path".to_owned()); + }; + manifest_path = Some(PathBuf::from(path)); + index += 2; + } + "--target" => { + let Some(name) = args.get(index + 1) else { + return Err("--target requires a name".to_owned()); + }; + target = Some(name.clone()); + index += 2; + } + "--all-targets" => { + all_targets = true; + index += 1; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a path".to_owned()); + }; + media_root = Some(PathBuf::from(path)); + index += 2; + } + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + include_paths.push(PathBuf::from(path)); + index += 2; + } + "--package-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--package-root requires a path".to_owned()); + }; + package_roots.push(PathBuf::from(path)); + index += 2; + } + other => return Err(format!("unexpected verify argument {other:?}")), + } + } + if all_targets && target.is_some() { + return Err("choose --all-targets or --target, not both".to_owned()); + } + Ok(VerifyArgs { + manifest_path: manifest_path.unwrap_or_else(|| PathBuf::from("brainbrew.yaml")), + target, + all_targets, + media_root, + include_paths, + package_roots, + }) +} + +pub(crate) fn parse_overlay_and_optional_out( + args: &[String], +) -> Result<(Vec, Option), String> { + let export_args = parse_overlay_out_media(args)?; + if export_args.media_root.is_some() { + return Err("--media-root is only supported for media-aware commands".to_owned()); + } + Ok((export_args.overlay_paths, export_args.out_path)) +} + +pub(crate) fn parse_overlay_out_media(args: &[String]) -> Result { + let mut overlay_paths = Vec::new(); + let mut out_path = None; + let mut media_root = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--overlay" => { + let Some(path) = args.get(index + 1) else { + return Err("--overlay requires a path".to_owned()); + }; + overlay_paths.push(path.clone()); + index += 2; + } + "--out" => { + let Some(path) = args.get(index + 1) else { + return Err("--out requires a path".to_owned()); + }; + out_path = Some(PathBuf::from(path)); + index += 2; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a path".to_owned()); + }; + media_root = Some(PathBuf::from(path)); + index += 2; + } + other => return Err(format!("unexpected argument {other:?}")), + } + } + Ok(ExportArgs { + overlay_paths, + out_path, + media_root, + }) +} + +pub(crate) fn parse_required_out(args: &[String]) -> Result { + let Some(index) = args.iter().position(|arg| arg == "--out") else { + return Err("missing --out".to_owned()); + }; + let Some(path) = args.get(index + 1) else { + return Err("--out requires a path".to_owned()); + }; + Ok(PathBuf::from(path)) +} + +pub(crate) fn parse_diff_overlay_args(args: &[String]) -> Result { + let mut paths = Vec::new(); + let mut id = None; + let mut kind = OverlayKind::Patch; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--as-overlay" => index += 1, + "--id" => { + let Some(value) = args.get(index + 1) else { + return Err("--id requires an overlay stable id".to_owned()); + }; + id = Some(stable_id(value)?); + index += 2; + } + "--kind" => { + let Some(value) = args.get(index + 1) else { + return Err("--kind requires an overlay kind".to_owned()); + }; + kind = parse_overlay_kind(value)?; + index += 2; + } + other if !other.starts_with('-') => { + paths.push(PathBuf::from(other)); + index += 1; + } + other => return Err(format!("unexpected diff --as-overlay argument {other:?}")), + } + } + if paths.len() != 2 { + return Err( + "usage: brainbrew diff --as-overlay --id [--kind patch]" + .to_owned(), + ); + } + let Some(id) = id else { + return Err("diff --as-overlay requires --id".to_owned()); + }; + Ok(DiffOverlayArgs { + left_path: paths.remove(0), + right_path: paths.remove(0), + id, + kind, + }) +} + +fn parse_overlay_kind(value: &str) -> Result { + match value { + "translation" => Ok(OverlayKind::Translation), + "extension" => Ok(OverlayKind::Extension), + "patch" => Ok(OverlayKind::Patch), + "personal" => Ok(OverlayKind::Personal), + other => Err(format!("unknown overlay kind {other:?}")), + } +} + +fn stable_id(value: &str) -> Result { + StableId::new(value).map_err(|error| error.to_string()) +} diff --git a/crates/brain-brew-cli/src/commands/compose.rs b/crates/brain-brew-cli/src/commands/compose.rs new file mode 100644 index 0000000..6fec930 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/compose.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::Path; + +use brain_brew_formats::canonical_yaml; + +use crate::args::{parse_manifest_target_args, parse_overlay_and_optional_out}; +use crate::help; +use crate::io::{read_and_compose_deck, read_and_compose_manifest_target_with_packages}; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("compose").expect("compose help exists")); + return Ok(()); + } + + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let manifest_args = parse_manifest_target_args(args)?; + let deck = read_and_compose_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + let yaml = canonical_yaml::to_string(&deck).map_err(|error| error.to_string())?; + if let Some(path) = manifest_args.out_path { + fs::write(&path, yaml).map_err(|error| format!("{}: {error}", path.display()))?; + let mut details = vec![ + ( + "manifest", + manifest_args.manifest_path.display().to_string(), + ), + ("target", manifest_args.target.clone()), + ("output", path.display().to_string()), + ]; + details.extend(output::deck_stats(&deck)); + output::print_success( + format!("composed target {}", manifest_args.target), + &details, + ); + } else { + print!("{yaml}"); + } + return Ok(()); + } + + if args.is_empty() { + return Err(help::usage_error( + "compose", + "usage: brainbrew compose [--overlay overlay.yaml ...] [--out resolved.yaml]", + )); + } + let deck_path = Path::new(&args[0]); + let (overlay_paths, out_path) = parse_overlay_and_optional_out(&args[1..])?; + let deck = read_and_compose_deck(deck_path, &overlay_paths)?; + let yaml = canonical_yaml::to_string(&deck).map_err(|error| error.to_string())?; + if let Some(path) = out_path { + fs::write(&path, yaml).map_err(|error| format!("{}: {error}", path.display()))?; + let mut details = vec![ + ("source", deck_path.display().to_string()), + ("overlays", overlay_paths.len().to_string()), + ("output", path.display().to_string()), + ]; + details.extend(output::deck_stats(&deck)); + output::print_success("composed deck", &details); + } else { + print!("{yaml}"); + } + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/diff.rs b/crates/brain-brew-cli/src/commands/diff.rs new file mode 100644 index 0000000..8adeb50 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/diff.rs @@ -0,0 +1,33 @@ +use std::path::Path; + +use brain_brew_formats::canonical_yaml; + +use crate::args::{parse_diff_overlay_args, split_json_flag}; +use crate::io::read_deck; +use crate::output::{print_human_diff, print_json_diff}; +use crate::overlay_draft::draft_overlay_from_diff; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.iter().any(|arg| arg == "--as-overlay") { + let overlay_args = parse_diff_overlay_args(args)?; + let left = read_deck(&overlay_args.left_path)?; + let right = read_deck(&overlay_args.right_path)?; + let overlay = draft_overlay_from_diff(&left, &right, overlay_args.id, overlay_args.kind)?; + print!("{}", canonical_yaml::overlay_to_string(&overlay)); + return Ok(()); + } + + let (json_output, paths) = split_json_flag(args); + if paths.len() != 2 { + return Err("usage: brainbrew diff [--json]".to_owned()); + } + let left = read_deck(Path::new(&paths[0]))?; + let right = read_deck(Path::new(&paths[1]))?; + let diff = left.semantic_diff(&right); + if json_output { + print_json_diff(&diff); + } else { + print_human_diff(&diff); + } + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/explain.rs b/crates/brain-brew-cli/src/commands/explain.rs new file mode 100644 index 0000000..d237bb4 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/explain.rs @@ -0,0 +1,122 @@ +use serde_json::json; + +use crate::args::{parse_manifest_target_args, split_json_flag}; +use crate::io::plan_manifest_target_with_packages; +use crate::output::{one_line, package_json, semantic_kind_name}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + let (json_output, rest) = split_json_flag(args); + let manifest_args = parse_manifest_target_args(&rest)?; + let plan = plan_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + + if !json_output { + if let Some(package) = &plan.package { + println!("package: {}@{}", package.id, package.version); + } + println!("target: {}", plan.target); + println!("base: {}", plan.base_label); + println!("overlay stack:"); + if plan.overlays.is_empty() { + println!(" (none)"); + } else { + for (index, (overlay, _)) in plan.overlays.iter().enumerate() { + println!(" {}. {} ({})", index + 1, overlay.id, overlay.display_file); + } + } + } + + let overlay_stack = plan + .overlays + .iter() + .map(|(overlay, _)| json!({"id": overlay.id, "file": overlay.display_file})) + .collect::>(); + let overlays = plan + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(); + match plan.base.compose(&overlays) { + Ok(deck) => { + let diff = plan.base.semantic_diff(&deck); + if json_output { + let changes = diff + .changes + .iter() + .map(|change| { + json!({ + "kind": semantic_kind_name(change.kind), + "path": change.path, + "before": change.before, + "after": change.after, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "package": plan.package.as_ref().map(package_json), + "target": plan.target, + "base": plan.base_label, + "overlay_stack": overlay_stack, + "changes": changes, + "errors": [], + })) + .unwrap() + ); + } else { + println!("changes:"); + if diff.is_empty() { + println!(" none"); + } else { + for change in diff.changes { + println!(" {} {}", semantic_kind_name(change.kind), change.path); + if let Some(before) = change.before { + println!(" before: {}", one_line(&before)); + } + if let Some(after) = change.after { + println!(" after: {}", one_line(&after)); + } + } + } + } + Ok(()) + } + Err(report) => { + if json_output { + let errors = report + .errors + .iter() + .map(|error| { + json!({ + "kind": format!("{:?}", error.kind), + "path": error.path, + "message": error.message, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "package": plan.package.as_ref().map(package_json), + "target": plan.target, + "base": plan.base_label, + "overlay_stack": overlay_stack, + "changes": [], + "errors": errors, + })) + .unwrap() + ); + } else { + for error in report.errors { + eprintln!("{:?} {}: {}", error.kind, error.path, error.message); + } + } + Err("target composition failed".to_owned()) + } + } +} diff --git a/crates/brain-brew-cli/src/commands/export.rs b/crates/brain-brew-cli/src/commands/export.rs new file mode 100644 index 0000000..65f7b6f --- /dev/null +++ b/crates/brain-brew-cli/src/commands/export.rs @@ -0,0 +1,109 @@ +use std::fs; +use std::path::Path; + +use brain_brew_core::CanonicalDeck; +use brain_brew_formats::crowdanki; + +use crate::args::{parse_manifest_target_args, parse_overlay_out_media}; +use crate::help; +use crate::io::{ + configured_crowdanki_out, manifest_root, read_and_compose_deck, + read_and_compose_manifest_target_with_packages, read_manifest, root_relative_path, +}; +use crate::media_assets::{copy_media_assets, validate_media_assets}; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if matches!(args, [flag] if flag == "--help" || flag == "-h") + || matches!(args, [format, flag] if format == "crowdanki" && (flag == "--help" || flag == "-h")) + { + print!("{}", help::command("export").expect("export help exists")); + return Ok(()); + } + if args.first().map(String::as_str) != Some("crowdanki") { + return Err(help::usage_error( + "export", + "usage: brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder", + )); + } + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let manifest_args = parse_manifest_target_args(&args[1..])?; + let manifest = read_manifest(&manifest_args.manifest_path)?; + let root = manifest_root(&manifest_args.manifest_path); + let out_dir = if let Some(out_path) = manifest_args.out_path.clone() { + out_path + } else if let Some(path) = configured_crowdanki_out(&manifest, &manifest_args.target) { + root.join(path) + } else { + root.join("build") + .join("crowdanki") + .join(&manifest_args.target) + }; + let deck = read_and_compose_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + let media_root = manifest_args + .media_root + .as_ref() + .map(|path| root_relative_path(&root, path)); + return write_crowdanki_export(&deck, &out_dir, media_root.as_deref()); + } + + if args.len() < 4 { + return Err(help::usage_error( + "export", + "usage: brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder", + )); + } + let deck_path = Path::new(&args[1]); + let export_args = parse_overlay_out_media(&args[2..])?; + let Some(out_dir) = export_args.out_path else { + return Err("missing --out".to_owned()); + }; + + let deck = read_and_compose_deck(deck_path, &export_args.overlay_paths)?; + write_crowdanki_export(&deck, &out_dir, export_args.media_root.as_deref()) +} + +fn write_crowdanki_export( + deck: &CanonicalDeck, + out_dir: &Path, + media_root: Option<&Path>, +) -> Result<(), String> { + if let Some(media_root) = media_root { + validate_media_assets(deck, media_root)?; + } + let export = crowdanki::export_deck(deck).map_err(|error| error.to_string())?; + fs::create_dir_all(out_dir).map_err(|error| format!("{}: {error}", out_dir.display()))?; + fs::write(out_dir.join("deck.json"), export.deck_json) + .map_err(|error| format!("{}: {error}", out_dir.display()))?; + + if let Some(media_root) = media_root { + copy_media_assets(deck, media_root, out_dir)?; + } + + let mut details = vec![("output", out_dir.join("deck.json").display().to_string())]; + details.extend(output::deck_stats(deck)); + if let Some(media_root) = media_root { + details.push(("media root", media_root.display().to_string())); + } + if !export.omitted_tombstones.is_empty() { + details.push(( + "omitted tombstones", + export + .omitted_tombstones + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + )); + } + output::print_success("exported CrowdAnki deck", &details); + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/fmt.rs b/crates/brain-brew-cli/src/commands/fmt.rs new file mode 100644 index 0000000..b3b9799 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/fmt.rs @@ -0,0 +1,23 @@ +use std::fs; +use std::path::Path; + +use crate::help; +use crate::io::format_source; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("fmt").expect("fmt help exists")); + return Ok(()); + } + if args.len() != 1 { + return Err(help::usage_error("fmt", "usage: brainbrew fmt ")); + } + let path = Path::new(&args[0]); + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let formatted = + format_source(&input).map_err(|error| format!("{}: {error}", path.display()))?; + fs::write(path, formatted).map_err(|error| format!("{}: {error}", path.display()))?; + output::print_success("formatted source", &[("path", path.display().to_string())]); + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/import.rs b/crates/brain-brew-cli/src/commands/import.rs new file mode 100644 index 0000000..fcd0723 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/import.rs @@ -0,0 +1,38 @@ +use std::fs; +use std::path::Path; + +use brain_brew_formats::{canonical_yaml, crowdanki}; + +use crate::args::parse_required_out; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.first().map(String::as_str) != Some("crowdanki") { + return Err( + "usage: brainbrew import crowdanki --accept-suggested-ids --out deck.yaml" + .to_owned(), + ); + } + if !args.iter().any(|arg| arg == "--accept-suggested-ids") { + return Err( + "non-interactive CrowdAnki import requires --accept-suggested-ids for now".to_owned(), + ); + } + if args.len() < 5 { + return Err( + "usage: brainbrew import crowdanki --accept-suggested-ids --out deck.yaml" + .to_owned(), + ); + } + + let deck_dir = Path::new(&args[1]); + let out_path = parse_required_out(&args[2..])?; + let deck_json_path = deck_dir.join("deck.json"); + let deck_json = fs::read_to_string(&deck_json_path) + .map_err(|error| format!("{}: {error}", deck_json_path.display()))?; + let deck = crowdanki::import_deck_accept_suggested_ids(&deck_json) + .map_err(|error| error.to_string())?; + let yaml = canonical_yaml::to_string(&deck).map_err(|error| error.to_string())?; + fs::write(&out_path, yaml).map_err(|error| format!("{}: {error}", out_path.display()))?; + println!("imported crowdanki deck"); + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/lock.rs b/crates/brain-brew-cli/src/commands/lock.rs new file mode 100644 index 0000000..72cadd4 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/lock.rs @@ -0,0 +1,885 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::{Cursor, Read}; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use brain_brew_formats::lockfile::{ + self, FederationLock, LockedPackage, LockedPackageMetadata, LockedSource, +}; +use flate2::read::GzDecoder; +use nix_nar::Encoder; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tar::Archive; +use tempfile::TempDir; + +use crate::help; +use crate::io::read_manifest; +use crate::output; + +const USER_AGENT: &str = concat!("brainbrew/", env!("CARGO_PKG_VERSION")); + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("lock").expect("lock help exists")); + return Ok(()); + } + let Some(subcommand) = args.first().map(String::as_str) else { + return Err(help::usage_error( + "lock", + "usage: brainbrew lock ", + )); + }; + match subcommand { + "update" => update(&args[1..]), + "verify" => verify(&args[1..]), + other => Err(format!("unknown lock subcommand {other:?}")), + } +} + +fn update(args: &[String]) -> Result<(), String> { + let args = parse_lock_update_args(args)?; + let requested = args.source.to_requested_source()?; + let fetched = fetch_source(&requested, None)?; + let package_manifest = fetched.source_path.join(&args.package_manifest); + let manifest = read_manifest(&package_manifest)?; + let package = manifest.package.as_ref().ok_or_else(|| { + format!( + "locked package source {} has no package metadata in {}", + fetched.source_path.display(), + args.package_manifest.display() + ) + })?; + if package.id != args.package_id { + return Err(format!( + "locked package source declares package id {}, expected {}", + package.id, args.package_id + )); + } + + let mut lock = read_lock_or_empty(&args.lock_path)?; + lock.packages.insert( + args.package_id.clone(), + LockedPackage { + manifest: args.package_manifest.display().to_string(), + package: LockedPackageMetadata { + version: package.version.clone(), + }, + original: Some(requested.original_source()), + locked: requested.locked_source(&fetched), + }, + ); + let formatted = lockfile::to_string(&lock); + if let Some(parent) = args.lock_path.parent() { + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + fs::write(&args.lock_path, formatted) + .map_err(|error| format!("{}: {error}", args.lock_path.display()))?; + + output::print_success( + format!("updated lock package {}", args.package_id), + &[ + ("lock", args.lock_path.display().to_string()), + ("version", package.version.clone()), + ("nar_hash", fetched.nar_hash), + ], + ); + Ok(()) +} + +fn verify(args: &[String]) -> Result<(), String> { + let args = parse_lock_verify_args(args)?; + let lock = read_lock(&args.lock_path)?; + for (package_id, package) in &lock.packages { + let fetched = fetch_locked_source(&args.lock_path, package_id, &package.locked)?; + if let Some(expected_hash) = &package.locked.nar_hash + && &fetched.nar_hash != expected_hash + { + return Err(format!( + "locked package {package_id} nar_hash mismatch: expected {expected_hash}, found {}", + fetched.nar_hash + )); + } + let manifest_path = fetched.source_path.join(&package.manifest); + verify_locked_manifest_metadata(package_id, package, &manifest_path)?; + } + + let suffix = if lock.packages.len() == 1 { "" } else { "s" }; + output::print_success( + format!("verified {} locked package{suffix}", lock.packages.len()), + &[("lock", args.lock_path.display().to_string())], + ); + Ok(()) +} + +#[derive(Debug)] +struct LockUpdateArgs { + lock_path: PathBuf, + package_id: String, + package_manifest: PathBuf, + source: UpdateSource, +} + +#[derive(Debug)] +enum UpdateSource { + Path(PathBuf), + Git { + url: String, + reference: Option, + rev: Option, + }, + Tarball { + url: String, + }, +} + +impl UpdateSource { + fn to_requested_source(&self) -> Result { + match self { + Self::Path(path) => { + let path = canonicalize_for_lock(path)?; + Ok(RequestedSource { + source_type: "path".to_owned(), + url: None, + path: Some(path.display().to_string()), + reference: None, + rev: None, + }) + } + Self::Git { + url, + reference, + rev, + } => Ok(RequestedSource { + source_type: "git".to_owned(), + url: Some(url.clone()), + path: None, + reference: reference.clone(), + rev: rev.clone(), + }), + Self::Tarball { url } => Ok(RequestedSource { + source_type: "tarball".to_owned(), + url: Some(url.clone()), + path: None, + reference: None, + rev: None, + }), + } + } +} + +#[derive(Debug)] +struct LockVerifyArgs { + lock_path: PathBuf, +} + +fn parse_lock_update_args(args: &[String]) -> Result { + let mut lock_path = PathBuf::from("brainbrew.lock"); + let mut package_id = None; + let mut package_manifest = PathBuf::from("brainbrew.yaml"); + let mut path = None; + let mut git = None; + let mut tarball = None; + let mut reference = None; + let mut rev = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--lock" => { + let Some(value) = args.get(index + 1) else { + return Err("--lock requires a path".to_owned()); + }; + lock_path = PathBuf::from(value); + index += 2; + } + "--package" => { + let Some(value) = args.get(index + 1) else { + return Err("--package requires a package id".to_owned()); + }; + package_id = Some(value.clone()); + index += 2; + } + "--package-manifest" => { + let Some(value) = args.get(index + 1) else { + return Err("--package-manifest requires a path".to_owned()); + }; + package_manifest = PathBuf::from(value); + index += 2; + } + "--path" => { + let Some(value) = args.get(index + 1) else { + return Err("--path requires a directory".to_owned()); + }; + path = Some(PathBuf::from(value)); + index += 2; + } + "--git" => { + let Some(value) = args.get(index + 1) else { + return Err("--git requires a URL".to_owned()); + }; + git = Some(value.clone()); + index += 2; + } + "--tarball" => { + let Some(value) = args.get(index + 1) else { + return Err("--tarball requires a URL".to_owned()); + }; + tarball = Some(value.clone()); + index += 2; + } + "--ref" => { + let Some(value) = args.get(index + 1) else { + return Err("--ref requires a ref".to_owned()); + }; + reference = Some(value.clone()); + index += 2; + } + "--rev" => { + let Some(value) = args.get(index + 1) else { + return Err("--rev requires a revision".to_owned()); + }; + rev = Some(value.clone()); + index += 2; + } + other => return Err(format!("unexpected lock update argument {other:?}")), + } + } + let Some(package_id) = package_id else { + return Err("lock update requires --package".to_owned()); + }; + let source_count = + usize::from(path.is_some()) + usize::from(git.is_some()) + usize::from(tarball.is_some()); + if source_count != 1 { + return Err("lock update requires exactly one of --path, --git, or --tarball".to_owned()); + } + let source = if let Some(path) = path { + if reference.is_some() || rev.is_some() { + return Err("--ref and --rev are only valid with --git".to_owned()); + } + UpdateSource::Path(path) + } else if let Some(url) = git { + UpdateSource::Git { + url, + reference, + rev, + } + } else { + if reference.is_some() || rev.is_some() { + return Err("--ref and --rev are only valid with --git".to_owned()); + } + UpdateSource::Tarball { + url: tarball.expect("source_count checked"), + } + }; + + Ok(LockUpdateArgs { + lock_path, + package_id, + package_manifest, + source, + }) +} + +fn parse_lock_verify_args(args: &[String]) -> Result { + let mut lock_path = PathBuf::from("brainbrew.lock"); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--lock" => { + let Some(value) = args.get(index + 1) else { + return Err("--lock requires a path".to_owned()); + }; + lock_path = PathBuf::from(value); + index += 2; + } + other => return Err(format!("unexpected lock verify argument {other:?}")), + } + } + Ok(LockVerifyArgs { lock_path }) +} + +fn read_lock(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + lockfile::from_str(&input).map_err(|error| error.to_string()) +} + +fn read_lock_or_empty(path: &Path) -> Result { + if path.exists() { + read_lock(path) + } else { + Ok(FederationLock { + version: 1, + packages: BTreeMap::new(), + }) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct RequestedSource { + pub(crate) source_type: String, + pub(crate) url: Option, + pub(crate) path: Option, + pub(crate) reference: Option, + pub(crate) rev: Option, +} + +impl RequestedSource { + fn original_source(&self) -> LockedSource { + LockedSource { + source_type: self.source_type.clone(), + url: self.url.clone(), + path: self.path.clone(), + reference: self.reference.clone(), + rev: self.rev.clone(), + nar_hash: None, + } + } + + fn locked_source(&self, fetched: &FetchedSource) -> LockedSource { + match self.source_type.as_str() { + "path" => LockedSource { + source_type: "path".to_owned(), + url: None, + path: self.path.clone(), + reference: None, + rev: None, + nar_hash: Some(fetched.nar_hash.clone()), + }, + "git" => LockedSource { + source_type: "git".to_owned(), + url: self.url.clone(), + path: None, + reference: None, + rev: fetched.rev.clone().or_else(|| self.rev.clone()), + nar_hash: Some(fetched.nar_hash.clone()), + }, + "tarball" => LockedSource { + source_type: "tarball".to_owned(), + url: self.url.clone(), + path: None, + reference: None, + rev: None, + nar_hash: Some(fetched.nar_hash.clone()), + }, + other => LockedSource { + source_type: other.to_owned(), + url: self.url.clone(), + path: self.path.clone(), + reference: self.reference.clone(), + rev: self.rev.clone(), + nar_hash: Some(fetched.nar_hash.clone()), + }, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct FetchedSource { + pub(crate) source_path: PathBuf, + pub(crate) nar_hash: String, + pub(crate) rev: Option, +} + +pub(crate) fn fetch_locked_source( + lock_path: &Path, + package_id: &str, + source: &LockedSource, +) -> Result { + let expected_hash = source.nar_hash.as_deref(); + if let Some(cached) = cached_source(expected_hash)? { + return Ok(cached); + } + + let requested = RequestedSource { + source_type: source.source_type.clone(), + url: source.url.clone(), + path: source + .path + .as_deref() + .map(|path| lock_relative_path(lock_path, path).display().to_string()), + reference: source.reference.clone(), + rev: source.rev.clone(), + }; + fetch_source(&requested, expected_hash) + .map_err(|error| format!("locked package {package_id}: {error}")) +} + +pub(crate) fn locked_package_manifest_paths(lock_path: &Path) -> Result, String> { + if !lock_path.exists() { + return Ok(Vec::new()); + } + let lock = read_lock(lock_path)?; + lock.packages + .iter() + .map(|(package_id, package)| { + let fetched = fetch_locked_source(lock_path, package_id, &package.locked)?; + if let Some(expected_hash) = &package.locked.nar_hash + && &fetched.nar_hash != expected_hash + { + return Err(format!( + "locked package {package_id} nar_hash mismatch: expected {expected_hash}, found {}", + fetched.nar_hash + )); + } + let manifest_path = fetched.source_path.join(&package.manifest); + verify_locked_manifest_metadata(package_id, package, &manifest_path)?; + Ok(manifest_path) + }) + .collect() +} + +fn fetch_source( + source: &RequestedSource, + expected_hash: Option<&str>, +) -> Result { + match source.source_type.as_str() { + "path" => { + let Some(path) = &source.path else { + return Err("path source requires path".to_owned()); + }; + snapshot_source_tree(Path::new(path), expected_hash, None) + } + "git" => fetch_git_source(source, expected_hash), + "tarball" => { + let Some(url) = &source.url else { + return Err("tarball source requires url".to_owned()); + }; + fetch_tarball_source(url, expected_hash, None) + } + other => Err(format!("unsupported locked source type {other:?}")), + } +} + +fn fetch_git_source( + source: &RequestedSource, + expected_hash: Option<&str>, +) -> Result { + let Some(url) = &source.url else { + return Err("git source requires url".to_owned()); + }; + let Some(repo) = GithubRepo::parse(url) else { + return Err(format!( + "native git locking currently supports GitHub HTTPS URLs; use --tarball for {url:?}" + )); + }; + let rev = if let Some(rev) = &source.rev { + rev.clone() + } else { + resolve_github_rev(&repo, source.reference.as_deref())? + }; + let tarball = repo.codeload_tarball_url(&rev); + fetch_tarball_source(&tarball, expected_hash, Some(rev)) +} + +fn fetch_tarball_source( + url: &str, + expected_hash: Option<&str>, + rev: Option, +) -> Result { + if let Some(cached) = cached_source(expected_hash)? { + return Ok(FetchedSource { rev, ..cached }); + } + + let bytes = read_url_or_file(url)?; + let extracted = TempDir::new().map_err(|error| error.to_string())?; + unpack_tarball(&bytes, extracted.path())?; + let source_root = normalized_extracted_root(extracted.path())?; + snapshot_source_tree(&source_root, expected_hash, rev) +} + +fn snapshot_source_tree( + source_path: &Path, + expected_hash: Option<&str>, + rev: Option, +) -> Result { + let source_path = source_path + .canonicalize() + .map_err(|error| format!("{}: {error}", source_path.display()))?; + if !source_path.is_dir() { + return Err(format!("{} is not a directory", source_path.display())); + } + + let staging = TempDir::new().map_err(|error| error.to_string())?; + let staged_source = staging.path().join("source"); + copy_source_tree_filtered(&source_path, &staged_source)?; + let nar_hash = nar_hash_path(&staged_source)?; + + if let Some(expected_hash) = expected_hash + && nar_hash != expected_hash + { + return Err(format!( + "nar_hash mismatch: expected {expected_hash}, found {nar_hash}" + )); + } + + let cache_path = cache_source_path(&nar_hash); + if !cache_path.exists() { + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + fs::rename(&staged_source, &cache_path).or_else(|_| { + copy_source_tree_filtered(&staged_source, &cache_path)?; + fs::remove_dir_all(&staged_source) + .map_err(|error| format!("{}: {error}", staged_source.display())) + })?; + } + + Ok(FetchedSource { + source_path: cache_path, + nar_hash, + rev, + }) +} + +fn cached_source(expected_hash: Option<&str>) -> Result, String> { + let Some(expected_hash) = expected_hash else { + return Ok(None); + }; + let path = cache_source_path(expected_hash); + if !path.exists() { + return Ok(None); + } + let actual_hash = nar_hash_path(&path)?; + if actual_hash == expected_hash { + return Ok(Some(FetchedSource { + source_path: path, + nar_hash: actual_hash, + rev: None, + })); + } + fs::remove_dir_all(&path).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(None) +} + +fn read_url_or_file(url: &str) -> Result, String> { + if let Some(path) = url.strip_prefix("file://") { + return fs::read(path).map_err(|error| format!("{path}: {error}")); + } + let path = Path::new(url); + if path.exists() { + return fs::read(path).map_err(|error| format!("{}: {error}", path.display())); + } + + let response = ureq::get(url) + .set("User-Agent", USER_AGENT) + .call() + .map_err(|error| format!("failed to fetch {url}: {error}"))?; + let mut reader = response.into_reader(); + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| format!("failed to read {url}: {error}"))?; + Ok(bytes) +} + +fn read_json_url(url: &str) -> Result { + let response = ureq::get(url) + .set("Accept", "application/vnd.github+json") + .set("User-Agent", USER_AGENT) + .call() + .map_err(|error| format!("failed to fetch {url}: {error}"))?; + let mut reader = response.into_reader(); + let mut body = String::new(); + reader + .read_to_string(&mut body) + .map_err(|error| format!("failed to read {url}: {error}"))?; + serde_json::from_str(&body).map_err(|error| format!("failed to parse {url} as JSON: {error}")) +} + +fn unpack_tarball(bytes: &[u8], destination: &Path) -> Result<(), String> { + if bytes.starts_with(&[0x1f, 0x8b]) { + let decoder = GzDecoder::new(Cursor::new(bytes)); + let mut archive = Archive::new(decoder); + archive + .unpack(destination) + .map_err(|error| format!("failed to unpack tar.gz: {error}"))?; + } else { + let mut archive = Archive::new(Cursor::new(bytes)); + archive + .unpack(destination) + .map_err(|error| format!("failed to unpack tar archive: {error}"))?; + } + Ok(()) +} + +fn normalized_extracted_root(path: &Path) -> Result { + let entries = fs::read_dir(path) + .map_err(|error| format!("{}: {error}", path.display()))? + .collect::, _>>() + .map_err(|error| error.to_string())?; + if entries.len() == 1 { + let only = entries[0].path(); + if only.is_dir() { + return Ok(only); + } + } + Ok(path.to_path_buf()) +} + +#[derive(Debug)] +struct GithubRepo { + owner: String, + name: String, +} + +impl GithubRepo { + fn parse(url: &str) -> Option { + let path = url + .strip_prefix("https://github.com/") + .or_else(|| url.strip_prefix("http://github.com/"))?; + let mut parts = path.trim_end_matches('/').split('/'); + let owner = parts.next()?.to_owned(); + let name = parts.next()?.trim_end_matches(".git").to_owned(); + if owner.is_empty() || name.is_empty() || parts.next().is_some() { + return None; + } + Some(Self { owner, name }) + } + + fn api_url(&self) -> String { + format!("https://api.github.com/repos/{}/{}", self.owner, self.name) + } + + fn commit_api_url(&self, reference: &str) -> String { + format!( + "https://api.github.com/repos/{}/{}/commits/{}", + self.owner, + self.name, + percent_encode_path_segment(reference) + ) + } + + fn codeload_tarball_url(&self, rev: &str) -> String { + format!( + "https://codeload.github.com/{}/{}/tar.gz/{}", + self.owner, self.name, rev + ) + } +} + +fn resolve_github_rev(repo: &GithubRepo, reference: Option<&str>) -> Result { + let reference = if let Some(reference) = reference { + reference.to_owned() + } else { + read_json_url(&repo.api_url())? + .get("default_branch") + .and_then(Value::as_str) + .ok_or_else(|| "GitHub repository response did not include default_branch".to_owned())? + .to_owned() + }; + read_json_url(&repo.commit_api_url(&reference))? + .get("sha") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("GitHub commit response did not include sha for {reference:?}")) +} + +fn percent_encode_path_segment(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn verify_locked_manifest_metadata( + package_id: &str, + package: &LockedPackage, + manifest_path: &Path, +) -> Result<(), String> { + let manifest = read_manifest(manifest_path)?; + let metadata = manifest.package.as_ref().ok_or_else(|| { + format!( + "locked package {package_id} manifest {} has no package metadata", + manifest_path.display() + ) + })?; + if metadata.id != *package_id { + return Err(format!( + "locked package {package_id} manifest {} declares package id {}", + manifest_path.display(), + metadata.id + )); + } + if metadata.version != package.package.version { + return Err(format!( + "locked package {package_id} version mismatch: lock has {}, manifest has {}", + package.package.version, metadata.version + )); + } + Ok(()) +} + +fn nar_hash_path(path: &Path) -> Result { + let mut encoder = Encoder::new(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = encoder + .read(&mut buffer) + .map_err(|error| format!("failed to encode {} as NAR: {error}", path.display()))?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!( + "sha256-{}", + BASE64_STANDARD.encode(hasher.finalize()) + )) +} + +fn copy_source_tree_filtered(source: &Path, destination: &Path) -> Result<(), String> { + if destination.exists() { + fs::remove_dir_all(destination) + .map_err(|error| format!("{}: {error}", destination.display()))?; + } + fs::create_dir_all(destination) + .map_err(|error| format!("{}: {error}", destination.display()))?; + copy_dir_contents(source, destination) +} + +fn copy_dir_contents(source: &Path, destination: &Path) -> Result<(), String> { + for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? { + let entry = entry.map_err(|error| error.to_string())?; + let file_name = entry.file_name(); + if should_skip_source_entry(&file_name.to_string_lossy()) { + continue; + } + let source_path = entry.path(); + let destination_path = destination.join(&file_name); + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| format!("{}: {error}", source_path.display()))?; + if metadata.file_type().is_symlink() { + copy_symlink(&source_path, &destination_path)?; + } else if metadata.is_dir() { + fs::create_dir_all(&destination_path) + .map_err(|error| format!("{}: {error}", destination_path.display()))?; + copy_dir_contents(&source_path, &destination_path)?; + } else if metadata.is_file() { + fs::copy(&source_path, &destination_path).map_err(|error| { + format!( + "failed to copy {} to {}: {error}", + source_path.display(), + destination_path.display() + ) + })?; + let permissions = metadata.permissions(); + fs::set_permissions(&destination_path, permissions) + .map_err(|error| format!("{}: {error}", destination_path.display()))?; + } + } + Ok(()) +} + +fn should_skip_source_entry(name: &str) -> bool { + matches!(name, ".git" | ".jj" | ".hg" | ".svn" | "target" | "result") + || name.starts_with("result-") +} + +#[cfg(unix)] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), String> { + use std::os::unix::fs::symlink; + + let target = fs::read_link(source).map_err(|error| format!("{}: {error}", source.display()))?; + symlink(&target, destination).map_err(|error| { + format!( + "failed to copy symlink {} to {}: {error}", + source.display(), + destination.display() + ) + }) +} + +#[cfg(windows)] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), String> { + use std::os::windows::fs::{symlink_dir, symlink_file}; + + let target = fs::read_link(source).map_err(|error| format!("{}: {error}", source.display()))?; + let metadata = + fs::metadata(source).map_err(|error| format!("{}: {error}", source.display()))?; + if metadata.is_dir() { + symlink_dir(&target, destination) + } else { + symlink_file(&target, destination) + } + .map_err(|error| { + format!( + "failed to copy symlink {} to {}: {error}", + source.display(), + destination.display() + ) + }) +} + +fn canonicalize_for_lock(path: &Path) -> Result { + path.canonicalize() + .map_err(|error| format!("{}: {error}", path.display())) +} + +fn lock_relative_path(lock_path: &Path, path: &str) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + lock_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(path) + } +} + +fn cache_source_path(nar_hash: &str) -> PathBuf { + cache_root().join("sources").join(cache_key(nar_hash)) +} + +fn cache_key(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +fn cache_root() -> PathBuf { + if let Some(path) = env::var_os("BRAINBREW_CACHE_DIR") { + return PathBuf::from(path); + } + + #[cfg(windows)] + { + if let Some(path) = env::var_os("LOCALAPPDATA") { + return PathBuf::from(path).join("BrainBrew").join("cache"); + } + } + + #[cfg(target_os = "macos")] + { + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home) + .join("Library") + .join("Caches") + .join("brainbrew"); + } + } + + if let Some(path) = env::var_os("XDG_CACHE_HOME") { + return PathBuf::from(path).join("brainbrew"); + } + if let Some(home) = env::var_os("HOME") { + return PathBuf::from(home).join(".cache").join("brainbrew"); + } + env::temp_dir().join("brainbrew-cache") +} diff --git a/crates/brain-brew-cli/src/commands/mod.rs b/crates/brain-brew-cli/src/commands/mod.rs new file mode 100644 index 0000000..d9fc279 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod compose; +pub(crate) mod diff; +pub(crate) mod explain; +pub(crate) mod export; +pub(crate) mod fmt; +pub(crate) mod import; +pub(crate) mod lock; +pub(crate) mod targets; +pub(crate) mod validate; +pub(crate) mod verify; diff --git a/crates/brain-brew-cli/src/commands/targets.rs b/crates/brain-brew-cli/src/commands/targets.rs new file mode 100644 index 0000000..ad24d5c --- /dev/null +++ b/crates/brain-brew-cli/src/commands/targets.rs @@ -0,0 +1,78 @@ +use serde_json::json; + +use crate::args::{parse_targets_args, split_json_flag}; +use crate::commands::lock::locked_package_manifest_paths; +use crate::io::{manifest_root, read_manifest, target_package_json}; +use crate::output::package_json; +use crate::package_resolver::{discover_package_manifests, validate_package_dependencies}; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + let (json_output, rest) = split_json_flag(args); + let target_args = parse_targets_args(&rest)?; + let mut manifest_paths = target_args.manifest_paths; + manifest_paths.extend(discover_package_manifests(&target_args.package_roots)?); + manifest_paths.sort(); + manifest_paths.dedup(); + let lock_manifest_paths = manifest_paths + .iter() + .map(|path| locked_package_manifest_paths(&manifest_root(path).join("brainbrew.lock"))) + .collect::, String>>()? + .into_iter() + .flatten() + .collect::>(); + let has_lock_manifest_paths = !lock_manifest_paths.is_empty(); + manifest_paths.extend(lock_manifest_paths); + manifest_paths.sort(); + manifest_paths.dedup(); + let manifests = manifest_paths + .iter() + .map(|path| Ok((path, read_manifest(path)?))) + .collect::, String>>()?; + if !target_args.package_roots.is_empty() || has_lock_manifest_paths { + validate_package_dependencies( + &manifests + .iter() + .map(|(path, manifest)| (*path, manifest)) + .collect::>(), + )?; + } + + if json_output { + let packages = manifests + .iter() + .map(|(path, manifest)| target_package_json(path, manifest)) + .collect::, String>>()?; + let package = manifests + .first() + .and_then(|(_, manifest)| manifest.package.as_ref()) + .map(package_json); + let targets = packages + .iter() + .flat_map(|package| package["targets"].as_array().cloned().unwrap_or_default()) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty( + &json!({"package": package, "targets": targets, "packages": packages}) + ) + .unwrap() + ); + } else { + let qualify = manifests.len() > 1; + for (_, manifest) in manifests { + let prefix = manifest.package.as_ref().map(|package| package.id.as_str()); + for target in manifest.targets.keys() { + if qualify { + if let Some(prefix) = prefix { + println!("{prefix}:{target}"); + } else { + println!("{target}"); + } + } else { + println!("{target}"); + } + } + } + } + Ok(()) +} diff --git a/crates/brain-brew-cli/src/commands/validate.rs b/crates/brain-brew-cli/src/commands/validate.rs new file mode 100644 index 0000000..de57a9b --- /dev/null +++ b/crates/brain-brew-cli/src/commands/validate.rs @@ -0,0 +1,128 @@ +use std::path::Path; + +use brain_brew_core::ValidationReport; +use serde_json::json; + +use crate::args::{parse_manifest_target_args, split_json_flag}; +use crate::help; +use crate::io::{read_and_compose_deck, read_and_compose_manifest_target_with_packages}; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args + .iter() + .any(|arg| arg == "--manifest" || arg == "--target") + { + let (json_output, rest) = split_json_flag(args); + let manifest_args = parse_manifest_target_args(&rest)?; + let deck = read_and_compose_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + let mut details = vec![ + ( + "manifest", + manifest_args.manifest_path.display().to_string(), + ), + ("target", manifest_args.target.clone()), + ]; + details.extend(output::deck_stats(&deck)); + return report_validation( + deck.validate(), + json_output, + format!("valid target {}", manifest_args.target), + details, + ); + } + + let mut json_output = false; + let mut deck_path = None; + let mut overlay_paths = Vec::new(); + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--json" => { + json_output = true; + index += 1; + } + "--overlay" => { + let Some(path) = args.get(index + 1) else { + return Err("--overlay requires a path".to_owned()); + }; + overlay_paths.push(path.clone()); + index += 2; + } + value if deck_path.is_none() => { + deck_path = Some(value.to_owned()); + index += 1; + } + other => return Err(format!("unexpected validate argument {other:?}")), + } + } + let Some(deck_path) = deck_path else { + return Err(help::usage_error( + "validate", + "usage: brainbrew validate [--overlay overlay.yaml ...] [--json]", + )); + }; + + let deck = read_and_compose_deck(Path::new(&deck_path), &overlay_paths)?; + let mut details = vec![("source", deck_path.clone())]; + if !overlay_paths.is_empty() { + details.push(("overlays", overlay_paths.len().to_string())); + } + details.extend(output::deck_stats(&deck)); + report_validation( + deck.validate(), + json_output, + "valid deck".to_owned(), + details, + ) +} + +fn report_validation( + result: Result<(), ValidationReport>, + json_output: bool, + success_message: String, + details: Vec<(&str, String)>, +) -> Result<(), String> { + match result { + Ok(()) => { + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({"status": "valid", "errors": []})) + .unwrap() + ); + } else { + output::print_success(success_message, &details); + } + Ok(()) + } + Err(report) => { + if json_output { + let errors = report + .errors + .iter() + .map(|error| { + json!({ + "kind": format!("{:?}", error.kind), + "path": error.path, + "message": error.message, + }) + }) + .collect::>(); + eprintln!( + "{}", + serde_json::to_string_pretty(&json!({"status": "invalid", "errors": errors})) + .unwrap() + ); + } else { + eprintln!("{report}"); + } + Err("invalid deck".to_owned()) + } + } +} diff --git a/crates/brain-brew-cli/src/commands/verify.rs b/crates/brain-brew-cli/src/commands/verify.rs new file mode 100644 index 0000000..6188c09 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/verify.rs @@ -0,0 +1,120 @@ +use std::fs; +use std::path::Path; + +use brain_brew_core::CanonicalDeck; +use brain_brew_formats::{crowdanki, manifest}; + +use crate::args::parse_verify_args; +use crate::help; +use crate::io::{ + manifest_root, plan_manifest_target_with_packages, read_manifest, root_relative_path, + verify_canonical_deck_format, verify_manifest_format, verify_overlay_format, +}; +use crate::media_assets::validate_media_assets; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!("{}", help::command("verify").expect("verify help exists")); + return Ok(()); + } + let verify_args = parse_verify_args(args)?; + verify_manifest_format(&verify_args.manifest_path)?; + let manifest = read_manifest(&verify_args.manifest_path)?; + let root = manifest_root(&verify_args.manifest_path); + let target_names = if verify_args.all_targets { + manifest.targets.keys().cloned().collect::>() + } else if let Some(target) = verify_args.target { + vec![target] + } else { + return Err(help::usage_error( + "verify", + "usage: brainbrew verify [--manifest brainbrew.yaml] --all-targets", + )); + }; + + verify_canonical_deck_format(&root.join(&manifest.base))?; + let media_root = verify_args + .media_root + .as_ref() + .map(|path| root_relative_path(&root, path)); + for target in &target_names { + let plan = plan_manifest_target_with_packages( + &verify_args.manifest_path, + target, + &verify_args.include_paths, + &verify_args.package_roots, + )?; + for (overlay, _) in &plan.overlays { + verify_overlay_format(&overlay.file)?; + } + let deck = plan.compose()?; + deck.validate().map_err(|error| error.to_string())?; + if let Some(media_root) = &media_root { + validate_media_assets(&deck, media_root)?; + } + verify_configured_exports(&root, &manifest, target, &deck)?; + } + + let suffix = if target_names.len() == 1 { "" } else { "s" }; + let mut details = vec![("manifest", verify_args.manifest_path.display().to_string())]; + if let Some(media_root) = &media_root { + details.push(("media root", media_root.display().to_string())); + } + output::print_success( + format!("verified {} target{suffix}", target_names.len()), + &details, + ); + Ok(()) +} + +fn verify_configured_exports( + root: &Path, + manifest: &manifest::FederatedDeckManifest, + target: &str, + deck: &CanonicalDeck, +) -> Result<(), String> { + let Some(target_entry) = manifest.targets.get(target) else { + return Ok(()); + }; + let Some(export) = &target_entry.exports.crowdanki else { + return Ok(()); + }; + if let Some(golden) = &export.golden { + verify_crowdanki_golden(root, target, golden, &export.golden_allowlist, deck)?; + } + Ok(()) +} + +fn verify_crowdanki_golden( + root: &Path, + target: &str, + golden: &str, + golden_allowlist: &[String], + deck: &CanonicalDeck, +) -> Result<(), String> { + let mut golden_path = root.join(golden); + if golden_path.is_dir() { + golden_path = golden_path.join("deck.json"); + } + let expected = fs::read_to_string(&golden_path) + .map_err(|error| format!("{}: {error}", golden_path.display()))?; + let actual = crowdanki::export_deck(deck) + .map_err(|error| error.to_string())? + .deck_json; + let expected_json = serde_json::from_str::(&expected) + .map_err(|error| format!("{}: {error}", golden_path.display()))?; + let actual_json = + serde_json::from_str::(&actual).expect("CrowdAnki export is valid JSON"); + let options = crowdanki::CrowdAnkiParityOptions { + allowed_path_globs: golden_allowlist.iter().cloned().collect(), + }; + if let Err(report) = crowdanki::compare_deck_json_values(&expected_json, &actual_json, &options) + { + return Err(format!( + "CrowdAnki golden mismatch for target {target}: {}\n{report}", + golden_path.display() + )); + } + Ok(()) +} diff --git a/crates/brain-brew-cli/src/help.rs b/crates/brain-brew-cli/src/help.rs new file mode 100644 index 0000000..b5f6598 --- /dev/null +++ b/crates/brain-brew-cli/src/help.rs @@ -0,0 +1,73 @@ +pub(crate) fn general() -> String { + format!( + concat!( + "Brain Brew {}\n", + "Local-first deck federation and round-trip tooling for Anki-compatible decks.\n\n", + "Usage:\n", + " brainbrew [options]\n\n", + "Commands:\n", + " fmt Format deck, overlay, manifest, or lock YAML in place\n", + " validate Validate a deck file or manifest target\n", + " compose Compose a base deck plus overlays into resolved CanonicalDeck YAML\n", + " export Export a resolved deck to an adapter format, currently CrowdAnki\n", + " import Import CrowdAnki into CanonicalDeck YAML\n", + " lock Update or verify locked federated package inputs\n", + " targets List manifest targets\n", + " verify Run manifest formatting, composition, validation, media, and golden checks\n", + " explain Explain a manifest target and its overlay stack\n", + " diff Compare decks semantically, or emit an overlay draft\n\n", + "Examples:\n", + " brainbrew targets --manifest brainbrew.yaml\n", + " brainbrew validate --manifest brainbrew.yaml --target da-standard\n", + " brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml\n", + " brainbrew compose --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n", + " brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography\n", + " brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/\n", + " brainbrew verify --manifest brainbrew.yaml --all-targets\n\n", + "Run `brainbrew --help` for command-specific examples.\n", + ), + env!("CARGO_PKG_VERSION") + ) +} + +pub(crate) fn command(name: &str) -> Option<&'static str> { + match name { + "fmt" => Some( + "Usage:\n brainbrew fmt \n\nExamples:\n brainbrew fmt deck.yaml\n brainbrew fmt overlays/languages/da.yaml\n brainbrew fmt brainbrew.yaml\n brainbrew fmt brainbrew.lock\n", + ), + "validate" => Some( + "Usage:\n brainbrew validate [--overlay overlay.yaml ...] [--json]\n brainbrew validate --manifest brainbrew.yaml --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--json]\n\nExamples:\n brainbrew validate deck.yaml\n brainbrew validate deck.yaml --overlay overlays/languages/da.yaml\n brainbrew validate --manifest brainbrew.yaml --target da-standard\n brainbrew validate --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew validate --manifest brainbrew.yaml --target de-extended --json\n", + ), + "compose" => Some( + "Usage:\n brainbrew compose [--overlay overlay.yaml ...] [--out resolved.yaml]\n brainbrew compose [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--out resolved.yaml]\n\nExamples:\n brainbrew compose deck.yaml --overlay overlays/languages/da.yaml --out build/da.yaml\n brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml\n brainbrew compose --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew compose --manifest brainbrew.yaml --target da-standard\n", + ), + "export" => Some( + "Usage:\n brainbrew export crowdanki [--overlay overlay.yaml ...] --out build/deck-folder\n brainbrew export crowdanki [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--out build/deck-folder] [--media-root media/]\n\nExamples:\n brainbrew export crowdanki deck.yaml --overlay overlays/languages/da.yaml --out build/da-crowdanki\n brainbrew export crowdanki --manifest brainbrew.yaml --target da-standard\n brainbrew export crowdanki --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america --out build/en-america\n brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/\n\nWhen --out is omitted for a manifest target, the output defaults to build/crowdanki/ unless exports.crowdanki.out is configured.\n", + ), + "import" => Some( + "Usage:\n brainbrew import crowdanki --accept-suggested-ids --out deck.yaml\n\nExamples:\n brainbrew import crowdanki build/de-extended --accept-suggested-ids --out deck.yaml\n", + ), + "lock" => Some( + "Usage:\n brainbrew lock update --package (--path | --git [--ref ] [--rev ] | --tarball ) [--package-manifest brainbrew.yaml] [--lock brainbrew.lock]\n brainbrew lock verify [--lock brainbrew.lock]\n\nExamples:\n brainbrew lock update --package anki-geo.ultimate-geography --path ../ultimate-geography\n brainbrew lock update --package anki-geo.ultimate-geography --git https://github.com/anki-geo/ultimate-geography.git --ref main\n brainbrew lock verify\n", + ), + "targets" => Some( + "Usage:\n brainbrew targets [--manifest brainbrew.yaml] [--include package/brainbrew.yaml ...] [--package-root packages/] [--json]\n\nExamples:\n brainbrew targets --manifest brainbrew.yaml\n brainbrew targets --manifest brainbrew.yaml --json\n brainbrew targets --package-root ../packages\n", + ), + "verify" => Some( + "Usage:\n brainbrew verify [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/] [--media-root media/]\n\nExamples:\n brainbrew verify --manifest brainbrew.yaml --all-targets\n brainbrew verify --manifest brainbrew.yaml --target da-standard\n brainbrew verify --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/\n", + ), + "explain" => Some( + "Usage:\n brainbrew explain [--manifest brainbrew.yaml] --target [--include package/brainbrew.yaml ...] [--package-root packages/] [--json]\n\nExamples:\n brainbrew explain --manifest brainbrew.yaml --target da-standard\n brainbrew explain --manifest america/brainbrew.yaml --include ultimate-geography/brainbrew.yaml --target en-america\n brainbrew explain --manifest brainbrew.yaml --target de-extended --json\n", + ), + "diff" => Some( + "Usage:\n brainbrew diff [--json]\n brainbrew diff --as-overlay --id [--kind patch]\n\nExamples:\n brainbrew diff deck.yaml edited.yaml\n brainbrew diff deck.yaml edited.yaml --json\n brainbrew diff deck.yaml edited.yaml --as-overlay --id overlay.patch.capitals --kind patch\n", + ), + _ => None, + } +} + +pub(crate) fn usage_error(command: &str, fallback: &str) -> String { + self::command(command) + .map(str::to_owned) + .unwrap_or_else(|| fallback.to_owned()) +} diff --git a/crates/brain-brew-cli/src/io.rs b/crates/brain-brew-cli/src/io.rs new file mode 100644 index 0000000..a03f31b --- /dev/null +++ b/crates/brain-brew-cli/src/io.rs @@ -0,0 +1,482 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_core::{CanonicalDeck, Overlay}; +use brain_brew_formats::{canonical_yaml, lockfile, manifest}; +use serde_json::json; + +use crate::commands::lock::locked_package_manifest_paths; +use crate::output::package_json; +use crate::package_resolver::{discover_package_manifests, validate_package_dependencies}; + +pub(crate) fn format_source(input: &str) -> Result { + let mut errors = Vec::new(); + match canonical_yaml::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("deck: {error}")), + } + match canonical_yaml::overlay_format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("overlay: {error}")), + } + match manifest::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("manifest: {error}")), + } + match lockfile::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("lockfile: {error}")), + } + Err(format!( + "unrecognized Brain Brew source file ({})", + errors.join("; ") + )) +} + +pub(crate) fn read_deck(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + canonical_yaml::from_str(&input).map_err(|error| error.to_string()) +} + +pub(crate) fn read_overlay(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + canonical_yaml::overlay_from_str(&input).map_err(|error| error.to_string()) +} + +pub(crate) fn read_manifest(path: &Path) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + manifest::from_str(&input).map_err(|error| error.to_string()) +} + +pub(crate) fn read_and_compose_deck( + deck_path: &Path, + overlay_paths: &[String], +) -> Result { + let deck = read_deck(deck_path)?; + let overlays = overlay_paths + .iter() + .map(|path| read_overlay(Path::new(path))) + .collect::, _>>()?; + deck.compose(&overlays).map_err(|error| error.to_string()) +} + +pub(crate) fn read_and_compose_manifest_target_with_packages( + manifest_path: &Path, + target: &str, + include_paths: &[PathBuf], + package_roots: &[PathBuf], +) -> Result { + let plan = + plan_manifest_target_with_packages(manifest_path, target, include_paths, package_roots)?; + plan.compose() +} + +#[derive(Clone)] +pub(crate) struct PlannedOverlay { + pub(crate) id: String, + pub(crate) file: PathBuf, + pub(crate) display_file: String, +} + +pub(crate) struct ManifestTargetPlan { + pub(crate) package: Option, + pub(crate) target: String, + pub(crate) base_label: String, + pub(crate) base: CanonicalDeck, + pub(crate) overlays: Vec<(PlannedOverlay, Overlay)>, +} + +impl ManifestTargetPlan { + pub(crate) fn compose(&self) -> Result { + self.base + .compose( + &self + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + .map_err(|error| error.to_string()) + } +} + +pub(crate) fn plan_manifest_target_with_packages( + manifest_path: &Path, + target: &str, + include_paths: &[PathBuf], + package_roots: &[PathBuf], +) -> Result { + let registry = ManifestRegistry::load(manifest_path, include_paths, package_roots)?; + registry.plan_root_target(target) +} + +struct LoadedManifest { + path: PathBuf, + root: PathBuf, + manifest: manifest::FederatedDeckManifest, +} + +struct ManifestRegistry { + root_index: usize, + manifests: Vec, + packages: BTreeMap, +} + +impl ManifestRegistry { + fn load( + manifest_path: &Path, + include_paths: &[PathBuf], + package_roots: &[PathBuf], + ) -> Result { + let mut paths = vec![manifest_path.to_path_buf()]; + paths.extend(include_paths.iter().cloned()); + paths.extend(discover_package_manifests(package_roots)?); + paths.sort(); + paths.dedup(); + + let lock_path = manifest_root(manifest_path).join("brainbrew.lock"); + let locked_paths = locked_package_manifest_paths(&lock_path)?; + let has_locked_paths = !locked_paths.is_empty(); + paths.extend(locked_paths); + paths.sort(); + paths.dedup(); + + let root_path = manifest_path.to_path_buf(); + let root_index = paths + .iter() + .position(|path| path == &root_path) + .unwrap_or(0); + let manifests = paths + .iter() + .map(|path| { + let manifest = read_manifest(path)?; + Ok(LoadedManifest { + path: path.clone(), + root: manifest_root(path), + manifest, + }) + }) + .collect::, String>>()?; + if !package_roots.is_empty() || has_locked_paths { + validate_package_dependencies( + &manifests + .iter() + .map(|loaded| (&loaded.path, &loaded.manifest)) + .collect::>(), + )?; + } + + let mut packages = BTreeMap::new(); + for (index, loaded) in manifests.iter().enumerate() { + let Some(package) = &loaded.manifest.package else { + continue; + }; + if let Some(previous) = packages.insert(package.id.clone(), index) { + return Err(format!( + "duplicate package id {} in {} and {}", + package.id, + manifests[previous].path.display(), + loaded.path.display() + )); + } + } + + Ok(Self { + root_index, + manifests, + packages, + }) + } + + fn plan_root_target(&self, target: &str) -> Result { + self.plan_target(self.root_index, target, &mut Vec::new()) + } + + fn plan_target( + &self, + manifest_index: usize, + target: &str, + stack: &mut Vec<(usize, String)>, + ) -> Result { + if stack + .iter() + .any(|(index, name)| *index == manifest_index && name == target) + { + return Err(format!("manifest target dependency cycle at {target}")); + } + stack.push((manifest_index, target.to_owned())); + + let loaded = &self.manifests[manifest_index]; + let target_entry = loaded.manifest.targets.get(target).ok_or_else(|| { + format!( + "manifest target {target:?} does not exist in {}; available targets: {}", + loaded.path.display(), + loaded + .manifest + .targets + .keys() + .cloned() + .collect::>() + .join(", ") + ) + })?; + + let (base_label, base) = if let Some(extends) = &target_entry.extends { + let (base_manifest_index, base_target) = + self.resolve_target_ref(manifest_index, extends)?; + let base_plan = self.plan_target(base_manifest_index, base_target, stack)?; + (extends.clone(), base_plan.compose()?) + } else { + ( + loaded.manifest.base.clone(), + read_deck(&loaded.root.join(&loaded.manifest.base))?, + ) + }; + + let planned_overlays = self.expand_target_overlays(manifest_index, target)?; + let overlays = planned_overlays + .into_iter() + .map(|planned| { + let overlay = read_overlay(&planned.file)?; + Ok((planned, overlay)) + }) + .collect::, String>>()?; + + stack.pop(); + Ok(ManifestTargetPlan { + package: loaded.manifest.package.clone(), + target: target.to_owned(), + base_label, + base, + overlays, + }) + } + + fn expand_target_overlays( + &self, + manifest_index: usize, + target: &str, + ) -> Result, String> { + let loaded = &self.manifests[manifest_index]; + let target_entry = loaded + .manifest + .targets + .get(target) + .ok_or_else(|| format!("manifest target {target:?} does not exist"))?; + let mut visited = BTreeSet::new(); + let mut stack = Vec::new(); + let mut expanded = Vec::new(); + for overlay in &target_entry.overlays { + self.visit_overlay_ref( + manifest_index, + overlay, + &mut visited, + &mut stack, + &mut expanded, + )?; + } + Ok(expanded) + } + + fn visit_overlay_ref( + &self, + current_manifest_index: usize, + overlay_ref: &str, + visited: &mut BTreeSet<(usize, String)>, + stack: &mut Vec<(usize, String)>, + expanded: &mut Vec, + ) -> Result<(), String> { + let (manifest_index, overlay_id) = + self.resolve_overlay_ref(current_manifest_index, overlay_ref)?; + let key = (manifest_index, overlay_id.to_owned()); + if visited.contains(&key) { + return Ok(()); + } + if stack.contains(&key) { + return Err(format!( + "manifest overlay dependency cycle at {overlay_ref}" + )); + } + let loaded = &self.manifests[manifest_index]; + let entry = loaded.manifest.overlays.get(overlay_id).ok_or_else(|| { + format!( + "manifest overlay {overlay_id:?} does not exist in {}", + loaded.path.display() + ) + })?; + stack.push(key.clone()); + for dependency in &entry.depends_on { + self.visit_overlay_ref(manifest_index, dependency, visited, stack, expanded)?; + } + stack.pop(); + + visited.insert(key); + let should_qualify = manifest_index != self.root_index; + let display_file = if should_qualify { + if let Some(package) = &loaded.manifest.package { + format!("{}:{}", package.id, entry.file) + } else { + entry.file.clone() + } + } else { + entry.file.clone() + }; + let id = if should_qualify { + if let Some(package) = &loaded.manifest.package { + format!("{}:{}", package.id, overlay_id) + } else { + overlay_id.to_owned() + } + } else { + overlay_id.to_owned() + }; + expanded.push(PlannedOverlay { + id, + file: loaded.root.join(&entry.file), + display_file, + }); + Ok(()) + } + + fn resolve_target_ref<'a>( + &self, + current_manifest_index: usize, + target_ref: &'a str, + ) -> Result<(usize, &'a str), String> { + if let Some((package_id, target)) = target_ref.split_once(':') { + let Some(index) = self.packages.get(package_id) else { + return Err(format!( + "package {package_id:?} required by target ref {target_ref:?} was not included" + )); + }; + Ok((*index, target)) + } else { + Ok((current_manifest_index, target_ref)) + } + } + + fn resolve_overlay_ref<'a>( + &self, + current_manifest_index: usize, + overlay_ref: &'a str, + ) -> Result<(usize, &'a str), String> { + if let Some((package_id, overlay_id)) = overlay_ref.split_once(':') { + let Some(index) = self.packages.get(package_id) else { + return Err(format!( + "package {package_id:?} required by overlay ref {overlay_ref:?} was not included" + )); + }; + Ok((*index, overlay_id)) + } else { + Ok((current_manifest_index, overlay_ref)) + } + } +} + +pub(crate) fn target_package_json( + path: &Path, + manifest: &manifest::FederatedDeckManifest, +) -> Result { + let targets = manifest + .targets + .keys() + .map(|target| { + let expanded = expand_manifest_target(manifest, target)?; + let overlays = expanded + .overlays + .iter() + .map(|overlay| json!({"id": overlay.id, "file": overlay.file})) + .collect::>(); + let qualified_name = manifest + .package + .as_ref() + .map(|package| format!("{}:{target}", package.id)); + Ok(json!({ + "name": target, + "qualified_name": qualified_name, + "extends": manifest.targets[target].extends.as_ref(), + "overlays": overlays, + })) + }) + .collect::, String>>()?; + Ok(json!({ + "manifest": path.display().to_string(), + "package": manifest.package.as_ref().map(package_json), + "targets": targets, + })) +} + +pub(crate) fn expand_manifest_target( + manifest: &manifest::FederatedDeckManifest, + target: &str, +) -> Result { + manifest.expand_target(target).map_err(|error| match error { + manifest::ManifestError::MissingTarget(_) => format!( + "manifest target {target:?} does not exist; available targets: {}", + manifest + .targets + .keys() + .cloned() + .collect::>() + .join(", ") + ), + other => other.to_string(), + }) +} + +pub(crate) fn verify_canonical_deck_format(path: &Path) -> Result<(), String> { + verify_format_with(path, canonical_yaml::format_str) +} + +pub(crate) fn verify_overlay_format(path: &Path) -> Result<(), String> { + verify_format_with(path, canonical_yaml::overlay_format_str) +} + +pub(crate) fn verify_manifest_format(path: &Path) -> Result<(), String> { + verify_format_with(path, manifest::format_str) +} + +fn verify_format_with( + path: &Path, + format: impl FnOnce(&str) -> Result, +) -> Result<(), String> +where + E: ToString, +{ + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let formatted = format(&input).map_err(|error| error.to_string())?; + if formatted != input { + return Err(format!("{} is not in canonical format", path.display())); + } + Ok(()) +} + +pub(crate) fn root_relative_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +pub(crate) fn configured_crowdanki_out( + manifest: &manifest::FederatedDeckManifest, + target: &str, +) -> Option { + manifest + .targets + .get(target)? + .exports + .crowdanki + .as_ref()? + .out + .as_deref() + .map(PathBuf::from) +} + +pub(crate) fn manifest_root(path: &Path) -> PathBuf { + path.parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() +} diff --git a/crates/brain-brew-cli/src/main.rs b/crates/brain-brew-cli/src/main.rs new file mode 100644 index 0000000..d50489f --- /dev/null +++ b/crates/brain-brew-cli/src/main.rs @@ -0,0 +1,63 @@ +//! Command-line entry point for Brain Brew. + +use std::env; +use std::process; + +mod args; +mod commands; +mod help; +mod io; +mod media_assets; +mod output; +mod overlay_draft; +mod package_resolver; + +fn main() { + if let Err(error) = run() { + output::print_error(&error); + process::exit(1); + } +} + +fn run() -> Result<(), String> { + let args = env::args().skip(1).collect::>(); + let Some(command) = args.first().map(String::as_str) else { + print_usage(); + return Ok(()); + }; + + if args + .get(1) + .is_some_and(|arg| arg == "--help" || arg == "-h") + && let Some(command_help) = help::command(command) + { + print!("{command_help}"); + return Ok(()); + } + + match command { + "fmt" => commands::fmt::run(&args[1..]), + "validate" => commands::validate::run(&args[1..]), + "compose" => commands::compose::run(&args[1..]), + "export" => commands::export::run(&args[1..]), + "import" => commands::import::run(&args[1..]), + "lock" => commands::lock::run(&args[1..]), + "targets" => commands::targets::run(&args[1..]), + "verify" => commands::verify::run(&args[1..]), + "explain" => commands::explain::run(&args[1..]), + "diff" => commands::diff::run(&args[1..]), + "--help" | "-h" => { + print_usage(); + Ok(()) + } + "--version" | "-V" => { + println!("brainbrew {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + other => Err(format!("unknown command {other:?}")), + } +} + +fn print_usage() { + print!("{}", help::general()); +} diff --git a/crates/brain-brew-cli/src/media_assets.rs b/crates/brain-brew-cli/src/media_assets.rs new file mode 100644 index 0000000..5d79c3c --- /dev/null +++ b/crates/brain-brew-cli/src/media_assets.rs @@ -0,0 +1,58 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use brain_brew_core::CanonicalDeck; +use brain_brew_formats::media; + +pub(crate) fn validate_media_assets(deck: &CanonicalDeck, media_root: &Path) -> Result<(), String> { + media::validate_references(deck).map_err(|error| error.to_string())?; + let assets = read_media_assets(deck, media_root)?; + media::validate_hashes(deck, &assets).map_err(|error| error.to_string()) +} + +fn read_media_assets( + deck: &CanonicalDeck, + media_root: &Path, +) -> Result>, String> { + let mut assets = BTreeMap::new(); + for media in deck.media.values() { + let relative_path = safe_media_relative_path(&media.path)?; + let full_path = media_root.join(&relative_path); + let bytes = + fs::read(&full_path).map_err(|error| format!("{}: {error}", full_path.display()))?; + assets.insert(media.path.clone(), bytes); + } + Ok(assets) +} + +pub(crate) fn copy_media_assets( + deck: &CanonicalDeck, + media_root: &Path, + out_dir: &Path, +) -> Result<(), String> { + for media in deck.media.values() { + let relative_path = safe_media_relative_path(&media.path)?; + let source = media_root.join(&relative_path); + let destination = out_dir.join("media").join(&relative_path); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + } + fs::copy(&source, &destination).map_err(|error| { + format!("{} -> {}: {error}", source.display(), destination.display()) + })?; + } + Ok(()) +} + +fn safe_media_relative_path(path: &str) -> Result { + let path = Path::new(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + return Err(format!("unsafe media path {}", path.display())); + } + Ok(path.to_path_buf()) +} diff --git a/crates/brain-brew-cli/src/output.rs b/crates/brain-brew-cli/src/output.rs new file mode 100644 index 0000000..32668fa --- /dev/null +++ b/crates/brain-brew-cli/src/output.rs @@ -0,0 +1,174 @@ +use std::env; +use std::io::{self, IsTerminal}; + +use brain_brew_core::{CanonicalDeck, SemanticChangeKind, SemanticDiff}; +use brain_brew_formats::manifest; +use serde_json::json; + +pub(crate) fn print_json_diff(diff: &SemanticDiff) { + let changes = diff + .changes + .iter() + .map(|change| { + json!({ + "kind": semantic_kind_name(change.kind), + "path": change.path, + "before": change.before, + "after": change.after, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({"changes": changes})).unwrap() + ); +} + +pub(crate) fn print_human_diff(diff: &SemanticDiff) { + if diff.is_empty() { + println!("{} no semantic changes", success_marker()); + return; + } + + let suffix = if diff.changes.len() == 1 { "" } else { "s" }; + println!("{} semantic change{suffix}", diff.changes.len()); + + for change in &diff.changes { + println!(); + println!("{} {}", change_marker(change.kind), change.path); + print_change_values(change); + } +} + +pub(crate) fn print_success(message: impl AsRef, details: &[(&str, String)]) { + println!("{} {}", success_marker(), message.as_ref()); + for (label, value) in details { + println!(" {}: {}", subtle(label), value); + } +} + +pub(crate) fn print_error(message: &str) { + eprintln!("{}", error_text(message)); +} + +pub(crate) fn deck_stats(deck: &CanonicalDeck) -> Vec<(&'static str, String)> { + vec![ + ("deck", deck.name.clone()), + ("notes", deck.notes.len().to_string()), + ("note types", deck.note_types.len().to_string()), + ("card templates", card_template_count(deck).to_string()), + ("media references", deck.media.len().to_string()), + ] +} + +pub(crate) fn semantic_kind_name(kind: SemanticChangeKind) -> &'static str { + match kind { + SemanticChangeKind::Added => "added", + SemanticChangeKind::Removed => "removed", + SemanticChangeKind::Modified => "modified", + SemanticChangeKind::Tombstoned => "tombstoned", + } +} + +pub(crate) fn package_json(package: &manifest::PackageMetadata) -> serde_json::Value { + json!({ + "id": package.id, + "version": package.version, + "compatible_base_versions": package.compatible_base_versions, + "depends_on": package.depends_on, + }) +} + +pub(crate) fn one_line(value: &str) -> String { + value.replace('\n', "\\n") +} + +fn print_change_values(change: &brain_brew_core::SemanticChange) { + match (&change.before, &change.after) { + (Some(before), Some(after)) => { + print_value_lines('-', before); + print_value_lines('+', after); + } + (Some(before), None) => print_value_lines('-', before), + (None, Some(after)) => print_value_lines('+', after), + (None, None) => println!(" {} entity", semantic_kind_name(change.kind)), + } +} + +fn print_value_lines(prefix: char, value: &str) { + let marker = match prefix { + '-' => removed_marker(), + '+' => added_marker(), + _ => prefix.to_string(), + }; + if value.contains('\n') { + println!(" {marker} |"); + for line in value.lines() { + println!(" {line}"); + } + } else if value.is_empty() { + println!(" {marker} \"\""); + } else { + println!(" {marker} {value}"); + } +} + +fn card_template_count(deck: &CanonicalDeck) -> usize { + deck.note_types + .values() + .map(|note_type| note_type.card_templates.len()) + .sum() +} + +fn success_marker() -> String { + color_stdout("✓", "32") +} + +fn change_marker(kind: SemanticChangeKind) -> String { + match kind { + SemanticChangeKind::Added => added_marker(), + SemanticChangeKind::Removed => removed_marker(), + SemanticChangeKind::Modified => color_stdout("~", "33"), + SemanticChangeKind::Tombstoned => color_stdout("×", "31"), + } +} + +fn added_marker() -> String { + color_stdout("+", "32") +} + +fn removed_marker() -> String { + color_stdout("-", "31") +} + +fn subtle(text: &str) -> String { + color_stdout(text, "2") +} + +fn error_text(text: &str) -> String { + color_stderr(text, "31") +} + +fn color_stdout(text: &str, code: &str) -> String { + if color_enabled(io::stdout().is_terminal()) { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } +} + +fn color_stderr(text: &str, code: &str) -> String { + if color_enabled(io::stderr().is_terminal()) { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_owned() + } +} + +fn color_enabled(is_terminal: bool) -> bool { + match env::var("BRAINBREW_COLOR") { + Ok(value) if value == "always" => true, + Ok(value) if value == "never" => false, + _ => env::var_os("NO_COLOR").is_none() && is_terminal, + } +} diff --git a/crates/brain-brew-cli/src/overlay_draft.rs b/crates/brain-brew-cli/src/overlay_draft.rs new file mode 100644 index 0000000..53d6fc3 --- /dev/null +++ b/crates/brain-brew-cli/src/overlay_draft.rs @@ -0,0 +1,299 @@ +use std::collections::BTreeMap; + +use brain_brew_core::{ + AdapterIdChange, AdapterIds, CanonicalDeck, CardTemplateChange, ChangeIntent, DeckChange, + ExpectedBase, FieldChange, FieldDefinitionChange, MediaChange, MediaReference, NoteChange, + NoteTypeChange, Overlay, OverlayKind, PropertyChange, StableId, TagChange, +}; + +pub(crate) fn draft_overlay_from_diff( + left: &CanonicalDeck, + right: &CanonicalDeck, + id: StableId, + kind: OverlayKind, +) -> Result { + let mut overlay = Overlay { + id, + kind, + translations: None, + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + draft_deck_changes(left, right, &mut overlay); + draft_note_type_adapter_changes(left, right, &mut overlay); + draft_note_changes(left, right, &mut overlay); + draft_media_changes(left, right, &mut overlay); + + if overlay.deck_change.is_none() + && overlay.note_changes.is_empty() + && overlay.note_type_changes.is_empty() + && overlay.media_changes.is_empty() + && !left.semantic_diff(right).is_empty() + { + return Err( + "diff --as-overlay currently supports deck name/description, adapter IDs, tags, media references, note additions/removals, and existing note field changes" + .to_owned(), + ); + } + + Ok(overlay) +} + +fn draft_deck_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + let mut deck_change = DeckChange { + name: None, + description: None, + variables: BTreeMap::new(), + adapter_ids: adapter_id_changes(&left.adapter_ids, &right.adapter_ids), + }; + if left.name != right.name { + deck_change.name = Some(replace_property_change(&left.name, &right.name)); + } + if left.description != right.description { + deck_change.description = Some(replace_property_change( + &left.description, + &right.description, + )); + } + if deck_change.name.is_some() + || deck_change.description.is_some() + || !deck_change.adapter_ids.is_empty() + { + overlay.deck_change = Some(deck_change); + } +} + +fn draft_note_type_adapter_changes( + left: &CanonicalDeck, + right: &CanonicalDeck, + overlay: &mut Overlay, +) { + for (note_type_id, left_note_type) in &left.note_types { + let Some(right_note_type) = right.note_types.get(note_type_id) else { + continue; + }; + let adapter_ids = + adapter_id_changes(&left_note_type.adapter_ids, &right_note_type.adapter_ids); + if !adapter_ids.is_empty() { + overlay.note_type_changes.insert( + note_type_id.clone(), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::::new(), + card_templates: BTreeMap::::new(), + adapter_ids, + expected_base: None, + }, + ); + } + } +} + +fn draft_note_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + for (note_id, left_note) in &left.notes { + if !right.notes.contains_key(note_id) { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Remove, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityPresent), + }, + ); + continue; + } + + let right_note = &right.notes[note_id]; + let mut fields = BTreeMap::new(); + for (field_id, left_value) in &left_note.fields { + let Some(right_value) = right_note.fields.get(field_id) else { + continue; + }; + if left_value != right_value { + fields.insert( + field_id.clone(), + FieldChange { + intent: ChangeIntent::Replace, + value: Some(right_value.clone()), + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + } + + let mut tags = BTreeMap::new(); + for tag in left_note.tags.difference(&right_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Remove, + expected_base: Some(ExpectedBase::EntityPresent), + }, + ); + } + for tag in right_note.tags.difference(&left_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Add, + expected_base: None, + }, + ); + } + + let adapter_ids = adapter_id_changes(&left_note.adapter_ids, &right_note.adapter_ids); + + if !fields.is_empty() || !tags.is_empty() || !adapter_ids.is_empty() { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields, + tags, + adapter_ids, + expected_base: None, + }, + ); + } + } + + for (note_id, right_note) in &right.notes { + if !left.notes.contains_key(note_id) { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Add, + note: Some(right_note.clone()), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + ); + } + } +} + +fn draft_media_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + for (media_id, left_media) in &left.media { + match right.media.get(media_id) { + Some(right_media) if left_media != right_media => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Replace, + media: Some(right_media.clone()), + expected_base: Some(ExpectedBase::Value(media_reference_summary( + left_media, + ))), + }, + ); + } + Some(_) => {} + None => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Remove, + media: None, + expected_base: Some(ExpectedBase::Value(media_reference_summary( + left_media, + ))), + }, + ); + } + } + } + + for (media_id, right_media) in &right.media { + if !left.media.contains_key(media_id) { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Add, + media: Some(right_media.clone()), + expected_base: None, + }, + ); + } + } +} + +fn adapter_id_changes(left: &AdapterIds, right: &AdapterIds) -> BTreeMap { + let left = left + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let right = right + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let mut changes = BTreeMap::new(); + + for (key, left_value) in &left { + match right.get(key) { + Some(right_value) if left_value != right_value => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some(right_value.clone()), + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + Some(_) => {} + None => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Remove, + value: None, + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + } + } + + for (key, right_value) in &right { + if !left.contains_key(key) { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Add, + value: Some(right_value.clone()), + expected_base: None, + }, + ); + } + } + + changes +} + +fn media_reference_summary(media: &MediaReference) -> String { + format!("path={};sha256={}", media.path, media.sha256) +} + +fn replace_property_change(before: &str, after: &str) -> PropertyChange { + PropertyChange { + intent: ChangeIntent::Replace, + value: Some(after.to_owned()), + expected_base: Some(ExpectedBase::Value(before.to_owned())), + } +} diff --git a/crates/brain-brew-cli/src/package_resolver.rs b/crates/brain-brew-cli/src/package_resolver.rs new file mode 100644 index 0000000..9648106 --- /dev/null +++ b/crates/brain-brew-cli/src/package_resolver.rs @@ -0,0 +1,93 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::manifest; + +/// Discover local Federated Deck package manifests and validate their package dependencies. +pub(crate) fn discover_package_manifests(roots: &[PathBuf]) -> Result, String> { + let mut paths = BTreeSet::new(); + for root in roots { + collect_manifests(root, &mut paths)?; + } + Ok(paths.into_iter().collect()) +} + +pub(crate) fn validate_package_dependencies( + packages: &[(&PathBuf, &manifest::FederatedDeckManifest)], +) -> Result<(), String> { + let mut by_id = BTreeMap::new(); + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + if let Some(previous) = by_id.insert(package.id.clone(), (*path, package.version.clone())) { + return Err(format!( + "duplicate package id {} in {} and {}", + package.id, + previous.0.display(), + path.display() + )); + } + } + + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + for dependency in &package.depends_on { + let (dependency_id, expected_version) = parse_dependency(dependency); + let Some((dependency_path, actual_version)) = by_id.get(dependency_id) else { + return Err(format!( + "package dependency {dependency_id} required by {} in {} was not found", + package.id, + path.display() + )); + }; + if let Some(expected_version) = expected_version + && actual_version != expected_version + { + return Err(format!( + "package dependency {dependency_id}@{expected_version} required by {} in {} resolved to version {} in {}", + package.id, + path.display(), + actual_version, + dependency_path.display() + )); + } + } + } + + Ok(()) +} + +fn collect_manifests(root: &Path, paths: &mut BTreeSet) -> Result<(), String> { + let metadata = fs::metadata(root).map_err(|error| format!("{}: {error}", root.display()))?; + if metadata.is_file() { + if root.file_name().and_then(|name| name.to_str()) == Some("brainbrew.yaml") { + paths.insert(root.to_path_buf()); + } + return Ok(()); + } + + let manifest = root.join("brainbrew.yaml"); + if manifest.exists() { + paths.insert(manifest); + } + + for entry in fs::read_dir(root).map_err(|error| format!("{}: {error}", root.display()))? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if path.is_dir() { + collect_manifests(&path, paths)?; + } + } + + Ok(()) +} + +fn parse_dependency(dependency: &str) -> (&str, Option<&str>) { + dependency + .split_once('@') + .map_or((dependency, None), |(id, version)| (id, Some(version))) +} diff --git a/crates/brain-brew-cli/tests/cli.rs b/crates/brain-brew-cli/tests/cli.rs new file mode 100644 index 0000000..3d9218b --- /dev/null +++ b/crates/brain-brew-cli/tests/cli.rs @@ -0,0 +1,1691 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use flate2::Compression; +use flate2::write::GzEncoder; +use tar::Builder; + +#[test] +fn top_level_help_includes_examples() { + let output = run(["--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Usage:")); + assert!(out.contains("Examples:")); + assert!(out.contains("brainbrew targets --manifest brainbrew.yaml")); + assert!( + out.contains("brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended") + ); +} + +#[test] +fn command_help_includes_focused_examples() { + let output = run(["compose", "--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Usage:")); + assert!(out.contains( + "brainbrew compose --manifest brainbrew.yaml --target da-standard --out build/da.yaml" + )); +} + +#[test] +fn validate_without_args_shows_usage_examples() { + let output = run(["validate"]); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("Usage:")); + assert!(err.contains("Examples:")); + assert!(err.contains("brainbrew validate deck.yaml")); +} + +#[test] +fn validate_reports_valid_deck_human_readably() { + let dir = temp_dir("validate-valid"); + let deck_path = dir.join("deck.yaml"); + fs::write(&deck_path, SAMPLE_CANONICAL_YAML).unwrap(); + + let output = run(["validate", deck_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("valid deck")); + assert!(out.contains(deck_path.to_str().unwrap())); + assert!(out.contains("notes: 1")); +} + +#[test] +fn validate_reports_invalid_deck_path() { + let dir = temp_dir("validate-invalid"); + let deck_path = dir.join("deck.yaml"); + fs::write( + &deck_path, + SAMPLE_CANONICAL_YAML.replace( + "note_type_id: note-type.country", + "note_type_id: note-type.missing", + ), + ) + .unwrap(); + + let output = run(["validate", deck_path.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("notes.note.finland.note_type_id")); +} + +#[test] +fn fmt_rewrites_canonical_yaml_in_place() { + let dir = temp_dir("fmt"); + let deck_path = dir.join("deck.yaml"); + fs::write(&deck_path, MESSY_CANONICAL_YAML).unwrap(); + + let output = run(["fmt", deck_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + fs::read_to_string(deck_path).unwrap(), + SAMPLE_CANONICAL_YAML + ); +} + +#[test] +fn fmt_rewrites_overlay_yaml_in_place() { + let dir = temp_dir("fmt-overlay"); + let overlay_path = dir.join("overlay.yaml"); + fs::write(&overlay_path, MESSY_OVERLAY_YAML).unwrap(); + + let output = run(["fmt", overlay_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + fs::read_to_string(overlay_path).unwrap(), + CAPITAL_OVERLAY_YAML + ); +} + +#[test] +fn fmt_rewrites_manifest_yaml_in_place() { + let dir = temp_dir("fmt-manifest"); + let manifest_path = dir.join("brainbrew.yaml"); + fs::write(&manifest_path, MESSY_MANIFEST_YAML).unwrap(); + + let output = run(["fmt", manifest_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(fs::read_to_string(manifest_path).unwrap(), MANIFEST_YAML); +} + +#[test] +fn fmt_rewrites_federation_lock_yaml_in_place() { + let dir = temp_dir("fmt-lock"); + let lock_path = dir.join("brainbrew.lock"); + fs::write(&lock_path, MESSY_LOCK_YAML).unwrap(); + + let output = run(["fmt", lock_path.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(fs::read_to_string(lock_path).unwrap(), LOCK_YAML); +} + +#[test] +fn compose_applies_overlay_files_in_order() { + let dir = temp_dir("compose-overlay"); + let deck_path = dir.join("deck.yaml"); + let overlay_path = dir.join("overlay.yaml"); + let resolved_path = dir.join("resolved.yaml"); + fs::write(&deck_path, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write(&overlay_path, CAPITAL_OVERLAY_YAML).unwrap(); + + let output = run([ + "compose", + deck_path.to_str().unwrap(), + "--overlay", + overlay_path.to_str().unwrap(), + "--out", + resolved_path.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("composed deck")); + assert!(out.contains(resolved_path.to_str().unwrap())); + assert!( + fs::read_to_string(resolved_path) + .unwrap() + .contains("field.capital: Helsingfors") + ); +} + +#[test] +fn targets_lists_manifest_targets() { + let dir = temp_dir("targets-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!(stdout(&output), "patched-via-dependency\n"); +} + +#[test] +fn targets_can_discover_multiple_package_manifests() { + let first = temp_dir("targets-package-first"); + let second = temp_dir("targets-package-second"); + write_manifest_workspace(&first); + write_manifest_workspace(&second); + fs::write(first.join("brainbrew.yaml"), MANIFEST_WITH_PACKAGE_YAML).unwrap(); + fs::write( + second.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run([ + "targets", + "--manifest", + first.join("brainbrew.yaml").to_str().unwrap(), + "--include", + second.join("brainbrew.yaml").to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("anki-geo.ultimate-geography:patched-via-dependency")); + assert!(out.contains("anki-geo.rivers:rivers")); +} + +#[test] +fn targets_discovers_package_root_and_validates_dependencies() { + let root = temp_dir("targets-package-root"); + let ug = root.join("ultimate-geography"); + let rivers = root.join("rivers"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&ug); + write_manifest_workspace(&rivers); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography", + "depends_on:\n - anki-geo.ultimate-geography@0.1.0", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("anki-geo.ultimate-geography:patched-via-dependency")); + assert!(out.contains("anki-geo.rivers:rivers")); +} + +#[test] +fn compose_can_resolve_extended_targets_from_brainbrew_lock() { + let root = temp_dir("compose-federated-lock"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + fs::write(america.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + america.join("america.yaml"), + r#"id: overlay.extension.america +kind: extension +notes: + note.finland: + intent: merge + tags: + America::Imported: + intent: add +"#, + ) + .unwrap(); + fs::write( + america.join("brainbrew.yaml"), + r#"package: + id: anki-geo.america + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 +base: deck.yaml +overlays: + overlay.extension.america: + file: america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - overlay.extension.america +"#, + ) + .unwrap(); + fs::write( + america.join("brainbrew.lock"), + format!( + r#"version: 1 +packages: + anki-geo.ultimate-geography: + manifest: brainbrew.yaml + package: + version: 0.1.0 + locked: + type: path + path: '{}' +"#, + ug.canonicalize().unwrap().display() + ), + ) + .unwrap(); + let resolved = root.join("resolved.yaml"); + let cache = root.join("cache"); + + let output = run_with_cache( + [ + "compose", + "--manifest", + america.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "en-america", + "--out", + resolved.to_str().unwrap(), + ], + &cache, + ); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let resolved_source = fs::read_to_string(resolved).unwrap(); + assert!(resolved_source.contains("field.capital: Helsingfors")); + assert!(resolved_source.contains("America::Imported")); +} + +#[test] +fn lock_update_and_verify_path_package_without_nix() { + let root = temp_dir("lock-update-path"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + let lock_path = america.join("brainbrew.lock"); + + let cache = root.join("cache"); + let update = run_with_cache( + [ + "lock", + "update", + "--lock", + lock_path.to_str().unwrap(), + "--package", + "anki-geo.ultimate-geography", + "--path", + ug.to_str().unwrap(), + ], + &cache, + ); + + assert!(update.status.success(), "stderr: {}", stderr(&update)); + let lock_source = fs::read_to_string(&lock_path).unwrap(); + assert!(lock_source.contains("original:\n type: path")); + assert!(lock_source.contains("locked:\n type: path")); + assert!(lock_source.contains(&format!("path: {}", ug.canonicalize().unwrap().display()))); + assert!(!lock_source.contains("/nix/store/")); + assert!(lock_source.contains("nar_hash: 'sha256-")); + + let verify = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(verify.status.success(), "stderr: {}", stderr(&verify)); + assert!(stdout(&verify).contains("verified 1 locked package")); + + fs::write( + &lock_path, + fs::read_to_string(&lock_path) + .unwrap() + .replace("sha256-", "sha256-bad"), + ) + .unwrap(); + let mismatch = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(!mismatch.status.success()); + assert!(stderr(&mismatch).contains("nar_hash mismatch")); +} + +#[test] +fn lock_update_and_verify_tarball_package_without_nix() { + let root = temp_dir("lock-update-tarball"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + let archive_path = root.join("ultimate-geography.tar.gz"); + write_tar_gz(&archive_path, "ultimate-geography", &ug); + let lock_path = america.join("brainbrew.lock"); + let cache = root.join("cache"); + + let update = run_with_cache( + [ + "lock", + "update", + "--lock", + lock_path.to_str().unwrap(), + "--package", + "anki-geo.ultimate-geography", + "--tarball", + &format!("file://{}", archive_path.display()), + ], + &cache, + ); + + assert!(update.status.success(), "stderr: {}", stderr(&update)); + let lock_source = fs::read_to_string(&lock_path).unwrap(); + assert!(lock_source.contains("original:\n type: tarball")); + assert!(lock_source.contains("locked:\n type: tarball")); + assert!(lock_source.contains("nar_hash: 'sha256-")); + + let verify = run_with_cache( + ["lock", "verify", "--lock", lock_path.to_str().unwrap()], + &cache, + ); + + assert!(verify.status.success(), "stderr: {}", stderr(&verify)); + assert!(stdout(&verify).contains("verified 1 locked package")); +} + +#[test] +fn compose_can_extend_targets_and_mix_overlays_from_included_package_manifests() { + let root = temp_dir("compose-federated-package"); + let ug = root.join("ultimate-geography"); + let america = root.join("america"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&america).unwrap(); + write_manifest_workspace(&ug); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + fs::write(america.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + america.join("america.yaml"), + r#"id: overlay.extension.america +kind: extension +notes: + note.finland: + intent: merge + tags: + America::Imported: + intent: add +"#, + ) + .unwrap(); + fs::write( + america.join("brainbrew.yaml"), + r#"package: + id: anki-geo.america + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 +base: deck.yaml +overlays: + overlay.extension.america: + file: america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - overlay.extension.america +"#, + ) + .unwrap(); + let america_manifest = america.join("brainbrew.yaml"); + let ug_manifest = ug.join("brainbrew.yaml"); + let resolved = root.join("resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + america_manifest.to_str().unwrap(), + "--include", + ug_manifest.to_str().unwrap(), + "--target", + "en-america", + "--out", + resolved.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let resolved_source = fs::read_to_string(resolved).unwrap(); + assert!(resolved_source.contains("field.capital: Helsingfors")); + assert!(resolved_source.contains("America::Imported")); + + let mixer = root.join("mixer"); + fs::create_dir_all(&mixer).unwrap(); + fs::write(mixer.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + mixer.join("brainbrew.yaml"), + r#"package: + id: example.mix + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 + - anki-geo.america@0.1.0 +base: deck.yaml +overlays: {} +targets: + en-mixed: + extends: anki-geo.ultimate-geography:patched-via-dependency + overlays: + - anki-geo.america:overlay.extension.america +"#, + ) + .unwrap(); + let mixer_manifest = mixer.join("brainbrew.yaml"); + let mixed_resolved = root.join("mixed-resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + mixer_manifest.to_str().unwrap(), + "--include", + ug_manifest.to_str().unwrap(), + "--include", + america_manifest.to_str().unwrap(), + "--target", + "en-mixed", + "--out", + mixed_resolved.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let mixed_source = fs::read_to_string(mixed_resolved).unwrap(); + assert!(mixed_source.contains("field.capital: Helsingfors")); + assert!(mixed_source.contains("America::Imported")); +} + +#[test] +fn targets_reports_missing_package_dependencies() { + let root = temp_dir("targets-missing-package-dep"); + let rivers = root.join("rivers"); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&rivers); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography", + "depends_on:\n - anki-geo.ultimate-geography@0.1.0", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("package dependency anki-geo.ultimate-geography")); +} + +#[test] +fn targets_reports_package_dependency_version_mismatches() { + let root = temp_dir("targets-package-version-mismatch"); + let ug = root.join("ultimate-geography"); + let rivers = root.join("rivers"); + fs::create_dir_all(&ug).unwrap(); + fs::create_dir_all(&rivers).unwrap(); + write_manifest_workspace(&ug); + write_manifest_workspace(&rivers); + fs::write( + ug.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML.replace(" depends_on:\n - anki-geo.shared-geography\n", ""), + ) + .unwrap(); + fs::write( + rivers.join("brainbrew.yaml"), + MANIFEST_WITH_PACKAGE_YAML + .replace("anki-geo.ultimate-geography", "anki-geo.rivers") + .replace( + "depends_on:\n - anki-geo.shared-geography", + "depends_on:\n - anki-geo.ultimate-geography@9.9.9", + ) + .replace("patched-via-dependency", "rivers"), + ) + .unwrap(); + + let output = run(["targets", "--package-root", root.to_str().unwrap()]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("resolved to version 0.1.0")); +} + +#[test] +fn targets_json_includes_package_metadata() { + let dir = temp_dir("targets-package-json"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_PACKAGE_YAML).unwrap(); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["package"]["id"], "anki-geo.ultimate-geography"); + assert_eq!(json["package"]["version"], "0.1.0"); +} + +#[test] +fn targets_can_report_json_with_expanded_overlays() { + let dir = temp_dir("targets-json"); + write_manifest_workspace(&dir); + + let output = run([ + "targets", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["targets"][0]["name"], "patched-via-dependency"); + assert_eq!(json["targets"][0]["overlays"][0]["id"], "patch.capital"); + assert_eq!( + json["targets"][0]["overlays"][1]["id"], + "noop.after-capital" + ); +} + +#[test] +fn validate_uses_manifest_target() { + let dir = temp_dir("validate-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "validate", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("valid target")); + assert!(out.contains("patched-via-dependency")); +} + +#[test] +fn manifest_target_errors_list_available_targets() { + let dir = temp_dir("missing-target"); + write_manifest_workspace(&dir); + + let output = run([ + "compose", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "missing", + ]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("available targets: patched-via-dependency")); +} + +#[test] +fn compose_uses_manifest_target_dependency_expansion() { + let dir = temp_dir("compose-manifest"); + write_manifest_workspace(&dir); + let resolved_path = dir.join("resolved.yaml"); + + let output = run([ + "compose", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--out", + resolved_path.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("composed target")); + assert!(out.contains("patched-via-dependency")); + assert!(out.contains(resolved_path.to_str().unwrap())); + assert!( + fs::read_to_string(resolved_path) + .unwrap() + .contains("field.capital: Helsingfors") + ); +} + +#[test] +fn export_crowdanki_uses_manifest_target_configured_out() { + let dir = temp_dir("export-manifest-configured-out"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_EXPORTS_YAML).unwrap(); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(dir.join("configured-crowdanki/deck.json").exists()); +} + +#[test] +fn export_crowdanki_defaults_manifest_target_out_to_build_crowdanki_target() { + let dir = temp_dir("export-manifest-default-out"); + write_manifest_workspace(&dir); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!( + dir.join("build/crowdanki/patched-via-dependency/deck.json") + .exists() + ); +} + +#[test] +fn export_crowdanki_uses_manifest_target() { + let dir = temp_dir("export-manifest"); + write_manifest_workspace(&dir); + let export_dir = dir.join("crowdanki"); + + let output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--out", + export_dir.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!( + fs::read_to_string(export_dir.join("deck.json")) + .unwrap() + .contains("Helsingfors") + ); +} + +#[test] +fn verify_compares_configured_crowdanki_golden() { + let dir = temp_dir("verify-golden"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_EXPORTS_YAML).unwrap(); + let golden_dir = dir.join("goldens/patched"); + + let export_output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--out", + golden_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + + let verify_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + ]); + assert!( + verify_output.status.success(), + "stderr: {}", + stderr(&verify_output) + ); + + let golden_path = golden_dir.join("deck.json"); + fs::write( + &golden_path, + fs::read_to_string(&golden_path) + .unwrap() + .replace("Helsingfors", "Helsinki"), + ) + .unwrap(); + let mismatch_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + assert!(!mismatch_output.status.success()); + assert!(stderr(&mismatch_output).contains("CrowdAnki golden mismatch")); +} + +#[test] +fn verify_allows_configured_crowdanki_golden_paths() { + let dir = temp_dir("verify-golden-allowlist"); + write_manifest_workspace(&dir); + fs::write( + dir.join("brainbrew.yaml"), + MANIFEST_WITH_EXPORTS_YAML.replace( + " golden: goldens/patched/deck.json\n", + " golden: goldens/patched/deck.json\n golden_allowlist:\n - '$.name'\n", + ), + ) + .unwrap(); + let golden_dir = dir.join("goldens/patched"); + + let export_output = run([ + "export", + "crowdanki", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--out", + golden_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + + let golden_path = golden_dir.join("deck.json"); + let mut golden_json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap(); + golden_json["name"] = serde_json::json!("Allowed Legacy Name"); + fs::write( + &golden_path, + serde_json::to_string_pretty(&golden_json).unwrap(), + ) + .unwrap(); + + let verify_output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + assert!( + verify_output.status.success(), + "stderr: {}", + stderr(&verify_output) + ); +} + +#[test] +fn verify_checks_all_manifest_targets() { + let dir = temp_dir("verify-manifest"); + write_manifest_workspace(&dir); + + let output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("✓")); + assert!(out.contains("verified 1 target")); +} + +#[test] +fn export_crowdanki_copies_media_from_media_root_and_checks_hashes() { + let dir = temp_dir("export-media"); + let deck_path = dir.join("deck.yaml"); + let media_root = dir.join("media"); + let export_dir = dir.join("crowdanki"); + fs::write(&deck_path, MEDIA_CANONICAL_YAML).unwrap(); + fs::create_dir_all(media_root.join("flags")).unwrap(); + fs::write(media_root.join("flags/fi.png"), b"flag-bytes").unwrap(); + + let output = run([ + "export", + "crowdanki", + deck_path.to_str().unwrap(), + "--media-root", + media_root.to_str().unwrap(), + "--out", + export_dir.to_str().unwrap(), + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert_eq!( + fs::read(export_dir.join("media/flags/fi.png")).unwrap(), + b"flag-bytes" + ); +} + +#[test] +fn verify_checks_media_root_hashes() { + let dir = temp_dir("verify-media"); + fs::write(dir.join("deck.yaml"), MEDIA_CANONICAL_YAML).unwrap(); + fs::write(dir.join("brainbrew.yaml"), SIMPLE_MEDIA_MANIFEST_YAML).unwrap(); + fs::create_dir_all(dir.join("media/flags")).unwrap(); + fs::write(dir.join("media/flags/fi.png"), b"wrong-bytes").unwrap(); + + let output = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--media-root", + dir.join("media").to_str().unwrap(), + ]); + + assert!(!output.status.success()); + assert!(stderr(&output).contains("sha256")); +} + +#[test] +fn explain_reports_expanded_stack_and_diff() { + let dir = temp_dir("explain"); + write_manifest_workspace(&dir); + + let output = run([ + "explain", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("target: patched-via-dependency")); + assert!(out.contains("1. patch.capital (capital.yaml)")); + assert!(out.contains("modified notes.note.finland.fields.field.capital")); +} + +#[test] +fn explain_reports_json_for_ui_consumers() { + let dir = temp_dir("explain-json"); + write_manifest_workspace(&dir); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_WITH_PACKAGE_YAML).unwrap(); + + let output = run([ + "explain", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "patched-via-dependency", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["package"]["id"], "anki-geo.ultimate-geography"); + assert_eq!(json["target"], "patched-via-dependency"); + assert_eq!(json["overlay_stack"][0]["id"], "patch.capital"); + assert_eq!( + json["changes"][0]["path"], + "notes.note.finland.fields.field.capital" + ); +} + +#[test] +fn explain_reports_json_conflicts_for_ui_consumers() { + let dir = temp_dir("explain-conflict-json"); + write_manifest_workspace(&dir); + fs::write(dir.join("second.yaml"), SECOND_CAPITAL_OVERLAY_YAML).unwrap(); + fs::write(dir.join("brainbrew.yaml"), CONFLICT_MANIFEST_YAML).unwrap(); + + let output = run([ + "explain", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "conflict", + "--json", + ]); + + assert!(!output.status.success()); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["target"], "conflict"); + assert_eq!(json["overlay_stack"][1]["id"], "patch.capital.second"); + assert_eq!(json["errors"][0]["kind"], "Conflict"); + assert_eq!( + json["errors"][0]["path"], + "notes.note.finland.fields.field.capital" + ); +} + +#[test] +fn explain_reports_overlay_conflicts_with_stack_context() { + let dir = temp_dir("explain-conflict"); + write_manifest_workspace(&dir); + fs::write(dir.join("second.yaml"), SECOND_CAPITAL_OVERLAY_YAML).unwrap(); + fs::write(dir.join("brainbrew.yaml"), CONFLICT_MANIFEST_YAML).unwrap(); + + let output = run([ + "explain", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "conflict", + ]); + + assert!(!output.status.success()); + assert!(stdout(&output).contains("2. patch.capital.second (second.yaml)")); + assert!(stderr(&output).contains("conflicts with earlier overlay")); +} + +#[test] +fn diff_can_emit_note_field_changes_as_overlay() { + let dir = temp_dir("diff-as-overlay"); + let left = dir.join("left.yaml"); + let right = dir.join("right.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &right, + SAMPLE_CANONICAL_YAML.replace("field.capital: Helsinki", "field.capital: Helsingfors"), + ) + .unwrap(); + + let output = run([ + "diff", + left.to_str().unwrap(), + right.to_str().unwrap(), + "--as-overlay", + "--id", + "overlay.patch.capital", + "--kind", + "patch", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("id: overlay.patch.capital")); + assert!(out.contains("kind: patch")); + assert!(out.contains("field.capital:")); + assert!(out.contains("value: Helsingfors")); + assert!(out.contains("expected_base:\n value: Helsinki")); +} + +#[test] +fn diff_as_overlay_emits_tag_and_adapter_id_changes() { + let dir = temp_dir("diff-as-overlay-tags"); + let left = dir.join("left.yaml"); + let right = dir.join("right.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &right, + SAMPLE_CANONICAL_YAML + .replace(" - Nordic", " - Baltic") + .replace("ug-finland-guid", "ug-finland-guid-v2"), + ) + .unwrap(); + + let output = run([ + "diff", + left.to_str().unwrap(), + right.to_str().unwrap(), + "--as-overlay", + "--id", + "overlay.patch.tags", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains(" Baltic:\n intent: add")); + assert!( + out.contains( + " Nordic:\n intent: remove\n expected_base: entity_present" + ) + ); + assert!(out.contains(" crowdanki:guid:\n intent: replace")); + assert!(out.contains(" value: ug-finland-guid-v2")); +} + +#[test] +fn diff_as_overlay_emits_media_reference_changes() { + let dir = temp_dir("diff-as-overlay-media"); + let left = dir.join("left.yaml"); + let right = dir.join("right.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &right, + SAMPLE_CANONICAL_YAML.replace( + "tombstones: []", + " media.flags-se-png:\n path: flags/se.png\n sha256: ''\ntombstones: []", + ), + ) + .unwrap(); + + let output = run([ + "diff", + left.to_str().unwrap(), + right.to_str().unwrap(), + "--as-overlay", + "--id", + "overlay.patch.media", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("media:\n media.flags-se-png:\n intent: add")); + assert!(out.contains(" path: flags/se.png")); +} + +#[test] +fn diff_as_overlay_emits_note_additions_and_removals() { + let dir = temp_dir("diff-as-overlay-notes"); + let left = dir.join("left.yaml"); + let added = dir.join("added.yaml"); + let removed = dir.join("removed.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &added, + SAMPLE_CANONICAL_YAML.replace( + "media:\n media.flags-fi-png:", + " note.sweden:\n note_type_id: note-type.country\n fields:\n field.capital: Stockholm\n field.country: Sweden\n field.flag: ''\n tags:\n - Europe\n - Nordic\n adapter_ids:\n crowdanki:guid: ug-sweden-guid\nmedia:\n media.flags-fi-png:", + ), + ) + .unwrap(); + fs::write(&removed, SAMPLE_WITHOUT_NOTES_CANONICAL_YAML).unwrap(); + + let add_output = run([ + "diff", + left.to_str().unwrap(), + added.to_str().unwrap(), + "--as-overlay", + "--id", + "overlay.patch.note-add", + ]); + assert!( + add_output.status.success(), + "stderr: {}", + stderr(&add_output) + ); + assert!(stdout(&add_output).contains(" note.sweden:\n intent: add")); + assert!(stdout(&add_output).contains(" note:\n note_type_id: note-type.country")); + + let remove_output = run([ + "diff", + left.to_str().unwrap(), + removed.to_str().unwrap(), + "--as-overlay", + "--id", + "overlay.patch.note-remove", + ]); + assert!( + remove_output.status.success(), + "stderr: {}", + stderr(&remove_output) + ); + assert!( + stdout(&remove_output) + .contains(" note.finland:\n intent: remove\n expected_base: entity_present") + ); +} + +#[test] +fn diff_reports_json_changes_by_stable_path() { + let dir = temp_dir("diff-json"); + let left = dir.join("left.yaml"); + let right = dir.join("right.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &right, + SAMPLE_CANONICAL_YAML.replace("field.capital: Helsinki", "field.capital: Helsingfors"), + ) + .unwrap(); + + let output = run([ + "diff", + left.to_str().unwrap(), + right.to_str().unwrap(), + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + assert!(stdout(&output).contains("\"path\": \"notes.note.finland.fields.field.capital\"")); + assert!(stdout(&output).contains("\"after\": \"Helsingfors\"")); +} + +#[test] +fn diff_reports_human_readable_before_and_after_values() { + let dir = temp_dir("diff-human"); + let left = dir.join("left.yaml"); + let right = dir.join("right.yaml"); + fs::write(&left, SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + &right, + SAMPLE_CANONICAL_YAML + .replace("field.capital: Helsinki", "field.capital: Helsingfors") + .replace("field.country: Finland", "field.country: Suomi"), + ) + .unwrap(); + + let output = run(["diff", left.to_str().unwrap(), right.to_str().unwrap()]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("2 semantic changes")); + assert!(out.contains("~ notes.note.finland.fields.field.capital")); + assert!(out.contains("- Helsinki")); + assert!(out.contains("+ Helsingfors")); + assert!(out.contains("~ notes.note.finland.fields.field.country")); + assert!(out.contains("- Finland")); + assert!(out.contains("+ Suomi")); +} + +#[test] +fn export_and_import_crowdanki_deck_folder() { + let dir = temp_dir("crowdanki-roundtrip"); + let deck_path = dir.join("deck.yaml"); + let export_dir = dir.join("crowdanki"); + let imported_path = dir.join("imported.yaml"); + fs::write(&deck_path, SAMPLE_CANONICAL_YAML).unwrap(); + + let export_output = run([ + "export", + "crowdanki", + deck_path.to_str().unwrap(), + "--out", + export_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + assert!( + fs::read_to_string(export_dir.join("deck.json")) + .unwrap() + .contains("ug-finland-guid") + ); + + let import_output = run([ + "import", + "crowdanki", + export_dir.to_str().unwrap(), + "--accept-suggested-ids", + "--out", + imported_path.to_str().unwrap(), + ]); + + assert!( + import_output.status.success(), + "stderr: {}", + stderr(&import_output) + ); + assert!( + fs::read_to_string(imported_path) + .unwrap() + .contains("id: deck.ultimate-geography") + ); +} + +fn run(args: [&str; N]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_brainbrew")) + .args(args) + .output() + .expect("command runs") +} + +fn run_with_cache(args: [&str; N], cache: &Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_brainbrew")) + .args(args) + .env("BRAINBREW_CACHE_DIR", cache) + .output() + .expect("command runs") +} + +fn write_manifest_workspace(dir: &Path) { + fs::write(dir.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write(dir.join("capital.yaml"), CAPITAL_OVERLAY_YAML).unwrap(); + fs::write(dir.join("noop.yaml"), NOOP_OVERLAY_YAML).unwrap(); + fs::write(dir.join("brainbrew.yaml"), MANIFEST_YAML).unwrap(); +} + +fn write_tar_gz(path: &Path, root_name: &str, source_dir: &Path) { + let file = fs::File::create(path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut archive = Builder::new(encoder); + archive.append_dir_all(root_name, source_dir).unwrap(); + let encoder = archive.into_inner().unwrap(); + encoder.finish().unwrap(); +} + +fn stdout(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn temp_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}-{unique}")); + fs::create_dir_all(&path).unwrap(); + path +} + +const CAPITAL_OVERLAY_YAML: &str = r#"id: overlay.patch.capital +kind: patch +notes: + note.finland: + intent: merge + fields: + field.capital: + intent: replace + value: Helsingfors + expected_base: + value: Helsinki +"#; + +const MESSY_OVERLAY_YAML: &str = r#"kind: patch +id: overlay.patch.capital +notes: + note.finland: + fields: + field.capital: + expected_base: + value: Helsinki + value: Helsingfors + intent: replace + intent: merge +"#; + +const NOOP_OVERLAY_YAML: &str = r#"id: overlay.noop +kind: patch +"#; + +const SECOND_CAPITAL_OVERLAY_YAML: &str = r#"id: overlay.patch.capital.second +kind: patch +notes: + note.finland: + intent: merge + fields: + field.capital: + intent: replace + value: Helsinki City + expected_base: + value: Helsinki +"#; + +const MANIFEST_YAML: &str = r#"base: deck.yaml +overlays: + noop.after-capital: + file: noop.yaml + kind: patch + depends_on: + - patch.capital + patch.capital: + file: capital.yaml + kind: patch +targets: + patched-via-dependency: + overlays: + - noop.after-capital +"#; + +const MANIFEST_WITH_EXPORTS_YAML: &str = r#"base: deck.yaml +overlays: + noop.after-capital: + file: noop.yaml + kind: patch + depends_on: + - patch.capital + patch.capital: + file: capital.yaml + kind: patch +targets: + patched-via-dependency: + overlays: + - noop.after-capital + exports: + crowdanki: + out: configured-crowdanki + golden: goldens/patched/deck.json +"#; + +const MANIFEST_WITH_PACKAGE_YAML: &str = r#"package: + id: anki-geo.ultimate-geography + version: 0.1.0 + compatible_base_versions: + - '>=0.1,<0.2' + depends_on: + - anki-geo.shared-geography +base: deck.yaml +overlays: + noop.after-capital: + file: noop.yaml + kind: patch + depends_on: + - patch.capital + patch.capital: + file: capital.yaml + kind: patch +targets: + patched-via-dependency: + overlays: + - noop.after-capital +"#; + +const CONFLICT_MANIFEST_YAML: &str = r#"base: deck.yaml +overlays: + patch.capital: + file: capital.yaml + kind: patch + patch.capital.second: + file: second.yaml + kind: patch +targets: + conflict: + overlays: + - patch.capital + - patch.capital.second +"#; + +const SIMPLE_MEDIA_MANIFEST_YAML: &str = r#"base: deck.yaml +overlays: {} +targets: + base: + overlays: [] +"#; + +const MESSY_MANIFEST_YAML: &str = r#"targets: + patched-via-dependency: + overlays: [noop.after-capital] +overlays: + patch.capital: + kind: patch + file: capital.yaml + noop.after-capital: + depends_on: [patch.capital] + kind: patch + file: noop.yaml +base: deck.yaml +"#; + +const LOCK_YAML: &str = r#"version: 1 +packages: + anki-geo.ultimate-geography: + manifest: brainbrew.yaml + package: + version: 0.1.0 + original: + type: git + url: https://github.com/anki-geo/ultimate-geography.git + ref: main + locked: + type: git + url: https://github.com/anki-geo/ultimate-geography.git + rev: ccf150a1b21e + nar_hash: sha256-example +"#; + +const MESSY_LOCK_YAML: &str = r#"packages: + anki-geo.ultimate-geography: + locked: + nar_hash: sha256-example + rev: ccf150a1b21e + url: https://github.com/anki-geo/ultimate-geography.git + type: git + original: + ref: main + url: https://github.com/anki-geo/ultimate-geography.git + type: git + package: + version: 0.1.0 + manifest: brainbrew.yaml +version: 1 +"#; + +const MESSY_CANONICAL_YAML: &str = r#"deck: + description: A geography deck fixture. + id: deck.ultimate-geography + name: Ultimate Geography + adapter_ids: + crowdanki:uuid: 43c5ba66-9a65-11e8-90c9-a0481cc15658 +note_types: + note-type.country: + adapter_ids: + crowdanki:uuid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa + name: Country + styling: | + .card { font-family: sans-serif; } + field_order: [field.country, field.capital, field.flag] + fields: + field.flag: { name: Flag } + field.capital: { name: Capital } + field.country: { name: Country } + card_template_order: [template.country-capital] + card_templates: + template.country-capital: + adapter_ids: {} + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' +notes: + note.finland: + adapter_ids: + crowdanki:guid: ug-finland-guid + fields: + field.flag: '' + field.capital: Helsinki + field.country: Finland + note_type_id: note-type.country + tags: [Europe, Nordic] +media: + media.flags-fi-png: + path: flags/fi.png + sha256: '' +tombstones: [] +"#; + +const SAMPLE_WITHOUT_NOTES_CANONICAL_YAML: &str = r#"deck: + id: deck.ultimate-geography + name: Ultimate Geography + description: A geography deck fixture. + adapter_ids: + crowdanki:uuid: 43c5ba66-9a65-11e8-90c9-a0481cc15658 +note_types: + note-type.country: + name: Country + field_order: + - field.country + - field.capital + - field.flag + fields: + field.capital: + name: Capital + field.country: + name: Country + field.flag: + name: Flag + card_template_order: + - template.country-capital + card_templates: + template.country-capital: + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' + adapter_ids: {} + styling: | + .card { font-family: sans-serif; } + adapter_ids: + crowdanki:uuid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +notes: {} +media: + media.flags-fi-png: + path: flags/fi.png + sha256: '' +tombstones: [] +"#; + +const MEDIA_CANONICAL_YAML: &str = r#"deck: + id: deck.media-fixture + name: Media Fixture + description: A deck with media. + adapter_ids: + crowdanki:uuid: media-deck-uuid +note_types: + note-type.country: + name: Country + field_order: + - field.country + - field.flag + fields: + field.country: + name: Country + field.flag: + name: Flag + card_template_order: + - template.country-flag + card_templates: + template.country-flag: + name: Country - Flag + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Flag}}' + adapter_ids: {} + styling: | + .card { font-family: sans-serif; } + adapter_ids: + crowdanki:uuid: media-note-type-uuid +notes: + note.finland: + note_type_id: note-type.country + fields: + field.country: Finland + field.flag: '' + tags: + - Media + adapter_ids: + crowdanki:guid: media-fi-guid +media: + media.flags-fi-png: + path: flags/fi.png + sha256: 14873f4faae48052921f9272d948a369f775b2406e57a9b8d55fb94452b73948 +tombstones: [] +"#; + +const SAMPLE_CANONICAL_YAML: &str = r#"deck: + id: deck.ultimate-geography + name: Ultimate Geography + description: A geography deck fixture. + adapter_ids: + crowdanki:uuid: 43c5ba66-9a65-11e8-90c9-a0481cc15658 +note_types: + note-type.country: + name: Country + field_order: + - field.country + - field.capital + - field.flag + fields: + field.capital: + name: Capital + field.country: + name: Country + field.flag: + name: Flag + card_template_order: + - template.country-capital + card_templates: + template.country-capital: + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' + adapter_ids: {} + styling: | + .card { font-family: sans-serif; } + adapter_ids: + crowdanki:uuid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +notes: + note.finland: + note_type_id: note-type.country + fields: + field.capital: Helsinki + field.country: Finland + field.flag: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-finland-guid +media: + media.flags-fi-png: + path: flags/fi.png + sha256: '' +tombstones: [] +"#; diff --git a/crates/brain-brew-cli/tests/ug_style_fixture.rs b/crates/brain-brew-cli/tests/ug_style_fixture.rs new file mode 100644 index 0000000..d1d6244 --- /dev/null +++ b/crates/brain-brew-cli/tests/ug_style_fixture.rs @@ -0,0 +1,158 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use brain_brew_core::StableId; +use brain_brew_formats::{canonical_yaml, media}; + +#[test] +fn ug_style_fixture_composes_exports_imports_and_diffs_semantically() { + let fixture = fixture_dir(); + let dir = temp_dir("ug-style-fixture"); + let resolved_path = dir.join("resolved.yaml"); + let export_dir = dir.join("crowdanki"); + let imported_path = dir.join("imported.yaml"); + + let targets_output = run([ + "targets", + "--manifest", + fixture.join("brainbrew.yaml").to_str().unwrap(), + ]); + assert!( + targets_output.status.success(), + "stderr: {}", + stderr(&targets_output) + ); + assert_eq!(stdout(&targets_output), "full-demo\n"); + + let verify_output = run([ + "verify", + "--manifest", + fixture.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + ]); + assert!( + verify_output.status.success(), + "stderr: {}", + stderr(&verify_output) + ); + + let compose_output = run([ + "compose", + "--manifest", + fixture.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "full-demo", + "--out", + resolved_path.to_str().unwrap(), + ]); + assert!( + compose_output.status.success(), + "stderr: {}", + stderr(&compose_output) + ); + + let validate_output = run(["validate", resolved_path.to_str().unwrap()]); + assert!( + validate_output.status.success(), + "stderr: {}", + stderr(&validate_output) + ); + + let export_output = run([ + "export", + "crowdanki", + resolved_path.to_str().unwrap(), + "--out", + export_dir.to_str().unwrap(), + ]); + assert!( + export_output.status.success(), + "stderr: {}", + stderr(&export_output) + ); + assert!(stdout(&export_output).contains("omitted tombstones: note.australia")); + + let deck_json = fs::read_to_string(export_dir.join("deck.json")).unwrap(); + let crowdanki: serde_json::Value = serde_json::from_str(&deck_json).unwrap(); + assert_eq!(crowdanki["notes"].as_array().unwrap().len(), 24); + assert_eq!(crowdanki["media_files"].as_array().unwrap().len(), 50); + assert_eq!( + crowdanki["note_models"][0]["flds"] + .as_array() + .unwrap() + .len(), + 10 + ); + assert_eq!( + crowdanki["note_models"][0]["tmpls"] + .as_array() + .unwrap() + .len(), + 6 + ); + assert!(deck_json.contains("Amsterdam (constitutional capital)")); + assert!(deck_json.contains("Starts with H")); + assert!(!deck_json.contains("ug-australia-guid")); + + let import_output = run([ + "import", + "crowdanki", + export_dir.to_str().unwrap(), + "--accept-suggested-ids", + "--out", + imported_path.to_str().unwrap(), + ]); + assert!( + import_output.status.success(), + "stderr: {}", + stderr(&import_output) + ); + + let mut expected_export_projection = + canonical_yaml::from_str(&fs::read_to_string(resolved_path).unwrap()).unwrap(); + media::validate_references(&expected_export_projection) + .expect("fixture media references validate"); + expected_export_projection + .notes + .remove(&sid("note.australia")); + expected_export_projection.tombstones.clear(); + let imported = canonical_yaml::from_str(&fs::read_to_string(imported_path).unwrap()).unwrap(); + + let diff = expected_export_projection.semantic_diff(&imported); + assert!(diff.is_empty(), "unexpected semantic diff: {diff:#?}"); +} + +fn run(args: [&str; N]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_brainbrew")) + .args(args) + .output() + .expect("command runs") +} + +fn stdout(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn temp_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("{name}-{unique}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/ug-style") +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-core/Cargo.toml b/crates/brain-brew-core/Cargo.toml new file mode 100644 index 0000000..f031610 --- /dev/null +++ b/crates/brain-brew-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "brain-brew-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/brain-brew-core/src/lib.rs b/crates/brain-brew-core/src/lib.rs new file mode 100644 index 0000000..5502d30 --- /dev/null +++ b/crates/brain-brew-core/src/lib.rs @@ -0,0 +1,2720 @@ +//! Pure domain model and behavior for Brain Brew. +//! +//! This crate intentionally contains no file formats, filesystem access, terminal UI, +//! or command-line concerns. It owns the CanonicalDeck domain model, validation, +//! composition, and semantic diffing as they are introduced through TDD. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +/// Name of the core crate. +pub const CRATE_NAME: &str = env!("CARGO_PKG_NAME"); + +/// Human-readable identity for a deck entity inside a CanonicalDeck. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct StableId(String); + +impl StableId { + /// Create a stable ID after checking the conservative CanonicalDeck ID syntax. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let is_valid = !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | ':')); + + if is_valid { + Ok(Self(value)) + } else { + Err(InvalidStableId { value }) + } + } + + /// Borrow the stable ID as text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for StableId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Error returned when text is not a valid stable ID. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidStableId { + value: String, +} + +impl InvalidStableId { + /// The rejected stable ID text. + pub fn value(&self) -> &str { + &self.value + } +} + +impl fmt::Display for InvalidStableId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid stable id {:?}", self.value) + } +} + +impl std::error::Error for InvalidStableId {} + +/// Adapter-specific identities keyed by adapter namespace. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AdapterIds(BTreeMap); + +impl AdapterIds { + /// Create an empty adapter ID collection. + pub fn new() -> Self { + Self::default() + } + + /// Insert an adapter identity. + pub fn insert(&mut self, key: impl Into, value: impl Into) -> Option { + self.0.insert(key.into(), value.into()) + } + + /// Look up an adapter identity by namespace key. + pub fn get(&self, key: &str) -> Option<&str> { + self.0.get(key).map(String::as_str) + } + + /// Returns true when an adapter identity exists for the namespace key. + pub fn contains_key(&self, key: &str) -> bool { + self.0.contains_key(key) + } + + /// Remove an adapter identity by namespace key. + pub fn remove(&mut self, key: &str) -> Option { + self.0.remove(key) + } + + /// Iterate adapter identities in deterministic key order. + pub fn iter(&self) -> impl Iterator { + self.0 + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + } + + /// Returns true when no adapter identities are present. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// The format-independent representation of a deck's content and structure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CanonicalDeck { + pub id: StableId, + pub name: String, + pub description: String, + pub variables: BTreeMap, + pub note_types: BTreeMap, + pub notes: BTreeMap, + pub media: BTreeMap, + pub tombstones: BTreeSet, + pub adapter_ids: AdapterIds, +} + +impl CanonicalDeck { + /// Apply an ordered overlay stack to this base deck. + pub fn compose(&self, overlays: &[Overlay]) -> Result { + let mut resolved = self.clone(); + let mut errors = Vec::new(); + let mut changed_paths = BTreeMap::::new(); + + for overlay in overlays { + apply_overlay(&mut resolved, overlay, &mut changed_paths, &mut errors); + } + + if !errors.is_empty() { + return Err(ComposeReport { errors }); + } + + resolved.validate().map_err(|report| ComposeReport { + errors: report + .errors + .into_iter() + .map(|error| { + ComposeError::new( + ComposeErrorKind::ValidationFailed, + error.path, + error.message, + ) + }) + .collect(), + })?; + + Ok(resolved) + } + + /// Compare this deck with another deck by stable IDs and deck entities. + pub fn semantic_diff(&self, other: &Self) -> SemanticDiff { + let mut changes = Vec::new(); + + push_modified_if_changed( + &mut changes, + "deck.name".to_owned(), + &self.name, + &other.name, + ); + push_modified_if_changed( + &mut changes, + "deck.description".to_owned(), + &self.description, + &other.description, + ); + push_modified_if_changed( + &mut changes, + "deck.variables".to_owned(), + &string_map_summary(&self.variables), + &string_map_summary(&other.variables), + ); + + diff_note_types(&self.note_types, &other.note_types, &mut changes); + diff_notes(&self.notes, &other.notes, &mut changes); + diff_media(&self.media, &other.media, &mut changes); + diff_tombstones(&self.tombstones, &other.tombstones, &mut changes); + + SemanticDiff { changes } + } + + /// Render `${variable}` references in deck text using deck, note type, card template, and note scopes. + pub fn render_variables(&self) -> Result { + render_deck_variables(self) + } + + /// Validate strict core invariants that do not require filesystem or format access. + pub fn validate(&self) -> Result<(), ValidationReport> { + let mut errors = Vec::new(); + + for (id, note_type) in &self.note_types { + if ¬e_type.id != id { + errors.push(ValidationError::new( + ValidationErrorKind::MismatchedEntityId, + format!("note_types.{id}.id"), + format!("note type stored under {id} contains id {}", note_type.id), + )); + } + + push_duplicate_id_errors( + note_type.fields.iter().map(|field| &field.id), + ValidationErrorKind::DuplicateFieldDefinition, + |duplicate_id| format!("note_types.{id}.fields.{duplicate_id}"), + &mut errors, + ); + + push_duplicate_id_errors( + note_type.card_templates.iter().map(|template| &template.id), + ValidationErrorKind::DuplicateCardTemplate, + |duplicate_id| format!("note_types.{id}.card_templates.{duplicate_id}"), + &mut errors, + ); + } + + for (id, media) in &self.media { + if &media.id != id { + errors.push(ValidationError::new( + ValidationErrorKind::MismatchedEntityId, + format!("media.{id}.id"), + format!("media stored under {id} contains id {}", media.id), + )); + } + } + + for (id, note) in &self.notes { + if ¬e.id != id { + errors.push(ValidationError::new( + ValidationErrorKind::MismatchedEntityId, + format!("notes.{id}.id"), + format!("note stored under {id} contains id {}", note.id), + )); + } + + let Some(note_type) = self.note_types.get(¬e.note_type_id) else { + errors.push(ValidationError::new( + ValidationErrorKind::MissingNoteType, + format!("notes.{id}.note_type_id"), + format!("note references missing note type {}", note.note_type_id), + )); + continue; + }; + + let expected_field_ids = note_type + .fields + .iter() + .map(|field| field.id.clone()) + .collect::>(); + + for field_id in note.fields.keys() { + if !expected_field_ids.contains(field_id) { + errors.push(ValidationError::new( + ValidationErrorKind::UnknownNoteField, + format!("notes.{id}.fields.{field_id}"), + format!( + "note field {field_id} is not defined by note type {}", + note.note_type_id + ), + )); + } + } + + for field_id in expected_field_ids { + if !note.fields.contains_key(&field_id) { + errors.push(ValidationError::new( + ValidationErrorKind::MissingNoteField, + format!("notes.{id}.fields.{field_id}"), + format!( + "note is missing field {field_id} defined by note type {}", + note.note_type_id + ), + )); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(ValidationReport { errors }) + } + } +} + +fn apply_overlay( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if let Some(translations) = &overlay.translations { + apply_translation_dictionary(resolved, overlay, translations, changed_paths, errors); + } + + if let Some(change) = &overlay.deck_change { + apply_deck_change(resolved, overlay, change, changed_paths, errors); + } + + let mut added_fields = Vec::new(); + for (note_type_id, change) in &overlay.note_type_changes { + match change.intent { + ChangeIntent::Add => apply_note_type_add( + resolved, + overlay, + note_type_id, + change, + changed_paths, + errors, + ), + ChangeIntent::Merge | ChangeIntent::Replace | ChangeIntent::Override => { + added_fields.extend(apply_note_type_change( + resolved, + overlay, + note_type_id, + change, + changed_paths, + errors, + )); + } + ChangeIntent::Remove => apply_note_type_remove( + resolved, + overlay, + note_type_id, + change, + changed_paths, + errors, + ), + } + } + + for (note_id, change) in &overlay.note_changes { + match change.intent { + ChangeIntent::Add => { + apply_note_add(resolved, overlay, note_id, change, changed_paths, errors) + } + ChangeIntent::Merge | ChangeIntent::Replace | ChangeIntent::Override => { + apply_note_merge(resolved, overlay, note_id, change, changed_paths, errors); + } + ChangeIntent::Remove => { + apply_note_remove(resolved, overlay, note_id, change, changed_paths, errors); + } + } + } + + for (note_type_id, field_id) in added_fields { + fill_added_field_blanks(resolved, ¬e_type_id, &field_id); + } + + for (media_id, change) in &overlay.media_changes { + apply_media_change(resolved, overlay, media_id, change, changed_paths, errors); + } +} + +fn fill_added_field_blanks( + resolved: &mut CanonicalDeck, + note_type_id: &StableId, + field_id: &StableId, +) { + for note in resolved + .notes + .values_mut() + .filter(|note| ¬e.note_type_id == note_type_id) + { + note.fields.entry(field_id.clone()).or_default(); + } +} + +fn apply_translation_dictionary( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + translations: &TranslationDictionary, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let mut seen_global_changes = BTreeSet::new(); + let mut seen_path_changes = BTreeSet::new(); + let mut seen_additions = BTreeSet::new(); + let mut seen_variables = BTreeSet::new(); + let mut seen_adapter_ids = BTreeSet::new(); + + { + let mut context = TranslationApplyContext { + overlay, + translations, + seen_global_changes: &mut seen_global_changes, + seen_path_changes: &mut seen_path_changes, + seen_additions: &mut seen_additions, + seen_variables: &mut seen_variables, + seen_adapter_ids: &mut seen_adapter_ids, + changed_paths, + errors, + }; + context.translate_string(&mut resolved.name, "deck.name".to_owned(), None); + context.translate_string( + &mut resolved.description, + "deck.description".to_owned(), + None, + ); + context.translate_variables(&mut resolved.variables, "deck.variables"); + context.translate_adapter_ids(&mut resolved.adapter_ids, "deck.adapter_ids"); + + for (note_type_id, note_type) in &mut resolved.note_types { + context.translate_string( + &mut note_type.name, + format!("note_types.{note_type_id}.name"), + None, + ); + context.translate_variables( + &mut note_type.variables, + &format!("note_types.{note_type_id}.variables"), + ); + for field in &mut note_type.fields { + context.translate_string( + &mut field.name, + format!("note_types.{note_type_id}.fields.{}.name", field.id), + None, + ); + } + for template in &mut note_type.card_templates { + context.translate_string( + &mut template.name, + format!( + "note_types.{note_type_id}.card_templates.{}.name", + template.id + ), + None, + ); + context.translate_variables( + &mut template.variables, + &format!( + "note_types.{note_type_id}.card_templates.{}.variables", + template.id + ), + ); + context.translate_adapter_ids( + &mut template.adapter_ids, + &format!( + "note_types.{note_type_id}.card_templates.{}.adapter_ids", + template.id + ), + ); + } + context.translate_adapter_ids( + &mut note_type.adapter_ids, + &format!("note_types.{note_type_id}.adapter_ids"), + ); + } + + for (note_id, note) in &mut resolved.notes { + context.translate_variables(&mut note.variables, &format!("notes.{note_id}.variables")); + for (field_id, value) in &mut note.fields { + context.translate_string(value, format!("notes.{note_id}.fields.{field_id}"), None); + } + context.translate_tags(&mut note.tags, &format!("notes.{note_id}.tags")); + context.translate_adapter_ids( + &mut note.adapter_ids, + &format!("notes.{note_id}.adapter_ids"), + ); + } + } + + for (source, change) in &translations.changes { + match change { + TranslationChange::Global(_) => { + if !seen_global_changes.contains(source) { + errors.push(ComposeError::new( + ComposeErrorKind::StaleTranslationEntry, + format!("translations.changes.{source}"), + format!("translation source {source:?} did not match any extracted text"), + )); + } + } + TranslationChange::AtPaths(paths) => { + for path in paths.keys() { + if !seen_path_changes.contains(&(source.clone(), path.clone())) { + errors.push(ComposeError::new( + ComposeErrorKind::StaleTranslationEntry, + format!("translations.changes.{source}.{path}"), + format!( + "translation source {source:?} did not match extracted text at {path}" + ), + )); + } + } + } + } + } + for path in translations.additions.keys() { + if !seen_additions.contains(path) { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + format!("translations.additions.{path}"), + format!("translation addition path {path} did not match any extracted text"), + )); + } + } + for (variable_key, replacements) in &translations.variables { + for source in replacements.keys() { + if !seen_variables.contains(&(variable_key.clone(), source.clone())) { + errors.push(ComposeError::new( + ComposeErrorKind::StaleTranslationEntry, + format!("translations.variables.{variable_key}.{source}"), + format!( + "variable translation source {variable_key}={source:?} did not match any variable" + ), + )); + } + } + } + for (adapter_key, replacements) in &translations.adapter_ids { + for source in replacements.keys() { + if !seen_adapter_ids.contains(&(adapter_key.clone(), source.clone())) { + errors.push(ComposeError::new( + ComposeErrorKind::StaleTranslationEntry, + format!("translations.adapter_ids.{adapter_key}.{source}"), + format!( + "adapter id translation source {adapter_key}={source:?} did not match any adapter id" + ), + )); + } + } + } +} + +struct TranslationApplyContext<'a, 'b> { + overlay: &'a Overlay, + translations: &'a TranslationDictionary, + seen_global_changes: &'b mut BTreeSet, + seen_path_changes: &'b mut BTreeSet<(String, String)>, + seen_additions: &'b mut BTreeSet, + seen_variables: &'b mut BTreeSet<(String, String)>, + seen_adapter_ids: &'b mut BTreeSet<(String, String)>, + changed_paths: &'b mut BTreeMap, + errors: &'b mut Vec, +} + +impl TranslationApplyContext<'_, '_> { + fn translate_variables(&mut self, variables: &mut BTreeMap, path_prefix: &str) { + for (key, value) in variables { + self.translate_string(value, format!("{path_prefix}.{key}"), Some(key)); + } + } + + fn translate_string(&mut self, value: &mut String, path: String, variable_key: Option<&str>) { + if let Some(addition) = self.translations.additions.get(&path) { + self.seen_additions.insert(path.clone()); + if !value.is_empty() { + self.errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("translation addition expected blank value, found {value:?}"), + )); + return; + } + if !record_change_path( + &path, + self.overlay, + ChangeIntent::Replace, + self.changed_paths, + self.errors, + ) { + return; + } + *value = addition.clone(); + return; + } + + if value.is_empty() || is_ignored_translation_path(self.translations, &path) { + return; + } + + if let Some(variable_key) = variable_key + && let Some(replacements) = self.translations.variables.get(variable_key) + && let Some(translated) = replacements.get(value.as_str()) + { + let source = value.clone(); + self.seen_variables + .insert((variable_key.to_owned(), source)); + if value != translated { + if !record_change_path( + &path, + self.overlay, + ChangeIntent::Replace, + self.changed_paths, + self.errors, + ) { + return; + } + *value = translated.clone(); + } + return; + } + + if let Some(change) = self.translations.changes.get(value.as_str()) { + match change { + TranslationChange::Global(translated) => { + let source = value.clone(); + self.seen_global_changes.insert(source); + if value != translated { + if !record_change_path( + &path, + self.overlay, + ChangeIntent::Replace, + self.changed_paths, + self.errors, + ) { + return; + } + *value = translated.clone(); + } + return; + } + TranslationChange::AtPaths(paths) => { + if let Some(translated) = paths.get(&path) { + let source = value.clone(); + self.seen_path_changes.insert((source, path.clone())); + if value != translated { + if !record_change_path( + &path, + self.overlay, + ChangeIntent::Replace, + self.changed_paths, + self.errors, + ) { + return; + } + *value = translated.clone(); + } + return; + } + } + } + } + + if self.translations.require_complete { + self.errors.push(ComposeError::new( + ComposeErrorKind::MissingTranslation, + path, + format!("missing translation for {value:?}"), + )); + } + } + + fn translate_tags(&mut self, tags: &mut BTreeSet, path_prefix: &str) { + for tag in tags.iter().cloned().collect::>() { + let mut translated = tag.clone(); + self.translate_string(&mut translated, format!("{path_prefix}.{tag}"), None); + if translated != tag { + tags.remove(&tag); + tags.insert(translated); + } + } + } + + fn translate_adapter_ids(&mut self, adapter_ids: &mut AdapterIds, path_prefix: &str) { + let current = adapter_ids + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + for (key, value) in current { + let Some(replacements) = self.translations.adapter_ids.get(&key) else { + continue; + }; + let Some(translated) = replacements.get(&value) else { + continue; + }; + self.seen_adapter_ids.insert((key.clone(), value.clone())); + if value != *translated { + let path = format!("{path_prefix}.{key}"); + if !record_change_path( + &path, + self.overlay, + ChangeIntent::Replace, + self.changed_paths, + self.errors, + ) { + continue; + } + adapter_ids.insert(key, translated.clone()); + } + } + } +} + +fn is_ignored_translation_path(translations: &TranslationDictionary, path: &str) -> bool { + translations + .ignore_paths + .iter() + .any(|pattern| glob_matches(pattern, path)) +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + fn matches_parts(pattern: &[u8], value: &[u8]) -> bool { + match pattern.split_first() { + None => value.is_empty(), + Some((&b'*', rest)) => { + matches_parts(rest, value) + || (!value.is_empty() && matches_parts(pattern, &value[1..])) + } + Some((&expected, rest)) => value.split_first().is_some_and(|(&actual, rest_value)| { + actual == expected && matches_parts(rest, rest_value) + }), + } + } + + matches_parts(pattern.as_bytes(), value.as_bytes()) +} + +fn apply_deck_change( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + change: &DeckChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if let Some(name) = &change.name { + apply_string_property_change( + &mut resolved.name, + overlay, + "deck.name".to_owned(), + name, + changed_paths, + errors, + ); + } + if let Some(description) = &change.description { + apply_string_property_change( + &mut resolved.description, + overlay, + "deck.description".to_owned(), + description, + changed_paths, + errors, + ); + } + apply_variable_changes( + &mut resolved.variables, + overlay, + "deck.variables", + &change.variables, + changed_paths, + errors, + ); + for (key, adapter_change) in &change.adapter_ids { + apply_adapter_id_change( + &mut resolved.adapter_ids, + overlay, + format!("deck.adapter_ids.{key}"), + key, + adapter_change, + changed_paths, + errors, + ); + } +} + +fn apply_note_type_add( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_type_id: &StableId, + change: &NoteTypeChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("note_types.{note_type_id}"); + if resolved.note_types.contains_key(note_type_id) { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("note type {note_type_id} already exists"), + )); + return; + } + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + let Some(note_type) = &change.note_type else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("add change for note type {note_type_id} must include a note_type"), + )); + return; + }; + if ¬e_type.id != note_type_id { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!( + "note type payload id {} does not match target {note_type_id}", + note_type.id + ), + )); + return; + } + resolved + .note_types + .insert(note_type_id.clone(), note_type.clone()); +} + +fn apply_note_type_remove( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_type_id: &StableId, + change: &NoteTypeChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("note_types.{note_type_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + if !has_expected_base(&change.expected_base, path.clone(), errors) { + return; + } + if !resolved.note_types.contains_key(note_type_id) { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!("note type {note_type_id} does not exist"), + )); + return; + } + if resolved + .notes + .values() + .any(|note| ¬e.note_type_id == note_type_id) + { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!("cannot remove note type {note_type_id} while notes still reference it"), + )); + return; + } + if let Some(ExpectedBase::Value(expected_value)) = &change.expected_base { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("note type removal expected an entity marker, not value {expected_value:?}"), + )); + return; + } + resolved.note_types.remove(note_type_id); + resolved.tombstones.insert(note_type_id.clone()); +} + +fn apply_note_type_change( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_type_id: &StableId, + change: &NoteTypeChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) -> Vec<(StableId, StableId)> { + let mut added_fields = Vec::new(); + if requires_expected_base(change.intent) + && !has_expected_base( + &change.expected_base, + format!("note_types.{note_type_id}"), + errors, + ) + { + return added_fields; + } + + let Some(note_type) = resolved.note_types.get_mut(note_type_id) else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + format!("note_types.{note_type_id}"), + format!("note type {note_type_id} does not exist"), + )); + return added_fields; + }; + + if let Some(name) = &change.name { + apply_string_property_change( + &mut note_type.name, + overlay, + format!("note_types.{note_type_id}.name"), + name, + changed_paths, + errors, + ); + } + apply_variable_changes( + &mut note_type.variables, + overlay, + &format!("note_types.{note_type_id}.variables"), + &change.variables, + changed_paths, + errors, + ); + if let Some(styling) = &change.styling { + apply_string_property_change( + &mut note_type.styling, + overlay, + format!("note_types.{note_type_id}.styling"), + styling, + changed_paths, + errors, + ); + } + for (key, adapter_change) in &change.adapter_ids { + apply_adapter_id_change( + &mut note_type.adapter_ids, + overlay, + format!("note_types.{note_type_id}.adapter_ids.{key}"), + key, + adapter_change, + changed_paths, + errors, + ); + } + for (template_id, template_change) in &change.card_templates { + apply_card_template_change( + note_type, + overlay, + note_type_id, + template_id, + template_change, + changed_paths, + errors, + ); + } + + for (field_id, field_change) in &change.fields { + if apply_field_definition_change( + note_type, + overlay, + note_type_id, + field_id, + field_change, + changed_paths, + errors, + ) { + added_fields.push((note_type_id.clone(), field_id.clone())); + } + } + + added_fields +} + +fn apply_card_template_change( + note_type: &mut NoteType, + overlay: &Overlay, + note_type_id: &StableId, + template_id: &StableId, + change: &CardTemplateChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("note_types.{note_type_id}.card_templates.{template_id}"); + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + let existing_index = note_type + .card_templates + .iter() + .position(|template| &template.id == template_id); + + match change.intent { + ChangeIntent::Add if existing_index.is_some() => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("card template {template_id} already exists on note type {note_type_id}"), + )); + return; + } + ChangeIntent::Add => { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + let Some(template) = &change.template else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("add change for card template {template_id} must include a template"), + )); + return; + }; + if &template.id != template_id { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!( + "card template payload id {} does not match target {template_id}", + template.id + ), + )); + return; + } + let insert_index = match &change.insert_after { + Some(after_id) => match note_type + .card_templates + .iter() + .position(|template| &template.id == after_id) + { + Some(index) => index + 1, + None => { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!("insert_after template {after_id} does not exist"), + )); + return; + } + }, + None => note_type.card_templates.len(), + }; + note_type + .card_templates + .insert(insert_index, template.clone()); + } + ChangeIntent::Remove => { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + if let Some(index) = existing_index { + note_type.card_templates.remove(index); + } else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!( + "card template {template_id} does not exist on note type {note_type_id}" + ), + )); + } + return; + } + ChangeIntent::Merge | ChangeIntent::Replace | ChangeIntent::Override => { + if let Some(template) = &change.template { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + let Some(index) = existing_index else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!( + "card template {template_id} does not exist on note type {note_type_id}" + ), + )); + return; + }; + if &template.id != template_id { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!( + "card template payload id {} does not match target {template_id}", + template.id + ), + )); + return; + } + note_type.card_templates[index] = template.clone(); + } + } + } + + let Some(template) = note_type + .card_templates + .iter_mut() + .find(|template| &template.id == template_id) + else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!("card template {template_id} does not exist on note type {note_type_id}"), + )); + return; + }; + + if let Some(name) = &change.name { + apply_string_property_change( + &mut template.name, + overlay, + format!("note_types.{note_type_id}.card_templates.{template_id}.name"), + name, + changed_paths, + errors, + ); + } + apply_variable_changes( + &mut template.variables, + overlay, + &format!("note_types.{note_type_id}.card_templates.{template_id}.variables"), + &change.variables, + changed_paths, + errors, + ); + if let Some(question_format) = &change.question_format { + apply_string_property_change( + &mut template.question_format, + overlay, + format!("note_types.{note_type_id}.card_templates.{template_id}.question_format"), + question_format, + changed_paths, + errors, + ); + } + if let Some(answer_format) = &change.answer_format { + apply_string_property_change( + &mut template.answer_format, + overlay, + format!("note_types.{note_type_id}.card_templates.{template_id}.answer_format"), + answer_format, + changed_paths, + errors, + ); + } + for (key, adapter_change) in &change.adapter_ids { + apply_adapter_id_change( + &mut template.adapter_ids, + overlay, + format!("note_types.{note_type_id}.card_templates.{template_id}.adapter_ids.{key}"), + key, + adapter_change, + changed_paths, + errors, + ); + } +} + +fn apply_string_property_change( + value: &mut String, + overlay: &Overlay, + path: String, + change: &PropertyChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::Value(expected_value) => { + if value != expected_value { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!( + "expected base value {:?}, found {:?}", + expected_value, value + ), + )); + return; + } + } + ExpectedBase::EntityPresent => { + if value.is_empty() { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + "expected property to be present".to_owned(), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if !value.is_empty() => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + "property already has a value".to_owned(), + )); + } + ChangeIntent::Remove => value.clear(), + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(new_value) = &change.value else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + "property change must include a value".to_owned(), + )); + return; + }; + *value = new_value.clone(); + } + } +} + +fn apply_variable_changes( + variables: &mut BTreeMap, + overlay: &Overlay, + path_prefix: &str, + changes: &BTreeMap, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + for (key, change) in changes { + apply_map_string_property_change( + variables, + overlay, + format!("{path_prefix}.{key}"), + key, + change, + changed_paths, + errors, + ); + } +} + +fn apply_map_string_property_change( + values: &mut BTreeMap, + overlay: &Overlay, + path: String, + key: &str, + change: &PropertyChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::Value(expected_value) => { + let current_value = values.get(key); + if current_value != Some(expected_value) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!( + "expected base value {:?}, found {:?}", + expected_value, current_value + ), + )); + return; + } + } + ExpectedBase::EntityPresent => { + if !values.contains_key(key) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected variable {key} to be present"), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if values.contains_key(key) => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("variable {key} already exists"), + )); + } + ChangeIntent::Remove => { + values.remove(key); + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(value) = &change.value else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("variable change for {key} must include a value"), + )); + return; + }; + values.insert(key.to_owned(), value.clone()); + } + } +} + +fn apply_adapter_id_change( + adapter_ids: &mut AdapterIds, + overlay: &Overlay, + path: String, + key: &str, + change: &AdapterIdChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::Value(expected_value) => { + let current_value = adapter_ids.get(key); + if current_value != Some(expected_value.as_str()) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!( + "expected base value {:?}, found {:?}", + expected_value, current_value + ), + )); + return; + } + } + ExpectedBase::EntityPresent => { + if !adapter_ids.contains_key(key) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected adapter id {key} to be present"), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if adapter_ids.contains_key(key) => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("adapter id {key} already exists"), + )); + } + ChangeIntent::Remove => { + adapter_ids.remove(key); + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(value) = &change.value else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("adapter id change for {key} must include a value"), + )); + return; + }; + adapter_ids.insert(key.to_owned(), value.clone()); + } + } +} + +fn apply_field_definition_change( + note_type: &mut NoteType, + overlay: &Overlay, + note_type_id: &StableId, + field_id: &StableId, + change: &FieldDefinitionChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) -> bool { + let path = format!("note_types.{note_type_id}.fields.{field_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return false; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return false; + } + + let existing_index = note_type + .fields + .iter() + .position(|field| &field.id == field_id); + match change.intent { + ChangeIntent::Add if existing_index.is_some() => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("field {field_id} already exists on note type {note_type_id}"), + )); + false + } + ChangeIntent::Remove => { + if let Some(index) = existing_index { + note_type.fields.remove(index); + } else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!("field {field_id} does not exist on note type {note_type_id}"), + )); + } + false + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(field) = &change.field else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("field definition change for {field_id} must include a field"), + )); + return false; + }; + if &field.id != field_id { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!( + "field payload id {} does not match target {field_id}", + field.id + ), + )); + return false; + } + if let Some(index) = existing_index { + note_type.fields[index] = field.clone(); + false + } else { + note_type.fields.push(field.clone()); + change.intent == ChangeIntent::Add + } + } + } +} + +fn apply_note_add( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_id: &StableId, + change: &NoteChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("notes.{note_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if resolved.notes.contains_key(note_id) && !resolved.tombstones.contains(note_id) { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("note {note_id} already exists"), + )); + return; + } + + let Some(note) = &change.note else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("add change for note {note_id} must include a note payload"), + )); + return; + }; + + resolved.notes.insert(note_id.clone(), note.clone()); + resolved.tombstones.remove(note_id); +} + +fn apply_note_merge( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_id: &StableId, + change: &NoteChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, format!("notes.{note_id}"), errors) + { + return; + } + + let Some(note) = resolved.notes.get_mut(note_id) else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + format!("notes.{note_id}"), + format!("note {note_id} does not exist"), + )); + return; + }; + + apply_variable_changes( + &mut note.variables, + overlay, + &format!("notes.{note_id}.variables"), + &change.variables, + changed_paths, + errors, + ); + + for (tag, tag_change) in &change.tags { + apply_tag_change( + note, + overlay, + note_id, + tag, + tag_change, + changed_paths, + errors, + ); + } + + for (key, adapter_change) in &change.adapter_ids { + apply_adapter_id_change( + &mut note.adapter_ids, + overlay, + format!("notes.{note_id}.adapter_ids.{key}"), + key, + adapter_change, + changed_paths, + errors, + ); + } + + for (field_id, field_change) in &change.fields { + apply_field_change( + note, + overlay, + note_id, + field_id, + field_change, + changed_paths, + errors, + ); + } +} + +fn apply_tag_change( + note: &mut Note, + overlay: &Overlay, + note_id: &StableId, + tag: &str, + change: &TagChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("notes.{note_id}.tags.{tag}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::EntityPresent => { + if !note.tags.contains(tag) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected tag {tag} to be present"), + )); + return; + } + } + ExpectedBase::Value(expected_value) => { + if expected_value != tag || !note.tags.contains(tag) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected tag value {:?} to be present", expected_value), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if note.tags.contains(tag) => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("tag {tag} already exists on note {note_id}"), + )); + } + ChangeIntent::Remove => { + note.tags.remove(tag); + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + note.tags.insert(tag.to_owned()); + } + } +} + +fn apply_field_change( + note: &mut Note, + overlay: &Overlay, + note_id: &StableId, + field_id: &StableId, + change: &FieldChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("notes.{note_id}.fields.{field_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::Value(expected_value) => { + let current_value = note.fields.get(field_id).map(String::as_str); + if current_value != Some(expected_value.as_str()) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!( + "expected base value {:?}, found {:?}", + expected_value, current_value + ), + )); + return; + } + } + ExpectedBase::EntityPresent => { + if !note.fields.contains_key(field_id) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected field {field_id} to be present"), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if note.fields.contains_key(field_id) => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("field {field_id} already exists on note {note_id}"), + )); + } + ChangeIntent::Remove => { + note.fields.remove(field_id); + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(value) = &change.value else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("field change for {field_id} must include a value"), + )); + return; + }; + note.fields.insert(field_id.clone(), value.clone()); + } + } +} + +fn apply_media_change( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + media_id: &StableId, + change: &MediaChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("media.{media_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if requires_expected_base(change.intent) + && !has_expected_base(&change.expected_base, path.clone(), errors) + { + return; + } + + if let Some(expected_base) = &change.expected_base { + match expected_base { + ExpectedBase::EntityPresent => { + if !resolved.media.contains_key(media_id) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("expected media reference {media_id} to be present"), + )); + return; + } + } + ExpectedBase::Value(expected_value) => { + let current_value = resolved.media.get(media_id).map(media_reference_summary); + if current_value.as_deref() != Some(expected_value.as_str()) { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!( + "expected base value {:?}, found {:?}", + expected_value, current_value + ), + )); + return; + } + } + } + } + + match change.intent { + ChangeIntent::Add if resolved.media.contains_key(media_id) => { + errors.push(ComposeError::new( + ComposeErrorKind::AlreadyExists, + path, + format!("media reference {media_id} already exists"), + )); + } + ChangeIntent::Remove => { + resolved.media.remove(media_id); + } + ChangeIntent::Add + | ChangeIntent::Merge + | ChangeIntent::Replace + | ChangeIntent::Override => { + let Some(media) = &change.media else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayPayload, + path, + format!("media change for {media_id} must include a media reference"), + )); + return; + }; + if &media.id != media_id { + errors.push(ComposeError::new( + ComposeErrorKind::ValidationFailed, + path, + format!( + "media payload id {} does not match target {media_id}", + media.id + ), + )); + return; + } + resolved.media.insert(media_id.clone(), media.clone()); + } + } +} + +fn media_reference_summary(media: &MediaReference) -> String { + format!("path={};sha256={}", media.path, media.sha256) +} + +fn apply_note_remove( + resolved: &mut CanonicalDeck, + overlay: &Overlay, + note_id: &StableId, + change: &NoteChange, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) { + let path = format!("notes.{note_id}"); + if !record_change_path(&path, overlay, change.intent, changed_paths, errors) { + return; + } + + if !has_expected_base(&change.expected_base, path.clone(), errors) { + return; + } + + if !resolved.notes.contains_key(note_id) { + errors.push(ComposeError::new( + ComposeErrorKind::MissingOverlayTarget, + path, + format!("note {note_id} does not exist"), + )); + return; + } + + if let Some(ExpectedBase::Value(expected_value)) = &change.expected_base { + errors.push(ComposeError::new( + ComposeErrorKind::ExpectedBaseMismatch, + path, + format!("note removal expected an entity marker, not value {expected_value:?}"), + )); + return; + } + + resolved.tombstones.insert(note_id.clone()); +} + +fn record_change_path( + path: &str, + overlay: &Overlay, + intent: ChangeIntent, + changed_paths: &mut BTreeMap, + errors: &mut Vec, +) -> bool { + if let Some(previous_overlay_id) = changed_paths.get(path) + && intent != ChangeIntent::Override + { + errors.push(ComposeError::new( + ComposeErrorKind::Conflict, + path.to_owned(), + format!( + "overlay {} conflicts with earlier overlay {} at {path}", + overlay.id, previous_overlay_id + ), + )); + return false; + } + + changed_paths.insert(path.to_owned(), overlay.id.clone()); + true +} + +fn has_expected_base( + expected_base: &Option, + path: String, + errors: &mut Vec, +) -> bool { + if expected_base.is_some() { + true + } else { + errors.push(ComposeError::new( + ComposeErrorKind::MissingExpectedBase, + path, + "destructive overlay change must declare an expected base".to_owned(), + )); + false + } +} + +fn requires_expected_base(intent: ChangeIntent) -> bool { + matches!( + intent, + ChangeIntent::Replace | ChangeIntent::Remove | ChangeIntent::Override + ) +} + +fn render_deck_variables(deck: &CanonicalDeck) -> Result { + let mut rendered = deck.clone(); + let mut errors = Vec::new(); + let deck_variables = rendered.variables.clone(); + + render_string_with_variables( + &mut rendered.name, + "deck.name", + &[&deck_variables], + &mut errors, + ); + render_string_with_variables( + &mut rendered.description, + "deck.description", + &[&deck_variables], + &mut errors, + ); + + for (note_type_id, note_type) in &mut rendered.note_types { + let note_type_variables = note_type.variables.clone(); + render_string_with_variables( + &mut note_type.name, + &format!("note_types.{note_type_id}.name"), + &[¬e_type_variables, &deck_variables], + &mut errors, + ); + render_string_with_variables( + &mut note_type.styling, + &format!("note_types.{note_type_id}.styling"), + &[¬e_type_variables, &deck_variables], + &mut errors, + ); + for field in &mut note_type.fields { + render_string_with_variables( + &mut field.name, + &format!("note_types.{note_type_id}.fields.{}.name", field.id), + &[¬e_type_variables, &deck_variables], + &mut errors, + ); + } + for template in &mut note_type.card_templates { + let template_variables = template.variables.clone(); + let scopes = [&template_variables, ¬e_type_variables, &deck_variables]; + render_string_with_variables( + &mut template.name, + &format!( + "note_types.{note_type_id}.card_templates.{}.name", + template.id + ), + &scopes, + &mut errors, + ); + render_string_with_variables( + &mut template.question_format, + &format!( + "note_types.{note_type_id}.card_templates.{}.question_format", + template.id + ), + &scopes, + &mut errors, + ); + render_string_with_variables( + &mut template.answer_format, + &format!( + "note_types.{note_type_id}.card_templates.{}.answer_format", + template.id + ), + &scopes, + &mut errors, + ); + } + } + + for (note_id, note) in &mut rendered.notes { + let note_variables = note.variables.clone(); + let note_type_variables = rendered + .note_types + .get(¬e.note_type_id) + .map(|note_type| ¬e_type.variables); + for (field_id, value) in &mut note.fields { + let path = format!("notes.{note_id}.fields.{field_id}"); + if let Some(note_type_variables) = note_type_variables { + render_string_with_variables( + value, + &path, + &[¬e_variables, note_type_variables, &deck_variables], + &mut errors, + ); + } else { + render_string_with_variables( + value, + &path, + &[¬e_variables, &deck_variables], + &mut errors, + ); + } + } + } + + if errors.is_empty() { + Ok(rendered) + } else { + Err(VariableRenderReport { errors }) + } +} + +fn render_string_with_variables( + value: &mut String, + path: &str, + scopes: &[&BTreeMap], + errors: &mut Vec, +) { + let mut rendered = String::new(); + let mut remaining = value.as_str(); + while let Some(start) = remaining.find("${") { + rendered.push_str(&remaining[..start]); + let after_start = &remaining[start + 2..]; + let Some(end) = after_start.find('}') else { + rendered.push_str(&remaining[start..]); + *value = rendered; + return; + }; + let key = &after_start[..end]; + if let Some(replacement) = lookup_variable(scopes, key) { + rendered.push_str(replacement); + } else { + errors.push(VariableRenderError { + path: path.to_owned(), + variable: key.to_owned(), + }); + rendered.push_str(&remaining[start..start + end + 3]); + } + remaining = &after_start[end + 1..]; + } + rendered.push_str(remaining); + *value = rendered; +} + +fn lookup_variable<'a>(scopes: &[&'a BTreeMap], key: &str) -> Option<&'a str> { + scopes + .iter() + .find_map(|scope| scope.get(key).map(String::as_str)) +} + +fn diff_note_types( + left: &BTreeMap, + right: &BTreeMap, + changes: &mut Vec, +) { + for id in left.keys() { + if !right.contains_key(id) { + changes.push(SemanticChange::removed(format!("note_types.{id}"))); + } + } + + for (id, right_note_type) in right { + let Some(left_note_type) = left.get(id) else { + changes.push(SemanticChange::added(format!("note_types.{id}"))); + continue; + }; + + push_modified_if_changed( + changes, + format!("note_types.{id}.name"), + &left_note_type.name, + &right_note_type.name, + ); + push_modified_if_changed( + changes, + format!("note_types.{id}.styling"), + &left_note_type.styling, + &right_note_type.styling, + ); + push_modified_if_changed( + changes, + format!("note_types.{id}.variables"), + &string_map_summary(&left_note_type.variables), + &string_map_summary(&right_note_type.variables), + ); + push_modified_if_changed( + changes, + format!("note_types.{id}.fields"), + &field_summary(&left_note_type.fields), + &field_summary(&right_note_type.fields), + ); + push_modified_if_changed( + changes, + format!("note_types.{id}.card_templates"), + &template_summary(&left_note_type.card_templates), + &template_summary(&right_note_type.card_templates), + ); + push_modified_if_changed( + changes, + format!("note_types.{id}.adapter_ids"), + &adapter_ids_summary(&left_note_type.adapter_ids), + &adapter_ids_summary(&right_note_type.adapter_ids), + ); + } +} + +fn diff_notes( + left: &BTreeMap, + right: &BTreeMap, + changes: &mut Vec, +) { + for id in left.keys() { + if !right.contains_key(id) { + changes.push(SemanticChange::removed(format!("notes.{id}"))); + } + } + + for (id, right_note) in right { + let Some(left_note) = left.get(id) else { + changes.push(SemanticChange::added(format!("notes.{id}"))); + continue; + }; + + push_modified_if_changed( + changes, + format!("notes.{id}.note_type_id"), + &left_note.note_type_id.to_string(), + &right_note.note_type_id.to_string(), + ); + push_modified_if_changed( + changes, + format!("notes.{id}.variables"), + &string_map_summary(&left_note.variables), + &string_map_summary(&right_note.variables), + ); + diff_note_fields(id, &left_note.fields, &right_note.fields, changes); + push_modified_if_changed( + changes, + format!("notes.{id}.tags"), + &set_summary(&left_note.tags), + &set_summary(&right_note.tags), + ); + push_modified_if_changed( + changes, + format!("notes.{id}.adapter_ids"), + &adapter_ids_summary(&left_note.adapter_ids), + &adapter_ids_summary(&right_note.adapter_ids), + ); + } +} + +fn diff_note_fields( + note_id: &StableId, + left: &BTreeMap, + right: &BTreeMap, + changes: &mut Vec, +) { + for field_id in left.keys() { + if !right.contains_key(field_id) { + changes.push(SemanticChange::new( + SemanticChangeKind::Removed, + format!("notes.{note_id}.fields.{field_id}"), + left.get(field_id).cloned(), + None, + )); + } + } + + for (field_id, right_value) in right { + let Some(left_value) = left.get(field_id) else { + changes.push(SemanticChange::new( + SemanticChangeKind::Added, + format!("notes.{note_id}.fields.{field_id}"), + None, + Some(right_value.clone()), + )); + continue; + }; + + push_modified_if_changed( + changes, + format!("notes.{note_id}.fields.{field_id}"), + left_value, + right_value, + ); + } +} + +fn diff_media( + left: &BTreeMap, + right: &BTreeMap, + changes: &mut Vec, +) { + for id in left.keys() { + if !right.contains_key(id) { + changes.push(SemanticChange::removed(format!("media.{id}"))); + } + } + + for (id, right_media) in right { + let Some(left_media) = left.get(id) else { + changes.push(SemanticChange::added(format!("media.{id}"))); + continue; + }; + + push_modified_if_changed( + changes, + format!("media.{id}.path"), + &left_media.path, + &right_media.path, + ); + push_modified_if_changed( + changes, + format!("media.{id}.sha256"), + &left_media.sha256, + &right_media.sha256, + ); + } +} + +fn diff_tombstones( + left: &BTreeSet, + right: &BTreeSet, + changes: &mut Vec, +) { + for id in left { + if !right.contains(id) { + changes.push(SemanticChange::removed(format!("tombstones.{id}"))); + } + } + + for id in right { + if !left.contains(id) { + changes.push(SemanticChange::new( + SemanticChangeKind::Tombstoned, + format!("tombstones.{id}"), + None, + Some(id.to_string()), + )); + } + } +} + +fn push_modified_if_changed( + changes: &mut Vec, + path: String, + left: &str, + right: &str, +) { + if left != right { + changes.push(SemanticChange::new( + SemanticChangeKind::Modified, + path, + Some(left.to_owned()), + Some(right.to_owned()), + )); + } +} + +fn field_summary(fields: &[FieldDefinition]) -> String { + fields + .iter() + .map(|field| format!("{}={}", field.id, field.name)) + .collect::>() + .join("|") +} + +fn template_summary(templates: &[CardTemplate]) -> String { + templates + .iter() + .map(|template| { + format!( + "{}={}:{}:{}:{}:{}", + template.id, + template.name, + string_map_summary(&template.variables), + template.question_format, + template.answer_format, + adapter_ids_summary(&template.adapter_ids) + ) + }) + .collect::>() + .join("|") +} + +fn set_summary(values: &BTreeSet) -> String { + values.iter().cloned().collect::>().join("|") +} + +fn string_map_summary(values: &BTreeMap) -> String { + values + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("|") +} + +fn adapter_ids_summary(adapter_ids: &AdapterIds) -> String { + adapter_ids + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("|") +} + +fn push_duplicate_id_errors<'a>( + ids: impl Iterator, + kind: ValidationErrorKind, + path: impl Fn(&StableId) -> String, + errors: &mut Vec, +) { + let mut seen = BTreeSet::new(); + for id in ids { + if !seen.insert(id) { + errors.push(ValidationError::new( + kind, + path(id), + format!("duplicate stable id {id}"), + )); + } + } +} + +/// A sparse CanonicalDeck-shaped fragment applied to a base deck. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Overlay { + pub id: StableId, + pub kind: OverlayKind, + pub translations: Option, + pub deck_change: Option, + pub note_changes: BTreeMap, + pub note_type_changes: BTreeMap, + pub media_changes: BTreeMap, +} + +/// Translation dictionary applied by a translation overlay. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TranslationDictionary { + /// Exact source text replacements, either globally or scoped to stable deck paths. + pub changes: BTreeMap, + /// Stable deck paths to fill only when the current source value is blank. + pub additions: BTreeMap, + /// Variable-specific source text to translated text replacements by variable key. + pub variables: BTreeMap>, + /// Adapter-specific source ID to translated ID replacements by adapter namespace. + pub adapter_ids: BTreeMap>, + /// When true, every extracted translatable string must be translated or ignored. + pub require_complete: bool, + /// Glob-style paths ignored by complete-coverage checks. + pub ignore_paths: BTreeSet, +} + +/// Translation replacement for one exact source string. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TranslationChange { + /// Replace the source string wherever it is extracted. + Global(String), + /// Replace the source string only at the listed stable deck paths. + AtPaths(BTreeMap), +} + +/// Maintainer-facing category for an overlay. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OverlayKind { + Translation, + Extension, + Patch, + Personal, +} + +/// The declared meaning of an overlay change. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ChangeIntent { + Add, + Merge, + Replace, + Remove, + Override, +} + +/// Base value or condition an overlay expects before applying a destructive change. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ExpectedBase { + Value(String), + EntityPresent, +} + +/// Sparse change for deck-level metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeckChange { + pub name: Option, + pub description: Option, + pub variables: BTreeMap, + pub adapter_ids: BTreeMap, +} + +/// Sparse change for a scalar string property. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PropertyChange { + pub intent: ChangeIntent, + pub value: Option, + pub expected_base: Option, +} + +/// Sparse change for one adapter identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdapterIdChange { + pub intent: ChangeIntent, + pub value: Option, + pub expected_base: Option, +} + +/// Sparse change for one note type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NoteTypeChange { + pub intent: ChangeIntent, + pub note_type: Option, + pub name: Option, + pub variables: BTreeMap, + pub styling: Option, + pub fields: BTreeMap, + pub card_templates: BTreeMap, + pub adapter_ids: BTreeMap, + pub expected_base: Option, +} + +/// Sparse change for one card template. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CardTemplateChange { + pub intent: ChangeIntent, + pub template: Option, + pub insert_after: Option, + pub name: Option, + pub variables: BTreeMap, + pub question_format: Option, + pub answer_format: Option, + pub adapter_ids: BTreeMap, + pub expected_base: Option, +} + +/// Sparse change for one note type field definition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FieldDefinitionChange { + pub intent: ChangeIntent, + pub field: Option, + pub expected_base: Option, +} + +/// Sparse change for one note. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NoteChange { + pub intent: ChangeIntent, + pub note: Option, + pub variables: BTreeMap, + pub fields: BTreeMap, + pub tags: BTreeMap, + pub adapter_ids: BTreeMap, + pub expected_base: Option, +} + +/// Sparse change for one note tag. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TagChange { + pub intent: ChangeIntent, + pub expected_base: Option, +} + +/// Sparse change for one media reference. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaChange { + pub intent: ChangeIntent, + pub media: Option, + pub expected_base: Option, +} + +/// Sparse change for one note field value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FieldChange { + pub intent: ChangeIntent, + pub value: Option, + pub expected_base: Option, +} + +/// An Anki-compatible note type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NoteType { + pub id: StableId, + pub name: String, + pub variables: BTreeMap, + pub fields: Vec, + pub card_templates: Vec, + pub styling: String, + pub adapter_ids: AdapterIds, +} + +/// A field declared by a note type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FieldDefinition { + pub id: StableId, + pub name: String, +} + +/// Raw Anki-compatible card template text plus identity metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CardTemplate { + pub id: StableId, + pub name: String, + pub variables: BTreeMap, + pub question_format: String, + pub answer_format: String, + pub adapter_ids: AdapterIds, +} + +/// A note belonging to a note type. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Note { + pub id: StableId, + pub note_type_id: StableId, + pub variables: BTreeMap, + pub fields: BTreeMap, + pub tags: BTreeSet, + pub adapter_ids: AdapterIds, +} + +/// A reference to an external media asset. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaReference { + pub id: StableId, + pub path: String, + pub sha256: String, +} + +/// A failed attempt to render source variables. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VariableRenderReport { + pub errors: Vec, +} + +impl fmt::Display for VariableRenderReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, error) in self.errors.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + write!(f, "{}: missing variable ${}", error.path, error.variable)?; + } + Ok(()) + } +} + +impl std::error::Error for VariableRenderReport {} + +/// One variable rendering failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VariableRenderError { + pub path: String, + pub variable: String, +} + +/// A failed attempt to compose an overlay stack. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComposeReport { + pub errors: Vec, +} + +impl ComposeReport { + /// Returns true when the report contains at least one error of the given kind. + pub fn has_kind(&self, kind: ComposeErrorKind) -> bool { + self.errors.iter().any(|error| error.kind == kind) + } +} + +impl fmt::Display for ComposeReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, error) in self.errors.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + write!(f, "{}: {}", error.path, error.message)?; + } + Ok(()) + } +} + +impl std::error::Error for ComposeReport {} + +/// One overlay composition error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ComposeError { + pub kind: ComposeErrorKind, + pub path: String, + pub message: String, +} + +impl ComposeError { + fn new(kind: ComposeErrorKind, path: String, message: String) -> Self { + Self { + kind, + path, + message, + } + } +} + +/// Machine-readable overlay composition error category. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComposeErrorKind { + MissingExpectedBase, + ExpectedBaseMismatch, + Conflict, + MissingOverlayTarget, + AlreadyExists, + MissingOverlayPayload, + MissingTranslation, + StaleTranslationEntry, + ValidationFailed, +} + +/// A semantic comparison between two CanonicalDeck values. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SemanticDiff { + pub changes: Vec, +} + +impl SemanticDiff { + /// Returns true when no deck entity differs semantically. + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + /// Returns true when a change with this kind and stable path exists. + pub fn has_change(&self, kind: SemanticChangeKind, path: &str) -> bool { + self.changes + .iter() + .any(|change| change.kind == kind && change.path == path) + } +} + +/// One semantic change at a stable deck path. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SemanticChange { + pub kind: SemanticChangeKind, + pub path: String, + pub before: Option, + pub after: Option, +} + +impl SemanticChange { + fn new( + kind: SemanticChangeKind, + path: String, + before: Option, + after: Option, + ) -> Self { + Self { + kind, + path, + before, + after, + } + } + + fn added(path: String) -> Self { + Self::new(SemanticChangeKind::Added, path, None, None) + } + + fn removed(path: String) -> Self { + Self::new(SemanticChangeKind::Removed, path, None, None) + } +} + +/// Machine-readable semantic change category. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SemanticChangeKind { + Added, + Removed, + Modified, + Tombstoned, +} + +/// A strict validation failure report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidationReport { + pub errors: Vec, +} + +impl ValidationReport { + /// Returns true when the report contains at least one error of the given kind. + pub fn has_kind(&self, kind: ValidationErrorKind) -> bool { + self.errors.iter().any(|error| error.kind == kind) + } +} + +impl fmt::Display for ValidationReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, error) in self.errors.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + write!(f, "{}: {}", error.path, error.message)?; + } + Ok(()) + } +} + +impl std::error::Error for ValidationReport {} + +/// One strict validation failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ValidationError { + pub kind: ValidationErrorKind, + pub path: String, + pub message: String, +} + +impl ValidationError { + pub fn new(kind: ValidationErrorKind, path: String, message: String) -> Self { + Self { + kind, + path, + message, + } + } +} + +/// Machine-readable validation error category. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ValidationErrorKind { + MissingNoteType, + UnknownNoteField, + MissingNoteField, + MismatchedEntityId, + DuplicateFieldDefinition, + DuplicateCardTemplate, +} + +#[cfg(test)] +mod tests { + use super::CRATE_NAME; + + #[test] + fn exposes_core_crate_name() { + assert_eq!(CRATE_NAME, "brain-brew-core"); + } +} diff --git a/crates/brain-brew-core/tests/canonical_deck_validation.rs b/crates/brain-brew-core/tests/canonical_deck_validation.rs new file mode 100644 index 0000000..3fc6b91 --- /dev/null +++ b/crates/brain-brew-core/tests/canonical_deck_validation.rs @@ -0,0 +1,192 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + StableId, ValidationErrorKind, +}; + +#[test] +fn complete_deck_preserves_anki_compatible_structure_and_adapter_identities() { + let deck = ug_style_deck(); + + assert!(deck.validate().is_ok()); + + let note_type = deck + .note_types + .get(&sid("note-type.country")) + .expect("note type exists"); + assert_eq!(note_type.fields[0].id, sid("field.country")); + assert_eq!(note_type.fields[1].id, sid("field.capital")); + assert_eq!( + note_type.card_templates[0].id, + sid("template.country-to-capital") + ); + assert_eq!(note_type.card_templates[0].question_format, "{{Country}}"); + assert_eq!( + note_type.card_templates[0].answer_format, + "{{FrontSide}}
{{Capital}}" + ); + assert_eq!(note_type.styling, ".card { font-family: sans-serif; }\n"); + assert_eq!( + note_type.adapter_ids.get("crowdanki:model_id"), + Some("1548959259107") + ); + + let note = deck.notes.get(&sid("note.finland")).expect("note exists"); + assert_eq!(note.note_type_id, sid("note-type.country")); + assert_eq!( + note.fields.get(&sid("field.country")), + Some(&"Finland".to_owned()) + ); + assert!(note.tags.contains("Europe")); + assert_eq!( + note.adapter_ids.get("crowdanki:guid"), + Some("ug-finland-guid") + ); + + let flag = deck + .media + .get(&sid("media.flag.finland")) + .expect("media exists"); + assert_eq!(flag.path, "flags/fi.png"); + assert_eq!(flag.sha256, "0123456789abcdef"); +} + +#[test] +fn validation_rejects_note_with_missing_note_type() { + let mut deck = ug_style_deck(); + deck.notes + .get_mut(&sid("note.finland")) + .unwrap() + .note_type_id = sid("note-type.missing"); + + let report = deck + .validate() + .expect_err("missing note type must fail validation"); + + assert!(report.has_kind(ValidationErrorKind::MissingNoteType)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "notes.note.finland.note_type_id") + ); +} + +#[test] +fn validation_rejects_note_fields_not_defined_by_its_note_type() { + let mut deck = ug_style_deck(); + deck.notes + .get_mut(&sid("note.finland")) + .unwrap() + .fields + .insert(sid("field.population"), "5.6 million".to_owned()); + + let report = deck + .validate() + .expect_err("unknown field must fail validation"); + + assert!(report.has_kind(ValidationErrorKind::UnknownNoteField)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "notes.note.finland.fields.field.population") + ); +} + +#[test] +fn validation_rejects_note_missing_required_field() { + let mut deck = ug_style_deck(); + deck.notes + .get_mut(&sid("note.finland")) + .unwrap() + .fields + .remove(&sid("field.capital")); + + let report = deck + .validate() + .expect_err("missing field must fail validation"); + + assert!(report.has_kind(ValidationErrorKind::MissingNoteField)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "notes.note.finland.fields.field.capital") + ); +} + +fn ug_style_deck() -> CanonicalDeck { + let mut note_type_adapter_ids = AdapterIds::new(); + note_type_adapter_ids.insert("crowdanki:model_id", "1548959259107"); + + let note_type = NoteType { + id: sid("note-type.country"), + name: "Ultimate Geography Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.capital"), + name: "Capital".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-to-capital"), + name: "Country - Capital".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{FrontSide}}
{{Capital}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: note_type_adapter_ids, + }; + + let mut note_adapter_ids = AdapterIds::new(); + note_adapter_ids.insert("crowdanki:guid", "ug-finland-guid"); + + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.capital"), "Helsinki".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: note_adapter_ids, + }; + + CanonicalDeck { + id: sid("deck.ultimate-geography"), + name: "Ultimate Geography".to_owned(), + description: "A geography deck fixture.".to_owned(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([( + sid("media.flag.finland"), + MediaReference { + id: sid("media.flag.finland"), + path: "flags/fi.png".to_owned(), + sha256: "0123456789abcdef".to_owned(), + }, + )]), + tombstones: BTreeSet::new(), + adapter_ids: AdapterIds::new(), + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-core/tests/overlay_compose.rs b/crates/brain-brew-core/tests/overlay_compose.rs new file mode 100644 index 0000000..64ea7f5 --- /dev/null +++ b/crates/brain-brew-core/tests/overlay_compose.rs @@ -0,0 +1,963 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIdChange, AdapterIds, CanonicalDeck, CardTemplate, CardTemplateChange, ChangeIntent, + ComposeErrorKind, DeckChange, ExpectedBase, FieldChange, FieldDefinition, + FieldDefinitionChange, MediaChange, MediaReference, Note, NoteChange, NoteType, NoteTypeChange, + Overlay, OverlayKind, PropertyChange, StableId, TagChange, TranslationChange, + TranslationDictionary, +}; + +#[test] +fn add_overlay_adds_a_new_note_to_the_resolved_deck() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.extension.nordics"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_changes: BTreeMap::from([( + sid("note.sweden"), + NoteChange { + intent: ChangeIntent::Add, + note: Some(sweden_note()), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("overlay composes"); + + assert!(resolved.notes.contains_key(&sid("note.finland"))); + assert_eq!( + resolved + .notes + .get(&sid("note.sweden")) + .unwrap() + .fields + .get(&sid("field.capital")), + Some(&"Stockholm".to_owned()) + ); +} + +#[test] +fn extension_overlay_can_add_a_note_type_and_notes_using_it() { + let base = ug_style_deck(); + let region_note_type = NoteType { + id: sid("note-type.region"), + name: "Geography Region".to_owned(), + variables: BTreeMap::from([("label.region".to_owned(), "Region".to_owned())]), + fields: vec![ + FieldDefinition { + id: sid("field.region"), + name: "Region".to_owned(), + }, + FieldDefinition { + id: sid("field.map"), + name: "Map".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.region-map"), + name: "Region - Map".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Region}}".to_owned(), + answer_format: "{{Map}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: AdapterIds::new(), + }; + let overlay = Overlay { + id: sid("overlay.extension.regions"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_type_changes: BTreeMap::from([( + sid("note-type.region"), + NoteTypeChange { + intent: ChangeIntent::Add, + note_type: Some(region_note_type), + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + note_changes: BTreeMap::from([( + sid("note.europe"), + NoteChange { + intent: ChangeIntent::Add, + note: Some(Note { + id: sid("note.europe"), + note_type_id: sid("note-type.region"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.region"), "Europe".to_owned()), + (sid("field.map"), "".to_owned()), + ]), + tags: BTreeSet::from(["UG::Europe".to_owned()]), + adapter_ids: AdapterIds::new(), + }), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("extension composes"); + + assert!(resolved.note_types.contains_key(&sid("note-type.region"))); + assert_eq!( + resolved.notes[&sid("note.europe")].note_type_id, + sid("note-type.region") + ); +} + +#[test] +fn replace_field_requires_expected_base() { + let base = ug_style_deck(); + let overlay = overlay_replacing_capital(ChangeIntent::Replace, None, "Helsingfors"); + + let report = base + .compose(&[overlay]) + .expect_err("replace without expected base must fail"); + + assert!(report.has_kind(ComposeErrorKind::MissingExpectedBase)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "notes.note.finland.fields.field.capital") + ); +} + +#[test] +fn replace_field_succeeds_when_expected_base_matches_current_value() { + let base = ug_style_deck(); + let overlay = overlay_replacing_capital( + ChangeIntent::Replace, + Some(ExpectedBase::Value("Helsinki".to_owned())), + "Helsingfors", + ); + + let resolved = base.compose(&[overlay]).expect("expected base matches"); + + assert_eq!( + resolved + .notes + .get(&sid("note.finland")) + .unwrap() + .fields + .get(&sid("field.capital")), + Some(&"Helsingfors".to_owned()) + ); +} + +#[test] +fn ordered_overlay_stack_reports_conflicts_without_explicit_override() { + let base = ug_style_deck(); + let first = overlay_replacing_capital(ChangeIntent::Merge, None, "Helsingfors"); + let second = overlay_replacing_capital(ChangeIntent::Merge, None, "Helsinki, Helsingfors"); + + let report = base + .compose(&[first, second]) + .expect_err("two non-override changes to the same path conflict"); + + assert!(report.has_kind(ComposeErrorKind::Conflict)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "notes.note.finland.fields.field.capital") + ); +} + +#[test] +fn later_override_can_intentionally_replace_an_earlier_overlay_change() { + let base = ug_style_deck(); + let first = overlay_replacing_capital(ChangeIntent::Merge, None, "Helsingfors"); + let second = overlay_replacing_capital( + ChangeIntent::Override, + Some(ExpectedBase::Value("Helsingfors".to_owned())), + "Helsinki / Helsingfors", + ); + + let resolved = base + .compose(&[first, second]) + .expect("override resolves conflict explicitly"); + + assert_eq!( + resolved + .notes + .get(&sid("note.finland")) + .unwrap() + .fields + .get(&sid("field.capital")), + Some(&"Helsinki / Helsingfors".to_owned()) + ); +} + +#[test] +fn translation_dictionary_reports_stale_entries() { + let deck = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.translation.da"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary { + changes: BTreeMap::from([( + "Missing source".to_owned(), + TranslationChange::Global("Mangler".to_owned()), + )]), + additions: BTreeMap::new(), + variables: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + require_complete: false, + ignore_paths: BTreeSet::new(), + }), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let report = deck.compose(&[overlay]).expect_err("stale entry fails"); + + assert!(report.has_kind(ComposeErrorKind::StaleTranslationEntry)); + assert!( + report.errors[0] + .message + .contains("translation source \"Missing source\" did not match") + ); +} + +#[test] +fn translation_dictionary_can_scope_ambiguous_changes_by_path_and_add_blank_values() { + let mut base = ug_style_deck(); + base.notes + .get_mut(&sid("note.finland")) + .unwrap() + .fields + .insert(sid("field.country"), "Shared source".to_owned()); + let mut sweden = sweden_note(); + sweden + .fields + .insert(sid("field.country"), "Shared source".to_owned()); + sweden.fields.insert(sid("field.capital"), String::new()); + base.notes.insert(sid("note.sweden"), sweden); + let overlay = Overlay { + id: sid("overlay.translation.da"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary { + changes: BTreeMap::from([( + "Shared source".to_owned(), + TranslationChange::AtPaths(BTreeMap::from([ + ( + "notes.note.finland.fields.field.country".to_owned(), + "Finsk kontekst".to_owned(), + ), + ( + "notes.note.sweden.fields.field.country".to_owned(), + "Svensk kontekst".to_owned(), + ), + ])), + )]), + additions: BTreeMap::from([( + "notes.note.sweden.fields.field.capital".to_owned(), + "Stockholm".to_owned(), + )]), + variables: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + require_complete: false, + ignore_paths: BTreeSet::new(), + }), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("translations compose"); + + assert_eq!( + resolved.notes[&sid("note.finland")].fields[&sid("field.country")], + "Finsk kontekst" + ); + assert_eq!( + resolved.notes[&sid("note.sweden")].fields[&sid("field.country")], + "Svensk kontekst" + ); + assert_eq!( + resolved.notes[&sid("note.sweden")].fields[&sid("field.capital")], + "Stockholm" + ); +} + +#[test] +fn translation_dictionary_can_translate_tags() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.translation.da"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary { + changes: BTreeMap::from([ + ( + "Nordic".to_owned(), + TranslationChange::Global("UG::Nordic".to_owned()), + ), + ( + "Europe".to_owned(), + TranslationChange::AtPaths(BTreeMap::from([( + "notes.note.finland.tags.Europe".to_owned(), + "UG::Europe".to_owned(), + )])), + ), + ]), + additions: BTreeMap::new(), + variables: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + require_complete: false, + ignore_paths: BTreeSet::new(), + }), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("tag translations compose"); + let note = &resolved.notes[&sid("note.finland")]; + + assert!(note.tags.contains("UG::Europe")); + assert!(note.tags.contains("UG::Nordic")); + assert!(!note.tags.contains("Europe")); + assert!(!note.tags.contains("Nordic")); +} + +#[test] +fn translation_dictionary_addition_fails_when_base_is_not_blank() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.translation.da"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary { + changes: BTreeMap::new(), + additions: BTreeMap::from([( + "notes.note.finland.fields.field.capital".to_owned(), + "Helsinki translated".to_owned(), + )]), + variables: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + require_complete: false, + ignore_paths: BTreeSet::new(), + }), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let report = base + .compose(&[overlay]) + .expect_err("addition expects blank base"); + + assert!(report.has_kind(ComposeErrorKind::ExpectedBaseMismatch)); + assert!(report.errors.iter().any(|error| { + error.path == "notes.note.finland.fields.field.capital" + && error.message.contains("expected blank value") + })); +} + +#[test] +fn extension_overlay_can_add_a_note_type_field_and_backfill_note_values() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.extension.population"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_type_changes: BTreeMap::from([( + sid("note-type.country"), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::from([( + sid("field.population"), + FieldDefinitionChange { + intent: ChangeIntent::Add, + field: Some(FieldDefinition { + id: sid("field.population"), + name: "Population".to_owned(), + }), + expected_base: None, + }, + )]), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + note_changes: BTreeMap::from([( + sid("note.finland"), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::from([( + sid("field.population"), + FieldChange { + intent: ChangeIntent::Add, + value: Some("5.6 million".to_owned()), + expected_base: None, + }, + )]), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("extension composes"); + + let note_type = resolved.note_types.get(&sid("note-type.country")).unwrap(); + assert_eq!(note_type.fields.last().unwrap().id, sid("field.population")); + assert_eq!( + resolved + .notes + .get(&sid("note.finland")) + .unwrap() + .fields + .get(&sid("field.population")), + Some(&"5.6 million".to_owned()) + ); +} + +#[test] +fn extension_overlay_adds_blank_values_for_new_fields_without_explicit_values() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.extension.region-code"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_type_changes: BTreeMap::from([( + sid("note-type.country"), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::from([( + sid("field.region-code"), + FieldDefinitionChange { + intent: ChangeIntent::Add, + field: Some(FieldDefinition { + id: sid("field.region-code"), + name: "Region code".to_owned(), + }), + expected_base: None, + }, + )]), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + note_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("extension composes"); + + assert_eq!( + resolved.notes[&sid("note.finland")] + .fields + .get(&sid("field.region-code")), + Some(&String::new()) + ); + resolved + .validate() + .expect("new fields default to blank values on existing notes"); +} + +#[test] +fn metadata_overlay_can_replace_names_and_adapter_identities() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.translation.de"), + kind: OverlayKind::Translation, + translations: None, + deck_change: Some(DeckChange { + name: Some(PropertyChange { + intent: ChangeIntent::Replace, + value: Some("Ultimate Geography [DE]".to_owned()), + expected_base: Some(ExpectedBase::Value("Ultimate Geography".to_owned())), + }), + description: None, + variables: BTreeMap::new(), + adapter_ids: BTreeMap::from([( + "crowdanki:uuid".to_owned(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some("de-deck-uuid".to_owned()), + expected_base: Some(ExpectedBase::Value( + "43c5ba66-9a65-11e8-90c9-a0481cc15658".to_owned(), + )), + }, + )]), + }), + note_type_changes: BTreeMap::from([( + sid("note-type.country"), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: Some(PropertyChange { + intent: ChangeIntent::Replace, + value: Some("Ultimate Geography [DE]".to_owned()), + expected_base: Some(ExpectedBase::Value( + "Ultimate Geography Country".to_owned(), + )), + }), + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::from([( + sid("template.country-to-capital"), + CardTemplateChange { + intent: ChangeIntent::Merge, + template: None, + insert_after: None, + name: None, + variables: BTreeMap::new(), + question_format: None, + answer_format: None, + adapter_ids: BTreeMap::from([( + "crowdanki:ord".to_owned(), + AdapterIdChange { + intent: ChangeIntent::Add, + value: Some("0".to_owned()), + expected_base: None, + }, + )]), + expected_base: None, + }, + )]), + adapter_ids: BTreeMap::from([( + "crowdanki:model_id".to_owned(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some("de-model-id".to_owned()), + expected_base: Some(ExpectedBase::Value("1548959259107".to_owned())), + }, + )]), + expected_base: None, + }, + )]), + note_changes: BTreeMap::from([( + sid("note.finland"), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::from([( + "crowdanki:guid".to_owned(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some("ug-finland-de-guid".to_owned()), + expected_base: Some(ExpectedBase::Value("ug-finland-guid".to_owned())), + }, + )]), + expected_base: None, + }, + )]), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("metadata overlay composes"); + + assert_eq!(resolved.name, "Ultimate Geography [DE]"); + assert_eq!( + resolved.adapter_ids.get("crowdanki:uuid"), + Some("de-deck-uuid") + ); + let note_type = resolved.note_types.get(&sid("note-type.country")).unwrap(); + assert_eq!(note_type.name, "Ultimate Geography [DE]"); + assert_eq!( + note_type.adapter_ids.get("crowdanki:model_id"), + Some("de-model-id") + ); + assert_eq!( + note_type.card_templates[0].adapter_ids.get("crowdanki:ord"), + Some("0") + ); + assert_eq!( + resolved + .notes + .get(&sid("note.finland")) + .unwrap() + .adapter_ids + .get("crowdanki:guid"), + Some("ug-finland-de-guid") + ); +} + +#[test] +fn overlay_can_add_card_templates_in_order_and_replace_styling() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.extension.extended"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + note_type_changes: BTreeMap::from([( + sid("note-type.country"), + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: Some(PropertyChange { + intent: ChangeIntent::Replace, + value: Some(".card { font-family: serif; }\n".to_owned()), + expected_base: Some(ExpectedBase::Value( + ".card { font-family: sans-serif; }\n".to_owned(), + )), + }), + fields: BTreeMap::new(), + card_templates: BTreeMap::from([( + sid("template.country-to-flag"), + CardTemplateChange { + intent: ChangeIntent::Add, + template: Some(CardTemplate { + id: sid("template.country-to-flag"), + name: "Country - Flag".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{Flag}}".to_owned(), + adapter_ids: AdapterIds::new(), + }), + insert_after: Some(sid("template.country-to-capital")), + name: None, + variables: BTreeMap::new(), + question_format: None, + answer_format: None, + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + }; + + let resolved = base.compose(&[overlay]).expect("template overlay composes"); + let note_type = resolved.note_types.get(&sid("note-type.country")).unwrap(); + assert_eq!(note_type.styling, ".card { font-family: serif; }\n"); + assert_eq!( + note_type + .card_templates + .iter() + .map(|template| template.id.clone()) + .collect::>(), + vec![ + sid("template.country-to-capital"), + sid("template.country-to-flag") + ] + ); + assert_eq!(note_type.card_templates[1].answer_format, "{{Flag}}"); +} + +#[test] +fn overlay_can_change_note_tags_and_media_references() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.extension.media-tags"), + kind: OverlayKind::Extension, + translations: None, + deck_change: None, + note_type_changes: BTreeMap::new(), + note_changes: BTreeMap::from([( + sid("note.finland"), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::from([ + ( + "UG::Nordic".to_owned(), + TagChange { + intent: ChangeIntent::Add, + expected_base: None, + }, + ), + ( + "Nordic".to_owned(), + TagChange { + intent: ChangeIntent::Remove, + expected_base: Some(ExpectedBase::EntityPresent), + }, + ), + ]), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + media_changes: BTreeMap::from([( + sid("media.flag.sweden"), + MediaChange { + intent: ChangeIntent::Add, + media: Some(MediaReference { + id: sid("media.flag.sweden"), + path: "flags/se.png".to_owned(), + sha256: "abcdef".to_owned(), + }), + expected_base: None, + }, + )]), + }; + + let resolved = base + .compose(&[overlay]) + .expect("tag/media overlay composes"); + let note = resolved.notes.get(&sid("note.finland")).unwrap(); + assert!(note.tags.contains("UG::Nordic")); + assert!(!note.tags.contains("Nordic")); + assert_eq!( + resolved.media.get(&sid("media.flag.sweden")).unwrap().path, + "flags/se.png" + ); +} + +#[test] +fn remove_overlay_can_tombstone_an_unused_note_type() { + let mut base = ug_style_deck(); + base.note_types.insert( + sid("note-type.region"), + NoteType { + id: sid("note-type.region"), + name: "Region".to_owned(), + variables: BTreeMap::new(), + fields: vec![FieldDefinition { + id: sid("field.region"), + name: "Region".to_owned(), + }], + card_templates: vec![CardTemplate { + id: sid("template.region"), + name: "Region".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Region}}".to_owned(), + answer_format: "{{Region}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card {}\n".to_owned(), + adapter_ids: AdapterIds::new(), + }, + ); + let overlay = Overlay { + id: sid("overlay.patch.remove-region-type"), + kind: OverlayKind::Patch, + translations: None, + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::from([( + sid("note-type.region"), + NoteTypeChange { + intent: ChangeIntent::Remove, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityPresent), + }, + )]), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("remove composes"); + + assert!(!resolved.note_types.contains_key(&sid("note-type.region"))); + assert!(resolved.tombstones.contains(&sid("note-type.region"))); +} + +#[test] +fn remove_overlay_records_a_tombstone_without_erasing_the_entity_from_resolved_deck() { + let base = ug_style_deck(); + let overlay = Overlay { + id: sid("overlay.patch.remove-finland"), + kind: OverlayKind::Patch, + translations: None, + deck_change: None, + note_changes: BTreeMap::from([( + sid("note.finland"), + NoteChange { + intent: ChangeIntent::Remove, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: Some(ExpectedBase::EntityPresent), + }, + )]), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }; + + let resolved = base.compose(&[overlay]).expect("remove composes"); + + assert!(resolved.notes.contains_key(&sid("note.finland"))); + assert!(resolved.tombstones.contains(&sid("note.finland"))); +} + +fn overlay_replacing_capital( + intent: ChangeIntent, + expected_base: Option, + value: &str, +) -> Overlay { + Overlay { + id: sid("overlay.patch.capital"), + kind: OverlayKind::Patch, + translations: None, + deck_change: None, + note_changes: BTreeMap::from([( + sid("note.finland"), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::from([( + sid("field.capital"), + FieldChange { + intent, + value: Some(value.to_owned()), + expected_base, + }, + )]), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + )]), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + } +} + +fn ug_style_deck() -> CanonicalDeck { + let mut note_type_adapter_ids = AdapterIds::new(); + note_type_adapter_ids.insert("crowdanki:model_id", "1548959259107"); + + let note_type = NoteType { + id: sid("note-type.country"), + name: "Ultimate Geography Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.capital"), + name: "Capital".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-to-capital"), + name: "Country - Capital".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{FrontSide}}
{{Capital}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: note_type_adapter_ids, + }; + + let mut note_adapter_ids = AdapterIds::new(); + note_adapter_ids.insert("crowdanki:guid", "ug-finland-guid"); + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.capital"), "Helsinki".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: note_adapter_ids, + }; + + let mut deck_adapter_ids = AdapterIds::new(); + deck_adapter_ids.insert("crowdanki:uuid", "43c5ba66-9a65-11e8-90c9-a0481cc15658"); + + CanonicalDeck { + id: sid("deck.ultimate-geography"), + name: "Ultimate Geography".to_owned(), + description: "A geography deck fixture.".to_owned(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([( + sid("media.flag.finland"), + MediaReference { + id: sid("media.flag.finland"), + path: "flags/fi.png".to_owned(), + sha256: "0123456789abcdef".to_owned(), + }, + )]), + tombstones: BTreeSet::new(), + adapter_ids: deck_adapter_ids, + } +} + +fn sweden_note() -> Note { + Note { + id: sid("note.sweden"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Sweden".to_owned()), + (sid("field.capital"), "Stockholm".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: AdapterIds::new(), + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-core/tests/semantic_diff.rs b/crates/brain-brew-core/tests/semantic_diff.rs new file mode 100644 index 0000000..e47b9fc --- /dev/null +++ b/crates/brain-brew-core/tests/semantic_diff.rs @@ -0,0 +1,147 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + SemanticChangeKind, StableId, +}; + +#[test] +fn unchanged_decks_have_empty_semantic_diff() { + let left = ug_style_deck(); + let right = ug_style_deck(); + + let diff = left.semantic_diff(&right); + + assert!(diff.is_empty()); +} + +#[test] +fn note_field_changes_are_reported_by_stable_id_path() { + let left = ug_style_deck(); + let mut right = ug_style_deck(); + right + .notes + .get_mut(&sid("note.finland")) + .unwrap() + .fields + .insert(sid("field.capital"), "Helsingfors".to_owned()); + + let diff = left.semantic_diff(&right); + + assert_eq!(diff.changes.len(), 1); + let change = &diff.changes[0]; + assert_eq!(change.kind, SemanticChangeKind::Modified); + assert_eq!(change.path, "notes.note.finland.fields.field.capital"); + assert_eq!(change.before.as_deref(), Some("Helsinki")); + assert_eq!(change.after.as_deref(), Some("Helsingfors")); +} + +#[test] +fn added_and_removed_notes_are_reported_by_stable_id_not_position() { + let left = ug_style_deck(); + let mut right = ug_style_deck(); + right.notes.remove(&sid("note.finland")); + right.notes.insert(sid("note.sweden"), sweden_note()); + + let diff = left.semantic_diff(&right); + + assert!(diff.has_change(SemanticChangeKind::Added, "notes.note.sweden")); + assert!(diff.has_change(SemanticChangeKind::Removed, "notes.note.finland")); +} + +#[test] +fn tombstones_are_distinct_semantic_changes() { + let left = ug_style_deck(); + let mut right = ug_style_deck(); + right.tombstones.insert(sid("note.finland")); + + let diff = left.semantic_diff(&right); + + assert!(diff.has_change(SemanticChangeKind::Tombstoned, "tombstones.note.finland")); +} + +fn ug_style_deck() -> CanonicalDeck { + let mut note_type_adapter_ids = AdapterIds::new(); + note_type_adapter_ids.insert("crowdanki:model_id", "1548959259107"); + + let note_type = NoteType { + id: sid("note-type.country"), + name: "Ultimate Geography Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.capital"), + name: "Capital".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-to-capital"), + name: "Country - Capital".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{FrontSide}}
{{Capital}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: note_type_adapter_ids, + }; + + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.capital"), "Helsinki".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: AdapterIds::new(), + }; + + CanonicalDeck { + id: sid("deck.ultimate-geography"), + name: "Ultimate Geography".to_owned(), + description: "A geography deck fixture.".to_owned(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([( + sid("media.flag.finland"), + MediaReference { + id: sid("media.flag.finland"), + path: "flags/fi.png".to_owned(), + sha256: "0123456789abcdef".to_owned(), + }, + )]), + tombstones: BTreeSet::new(), + adapter_ids: AdapterIds::new(), + } +} + +fn sweden_note() -> Note { + Note { + id: sid("note.sweden"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Sweden".to_owned()), + (sid("field.capital"), "Stockholm".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: AdapterIds::new(), + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-formats/Cargo.toml b/crates/brain-brew-formats/Cargo.toml new file mode 100644 index 0000000..24264f0 --- /dev/null +++ b/crates/brain-brew-formats/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "brain-brew-formats" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false + +[dependencies] +brain-brew-core.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" + +[lints] +workspace = true diff --git a/crates/brain-brew-formats/src/canonical_yaml.rs b/crates/brain-brew-formats/src/canonical_yaml.rs new file mode 100644 index 0000000..0588995 --- /dev/null +++ b/crates/brain-brew-formats/src/canonical_yaml.rs @@ -0,0 +1,1827 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::{self, Write as _}; + +use brain_brew_core::{ + AdapterIdChange, AdapterIds, CanonicalDeck, CardTemplate, CardTemplateChange, ChangeIntent, + DeckChange, ExpectedBase, FieldChange, FieldDefinition, FieldDefinitionChange, InvalidStableId, + MediaChange, MediaReference, Note, NoteChange, NoteType, NoteTypeChange, Overlay, OverlayKind, + PropertyChange, StableId, TagChange, TranslationChange, TranslationDictionary, + ValidationReport, +}; +use serde::Deserialize; + +/// Parse a CanonicalDeck from strict canonical YAML. +pub fn from_str(input: &str) -> Result { + let file: CanonicalDeckYaml = serde_yaml::from_str(input).map_err(CanonicalYamlError::Parse)?; + let deck = file.into_deck()?; + deck.validate().map_err(CanonicalYamlError::Validation)?; + Ok(deck) +} + +/// Parse a sparse overlay YAML file. +pub fn overlay_from_str(input: &str) -> Result { + let file: OverlayYaml = serde_yaml::from_str(input).map_err(CanonicalYamlError::Parse)?; + file.into_overlay() +} + +/// Parse and re-emit a CanonicalDeck YAML file using deterministic formatting. +pub fn format_str(input: &str) -> Result { + let deck = from_str(input)?; + to_string(&deck) +} + +/// Parse and re-emit a sparse overlay YAML file using deterministic formatting. +pub fn overlay_format_str(input: &str) -> Result { + let overlay = overlay_from_str(input)?; + Ok(overlay_to_string(&overlay)) +} + +/// Emit a CanonicalDeck as deterministic canonical YAML. +pub fn to_string(deck: &CanonicalDeck) -> Result { + deck.validate().map_err(CanonicalYamlError::Validation)?; + + let mut out = String::new(); + writeln!(out, "deck:").expect("writing to a string cannot fail"); + writeln!(out, " id: {}", deck.id).expect("writing to a string cannot fail"); + writeln!(out, " name: {}", yaml_scalar(&deck.name)).expect("writing to a string cannot fail"); + write_multiline_or_scalar(&mut out, " ", "description", &deck.description); + write_variables(&mut out, " ", &deck.variables); + write_adapter_ids(&mut out, " ", &deck.adapter_ids); + + writeln!(out, "note_types:").expect("writing to a string cannot fail"); + for (id, note_type) in &deck.note_types { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " name: {}", yaml_scalar(¬e_type.name)) + .expect("writing to a string cannot fail"); + write_variables(&mut out, " ", ¬e_type.variables); + writeln!(out, " field_order:").expect("writing to a string cannot fail"); + for field in ¬e_type.fields { + writeln!(out, " - {}", field.id).expect("writing to a string cannot fail"); + } + writeln!(out, " fields:").expect("writing to a string cannot fail"); + let fields_by_id = note_type + .fields + .iter() + .map(|field| (&field.id, field)) + .collect::>(); + for (field_id, field) in fields_by_id { + writeln!(out, " {field_id}:").expect("writing to a string cannot fail"); + writeln!(out, " name: {}", yaml_scalar(&field.name)) + .expect("writing to a string cannot fail"); + } + writeln!(out, " card_template_order:").expect("writing to a string cannot fail"); + for template in ¬e_type.card_templates { + writeln!(out, " - {}", template.id).expect("writing to a string cannot fail"); + } + writeln!(out, " card_templates:").expect("writing to a string cannot fail"); + let templates_by_id = note_type + .card_templates + .iter() + .map(|template| (&template.id, template)) + .collect::>(); + for (template_id, template) in templates_by_id { + writeln!(out, " {template_id}:").expect("writing to a string cannot fail"); + writeln!(out, " name: {}", yaml_scalar(&template.name)) + .expect("writing to a string cannot fail"); + write_variables(&mut out, " ", &template.variables); + write_multiline_or_scalar( + &mut out, + " ", + "question_format", + &template.question_format, + ); + write_multiline_or_scalar( + &mut out, + " ", + "answer_format", + &template.answer_format, + ); + write_adapter_ids(&mut out, " ", &template.adapter_ids); + } + write_multiline_or_scalar(&mut out, " ", "styling", ¬e_type.styling); + write_adapter_ids(&mut out, " ", ¬e_type.adapter_ids); + } + + writeln!(out, "notes:").expect("writing to a string cannot fail"); + for (id, note) in &deck.notes { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " note_type_id: {}", note.note_type_id) + .expect("writing to a string cannot fail"); + write_variables(&mut out, " ", ¬e.variables); + writeln!(out, " fields:").expect("writing to a string cannot fail"); + for (field_id, value) in ¬e.fields { + writeln!(out, " {field_id}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } + writeln!(out, " tags:").expect("writing to a string cannot fail"); + for tag in ¬e.tags { + writeln!(out, " - {}", yaml_scalar(tag)).expect("writing to a string cannot fail"); + } + write_adapter_ids(&mut out, " ", ¬e.adapter_ids); + } + + writeln!(out, "media:").expect("writing to a string cannot fail"); + for (id, media) in &deck.media { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " path: {}", yaml_scalar(&media.path)) + .expect("writing to a string cannot fail"); + writeln!(out, " sha256: {}", yaml_scalar(&media.sha256)) + .expect("writing to a string cannot fail"); + } + + if deck.tombstones.is_empty() { + writeln!(out, "tombstones: []").expect("writing to a string cannot fail"); + } else { + writeln!(out, "tombstones:").expect("writing to a string cannot fail"); + for tombstone in &deck.tombstones { + writeln!(out, " - {tombstone}").expect("writing to a string cannot fail"); + } + } + + Ok(out) +} + +/// Emit a sparse overlay as deterministic YAML. +pub fn overlay_to_string(overlay: &Overlay) -> String { + let mut out = String::new(); + writeln!(out, "id: {}", overlay.id).expect("writing to a string cannot fail"); + writeln!(out, "kind: {}", overlay_kind_name(overlay.kind)) + .expect("writing to a string cannot fail"); + + let (field_additions, note_type_changes, note_changes) = + split_field_additions_for_format(overlay); + let (field_fills, note_changes) = if overlay.kind == OverlayKind::Translation { + (BTreeMap::new(), note_changes) + } else { + split_field_fills_for_format(note_changes) + }; + + if let Some(translations) = &overlay.translations { + write_translation_dictionary(&mut out, translations); + } + + if !field_additions.is_empty() { + write_field_additions(&mut out, &field_additions); + } + + if !field_fills.is_empty() { + write_field_fills(&mut out, &field_fills); + } + + if let Some(deck_change) = &overlay.deck_change { + writeln!(out, "deck:").expect("writing to a string cannot fail"); + if let Some(change) = &deck_change.name { + write_property_change(&mut out, " ", "name", change); + } + if let Some(change) = &deck_change.description { + write_property_change(&mut out, " ", "description", change); + } + write_property_changes(&mut out, " ", "variables", &deck_change.variables); + write_adapter_id_changes(&mut out, " ", &deck_change.adapter_ids); + } + + if !note_type_changes.is_empty() { + writeln!(out, "note_types:").expect("writing to a string cannot fail"); + for (id, change) in ¬e_type_changes { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " intent: {}", change_intent_name(change.intent)) + .expect("writing to a string cannot fail"); + if let Some(expected_base) = &change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + if let Some(note_type) = &change.note_type { + writeln!(out, " note_type:").expect("writing to a string cannot fail"); + write_note_type_payload(&mut out, " ", note_type); + } + if let Some(name) = &change.name { + write_property_change(&mut out, " ", "name", name); + } + write_property_changes(&mut out, " ", "variables", &change.variables); + if let Some(styling) = &change.styling { + write_property_change(&mut out, " ", "styling", styling); + } + if !change.fields.is_empty() { + writeln!(out, " fields:").expect("writing to a string cannot fail"); + for (field_id, field_change) in &change.fields { + writeln!(out, " {field_id}:").expect("writing to a string cannot fail"); + writeln!( + out, + " intent: {}", + change_intent_name(field_change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(field) = &field_change.field { + writeln!(out, " name: {}", yaml_scalar(&field.name)) + .expect("writing to a string cannot fail"); + } + if let Some(expected_base) = &field_change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + } + } + if !change.card_templates.is_empty() { + writeln!(out, " card_templates:").expect("writing to a string cannot fail"); + for (template_id, template_change) in &change.card_templates { + writeln!(out, " {template_id}:").expect("writing to a string cannot fail"); + writeln!( + out, + " intent: {}", + change_intent_name(template_change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(expected_base) = &template_change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + if let Some(insert_after) = &template_change.insert_after { + writeln!(out, " insert_after: {insert_after}") + .expect("writing to a string cannot fail"); + } + if let Some(template) = &template_change.template { + writeln!(out, " template:") + .expect("writing to a string cannot fail"); + write_card_template_payload(&mut out, " ", template); + } + if let Some(name) = &template_change.name { + write_property_change(&mut out, " ", "name", name); + } + write_property_changes( + &mut out, + " ", + "variables", + &template_change.variables, + ); + if let Some(question_format) = &template_change.question_format { + write_property_change( + &mut out, + " ", + "question_format", + question_format, + ); + } + if let Some(answer_format) = &template_change.answer_format { + write_property_change(&mut out, " ", "answer_format", answer_format); + } + write_adapter_id_changes(&mut out, " ", &template_change.adapter_ids); + } + } + write_adapter_id_changes(&mut out, " ", &change.adapter_ids); + } + } + + if !note_changes.is_empty() { + writeln!(out, "notes:").expect("writing to a string cannot fail"); + for (id, change) in ¬e_changes { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " intent: {}", change_intent_name(change.intent)) + .expect("writing to a string cannot fail"); + if let Some(expected_base) = &change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + if let Some(note) = &change.note { + writeln!(out, " note:").expect("writing to a string cannot fail"); + write_note_payload(&mut out, " ", note); + } + write_property_changes(&mut out, " ", "variables", &change.variables); + if !change.fields.is_empty() { + writeln!(out, " fields:").expect("writing to a string cannot fail"); + for (field_id, field_change) in &change.fields { + write_field_change(&mut out, " ", field_id, field_change); + } + } + if !change.tags.is_empty() { + writeln!(out, " tags:").expect("writing to a string cannot fail"); + for (tag, tag_change) in &change.tags { + writeln!(out, " {}:", yaml_scalar(tag)) + .expect("writing to a string cannot fail"); + writeln!( + out, + " intent: {}", + change_intent_name(tag_change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(expected_base) = &tag_change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + } + } + write_adapter_id_changes(&mut out, " ", &change.adapter_ids); + } + } + + if !overlay.media_changes.is_empty() { + writeln!(out, "media:").expect("writing to a string cannot fail"); + for (id, change) in &overlay.media_changes { + writeln!(out, " {id}:").expect("writing to a string cannot fail"); + writeln!(out, " intent: {}", change_intent_name(change.intent)) + .expect("writing to a string cannot fail"); + if let Some(media) = &change.media { + writeln!(out, " path: {}", yaml_scalar(&media.path)) + .expect("writing to a string cannot fail"); + writeln!(out, " sha256: {}", yaml_scalar(&media.sha256)) + .expect("writing to a string cannot fail"); + } + if let Some(expected_base) = &change.expected_base { + write_expected_base(&mut out, " ", expected_base); + } + } + } + + out +} + +#[derive(Default)] +struct FieldAdditionsForFormat { + fields: BTreeMap, + values: BTreeMap>, +} + +fn split_field_additions_for_format( + overlay: &Overlay, +) -> ( + BTreeMap, + BTreeMap, + BTreeMap, +) { + let mut field_additions = BTreeMap::::new(); + let mut note_type_changes = overlay.note_type_changes.clone(); + let mut note_changes = overlay.note_changes.clone(); + let mut field_to_note_type = BTreeMap::::new(); + let mut ambiguous_fields = BTreeSet::::new(); + + for (note_type_id, change) in &overlay.note_type_changes { + if change.intent != ChangeIntent::Merge { + continue; + } + for (field_id, field_change) in &change.fields { + if field_change.intent == ChangeIntent::Add + && field_change.expected_base.is_none() + && let Some(field) = &field_change.field + { + field_additions + .entry(note_type_id.clone()) + .or_default() + .fields + .insert(field_id.clone(), field.name.clone()); + if field_to_note_type + .insert(field_id.clone(), note_type_id.clone()) + .is_some() + { + ambiguous_fields.insert(field_id.clone()); + } + } + } + } + + for field_id in ambiguous_fields { + field_to_note_type.remove(&field_id); + } + + for (note_id, change) in &overlay.note_changes { + if change.intent != ChangeIntent::Merge { + continue; + } + for (field_id, field_change) in &change.fields { + if field_change.intent == ChangeIntent::Add && field_change.expected_base.is_none() { + let Some(value) = &field_change.value else { + continue; + }; + let Some(note_type_id) = field_to_note_type.get(field_id) else { + continue; + }; + field_additions + .entry(note_type_id.clone()) + .or_default() + .values + .entry(note_id.clone()) + .or_default() + .insert(field_id.clone(), value.clone()); + } + } + } + + for (note_type_id, additions) in &field_additions { + let Some(change) = note_type_changes.get_mut(note_type_id) else { + continue; + }; + for field_id in additions.fields.keys() { + change.fields.remove(field_id); + } + } + note_type_changes.retain(|_, change| !is_empty_note_type_merge_change(change)); + + for additions in field_additions.values() { + for (note_id, values) in &additions.values { + let Some(change) = note_changes.get_mut(note_id) else { + continue; + }; + for field_id in values.keys() { + change.fields.remove(field_id); + } + } + } + note_changes.retain(|_, change| !is_empty_note_merge_change(change)); + + field_additions.retain(|_, additions| !additions.fields.is_empty()); + (field_additions, note_type_changes, note_changes) +} + +fn split_field_fills_for_format( + mut note_changes: BTreeMap, +) -> ( + BTreeMap>, + BTreeMap, +) { + let mut field_fills = BTreeMap::>::new(); + + for (note_id, change) in ¬e_changes { + if change.intent != ChangeIntent::Merge { + continue; + } + for (field_id, field_change) in &change.fields { + if let Some(value) = field_fill_value(field_change) { + field_fills + .entry(note_id.clone()) + .or_default() + .insert(field_id.clone(), value.to_owned()); + } + } + } + + for (note_id, fields) in &field_fills { + let Some(change) = note_changes.get_mut(note_id) else { + continue; + }; + for field_id in fields.keys() { + change.fields.remove(field_id); + } + } + note_changes.retain(|_, change| !is_empty_note_merge_change(change)); + + (field_fills, note_changes) +} + +fn field_fill_value(change: &FieldChange) -> Option<&str> { + if change.intent == ChangeIntent::Replace + && matches!(change.expected_base, Some(ExpectedBase::Value(ref value)) if value.is_empty()) + { + change.value.as_deref() + } else { + None + } +} + +fn is_empty_note_type_merge_change(change: &NoteTypeChange) -> bool { + change.intent == ChangeIntent::Merge + && change.note_type.is_none() + && change.name.is_none() + && change.variables.is_empty() + && change.styling.is_none() + && change.fields.is_empty() + && change.card_templates.is_empty() + && change.adapter_ids.is_empty() + && change.expected_base.is_none() +} + +fn is_empty_note_merge_change(change: &NoteChange) -> bool { + change.intent == ChangeIntent::Merge + && change.note.is_none() + && change.variables.is_empty() + && change.fields.is_empty() + && change.tags.is_empty() + && change.adapter_ids.is_empty() + && change.expected_base.is_none() +} + +fn write_field_additions( + out: &mut String, + field_additions: &BTreeMap, +) { + writeln!(out, "field_additions:").expect("writing to a string cannot fail"); + for (note_type_id, additions) in field_additions { + writeln!(out, " {note_type_id}:").expect("writing to a string cannot fail"); + writeln!(out, " fields:").expect("writing to a string cannot fail"); + for (field_id, name) in &additions.fields { + writeln!(out, " {field_id}: {}", yaml_scalar(name)) + .expect("writing to a string cannot fail"); + } + if !additions.values.is_empty() { + writeln!(out, " values:").expect("writing to a string cannot fail"); + for (note_id, values) in &additions.values { + writeln!(out, " {note_id}:").expect("writing to a string cannot fail"); + for (field_id, value) in values { + writeln!(out, " {field_id}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } + } + } + } +} + +fn write_field_fills( + out: &mut String, + field_fills: &BTreeMap>, +) { + writeln!(out, "field_fills:").expect("writing to a string cannot fail"); + for (note_id, fields) in field_fills { + writeln!(out, " {note_id}:").expect("writing to a string cannot fail"); + for (field_id, value) in fields { + writeln!(out, " {field_id}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } + } +} + +fn write_translation_dictionary(out: &mut String, translations: &TranslationDictionary) { + writeln!(out, "translations:").expect("writing to a string cannot fail"); + if translations.require_complete { + writeln!(out, " require_complete: true").expect("writing to a string cannot fail"); + } + if !translations.ignore_paths.is_empty() { + writeln!(out, " ignore_paths:").expect("writing to a string cannot fail"); + for path in &translations.ignore_paths { + writeln!(out, " - {}", yaml_scalar(path)).expect("writing to a string cannot fail"); + } + } + if !translations.changes.is_empty() { + writeln!(out, " changes:").expect("writing to a string cannot fail"); + for (source, change) in &translations.changes { + match change { + TranslationChange::Global(translated) => { + writeln!( + out, + " {}: {}", + yaml_scalar(source), + yaml_scalar(translated) + ) + .expect("writing to a string cannot fail"); + } + TranslationChange::AtPaths(paths) => { + writeln!(out, " {}:", yaml_scalar(source)) + .expect("writing to a string cannot fail"); + for (path, translated) in paths { + writeln!( + out, + " {}: {}", + yaml_scalar(path), + yaml_scalar(translated) + ) + .expect("writing to a string cannot fail"); + } + } + } + } + } + if !translations.additions.is_empty() { + writeln!(out, " additions:").expect("writing to a string cannot fail"); + for (path, value) in &translations.additions { + writeln!(out, " {}: {}", yaml_scalar(path), yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } + } + if !translations.variables.is_empty() { + writeln!(out, " variables:").expect("writing to a string cannot fail"); + for (variable_key, replacements) in &translations.variables { + writeln!(out, " {variable_key}:").expect("writing to a string cannot fail"); + for (source, translated) in replacements { + writeln!( + out, + " {}: {}", + yaml_scalar(source), + yaml_scalar(translated) + ) + .expect("writing to a string cannot fail"); + } + } + } + if !translations.adapter_ids.is_empty() { + writeln!(out, " adapter_ids:").expect("writing to a string cannot fail"); + for (adapter_key, replacements) in &translations.adapter_ids { + writeln!(out, " {adapter_key}:").expect("writing to a string cannot fail"); + for (source, translated) in replacements { + writeln!( + out, + " {}: {}", + yaml_scalar(source), + yaml_scalar(translated) + ) + .expect("writing to a string cannot fail"); + } + } + } +} + +fn write_variables(out: &mut String, indent: &str, variables: &BTreeMap) { + if variables.is_empty() { + return; + } + writeln!(out, "{indent}variables:").expect("writing to a string cannot fail"); + for (key, value) in variables { + write_multiline_or_scalar(out, &format!("{indent} "), key, value); + } +} + +fn write_property_changes( + out: &mut String, + indent: &str, + key: &str, + changes: &BTreeMap, +) { + if changes.is_empty() { + return; + } + writeln!(out, "{indent}{key}:").expect("writing to a string cannot fail"); + for (change_key, change) in changes { + write_property_change(out, &format!("{indent} "), change_key, change); + } +} + +fn write_adapter_ids(out: &mut String, indent: &str, adapter_ids: &AdapterIds) { + if adapter_ids.is_empty() { + writeln!(out, "{indent}adapter_ids: {{}}").expect("writing to a string cannot fail"); + return; + } + + writeln!(out, "{indent}adapter_ids:").expect("writing to a string cannot fail"); + for (key, value) in adapter_ids.iter() { + writeln!(out, "{indent} {key}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } +} + +fn write_property_change(out: &mut String, indent: &str, key: &str, change: &PropertyChange) { + writeln!(out, "{indent}{key}:").expect("writing to a string cannot fail"); + writeln!( + out, + "{indent} intent: {}", + change_intent_name(change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(value) = &change.value { + write_multiline_or_scalar(out, &format!("{indent} "), "value", value); + } + if let Some(expected_base) = &change.expected_base { + write_expected_base(out, &format!("{indent} "), expected_base); + } +} + +fn write_adapter_id_changes( + out: &mut String, + indent: &str, + adapter_ids: &BTreeMap, +) { + if adapter_ids.is_empty() { + return; + } + writeln!(out, "{indent}adapter_ids:").expect("writing to a string cannot fail"); + for (key, change) in adapter_ids { + writeln!(out, "{indent} {key}:").expect("writing to a string cannot fail"); + writeln!( + out, + "{indent} intent: {}", + change_intent_name(change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(value) = &change.value { + write_multiline_or_scalar(out, &format!("{indent} "), "value", value); + } + if let Some(expected_base) = &change.expected_base { + write_expected_base(out, &format!("{indent} "), expected_base); + } + } +} + +fn write_field_change(out: &mut String, indent: &str, field_id: &StableId, change: &FieldChange) { + writeln!(out, "{indent}{field_id}:").expect("writing to a string cannot fail"); + writeln!( + out, + "{indent} intent: {}", + change_intent_name(change.intent) + ) + .expect("writing to a string cannot fail"); + if let Some(value) = &change.value { + write_multiline_or_scalar(out, &format!("{indent} "), "value", value); + } + if let Some(expected_base) = &change.expected_base { + write_expected_base(out, &format!("{indent} "), expected_base); + } +} + +fn write_note_type_payload(out: &mut String, indent: &str, note_type: &NoteType) { + writeln!(out, "{indent}name: {}", yaml_scalar(¬e_type.name)) + .expect("writing to a string cannot fail"); + write_variables(out, indent, ¬e_type.variables); + writeln!(out, "{indent}field_order:").expect("writing to a string cannot fail"); + for field in ¬e_type.fields { + writeln!(out, "{indent} - {}", field.id).expect("writing to a string cannot fail"); + } + writeln!(out, "{indent}fields:").expect("writing to a string cannot fail"); + let fields_by_id = note_type + .fields + .iter() + .map(|field| (&field.id, field)) + .collect::>(); + for (field_id, field) in fields_by_id { + writeln!(out, "{indent} {field_id}:").expect("writing to a string cannot fail"); + writeln!(out, "{indent} name: {}", yaml_scalar(&field.name)) + .expect("writing to a string cannot fail"); + } + writeln!(out, "{indent}card_template_order:").expect("writing to a string cannot fail"); + for template in ¬e_type.card_templates { + writeln!(out, "{indent} - {}", template.id).expect("writing to a string cannot fail"); + } + writeln!(out, "{indent}card_templates:").expect("writing to a string cannot fail"); + let templates_by_id = note_type + .card_templates + .iter() + .map(|template| (&template.id, template)) + .collect::>(); + for (template_id, template) in templates_by_id { + writeln!(out, "{indent} {template_id}:").expect("writing to a string cannot fail"); + writeln!(out, "{indent} name: {}", yaml_scalar(&template.name)) + .expect("writing to a string cannot fail"); + write_variables(out, &format!("{indent} "), &template.variables); + write_multiline_or_scalar( + out, + &format!("{indent} "), + "question_format", + &template.question_format, + ); + write_multiline_or_scalar( + out, + &format!("{indent} "), + "answer_format", + &template.answer_format, + ); + write_adapter_ids(out, &format!("{indent} "), &template.adapter_ids); + } + write_multiline_or_scalar(out, indent, "styling", ¬e_type.styling); + write_adapter_ids(out, indent, ¬e_type.adapter_ids); +} + +fn write_note_payload(out: &mut String, indent: &str, note: &Note) { + writeln!(out, "{indent}note_type_id: {}", note.note_type_id) + .expect("writing to a string cannot fail"); + write_variables(out, indent, ¬e.variables); + writeln!(out, "{indent}fields:").expect("writing to a string cannot fail"); + for (field_id, value) in ¬e.fields { + writeln!(out, "{indent} {field_id}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } + writeln!(out, "{indent}tags:").expect("writing to a string cannot fail"); + for tag in ¬e.tags { + writeln!(out, "{indent} - {}", yaml_scalar(tag)).expect("writing to a string cannot fail"); + } + write_adapter_ids(out, indent, ¬e.adapter_ids); +} + +fn write_card_template_payload(out: &mut String, indent: &str, template: &CardTemplate) { + writeln!(out, "{indent}name: {}", yaml_scalar(&template.name)) + .expect("writing to a string cannot fail"); + write_variables(out, indent, &template.variables); + write_multiline_or_scalar(out, indent, "question_format", &template.question_format); + write_multiline_or_scalar(out, indent, "answer_format", &template.answer_format); + write_adapter_ids(out, indent, &template.adapter_ids); +} + +fn write_expected_base(out: &mut String, indent: &str, expected_base: &ExpectedBase) { + match expected_base { + ExpectedBase::EntityPresent => { + writeln!(out, "{indent}expected_base: entity_present") + .expect("writing to a string cannot fail"); + } + ExpectedBase::Value(value) => { + writeln!(out, "{indent}expected_base:").expect("writing to a string cannot fail"); + write_multiline_or_scalar(out, &format!("{indent} "), "value", value); + } + } +} + +fn overlay_kind_name(kind: OverlayKind) -> &'static str { + match kind { + OverlayKind::Translation => "translation", + OverlayKind::Extension => "extension", + OverlayKind::Patch => "patch", + OverlayKind::Personal => "personal", + } +} + +fn change_intent_name(intent: ChangeIntent) -> &'static str { + match intent { + ChangeIntent::Add => "add", + ChangeIntent::Merge => "merge", + ChangeIntent::Replace => "replace", + ChangeIntent::Remove => "remove", + ChangeIntent::Override => "override", + } +} + +fn write_multiline_or_scalar(out: &mut String, indent: &str, key: &str, value: &str) { + if value.contains('\n') { + let chomp = if value.ends_with('\n') { "|" } else { "|-" }; + writeln!(out, "{indent}{key}: {chomp}").expect("writing to a string cannot fail"); + for line in value.lines() { + writeln!(out, "{indent} {line}").expect("writing to a string cannot fail"); + } + } else { + writeln!(out, "{indent}{key}: {}", yaml_scalar(value)) + .expect("writing to a string cannot fail"); + } +} + +fn yaml_scalar(value: &str) -> String { + if can_emit_plain_scalar(value) { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "''")) + } +} + +fn can_emit_plain_scalar(value: &str) -> bool { + !value.is_empty() + && !value.starts_with([ + ' ', '-', '?', ':', '@', '`', '&', '*', '!', '|', '>', '#', '{', '[', ',', + ]) + && !value.ends_with(' ') + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ' ' | '.' | ',' | '_' | '-' | '/')) + && !value.chars().all(|ch| ch.is_ascii_digit()) + && !matches!( + value, + "true" | "false" | "True" | "False" | "TRUE" | "FALSE" | "null" | "Null" | "NULL" + ) +} + +#[derive(Debug)] +pub enum CanonicalYamlError { + Parse(serde_yaml::Error), + StableId(InvalidStableId), + InvalidOverlayKind(String), + InvalidChangeIntent(String), + InvalidExpectedBase(String), + InvalidFieldAddition(String), + InvalidFieldFill(String), + MissingOrderedEntity { section: &'static str, id: String }, + UnorderedEntity { section: &'static str, id: String }, + Validation(ValidationReport), +} + +impl fmt::Display for CanonicalYamlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(error) => write!(f, "failed to parse canonical YAML: {error}"), + Self::StableId(error) => write!(f, "{error}"), + Self::InvalidOverlayKind(kind) => write!(f, "invalid overlay kind {kind:?}"), + Self::InvalidChangeIntent(intent) => write!(f, "invalid change intent {intent:?}"), + Self::InvalidExpectedBase(expected_base) => { + write!(f, "invalid expected base {expected_base:?}") + } + Self::InvalidFieldAddition(message) => write!(f, "invalid field addition: {message}"), + Self::InvalidFieldFill(message) => write!(f, "invalid field fill: {message}"), + Self::MissingOrderedEntity { section, id } => { + write!(f, "{section} order references missing entity {id}") + } + Self::UnorderedEntity { section, id } => { + write!(f, "{section} entity {id} is missing from its order array") + } + Self::Validation(report) => write!(f, "canonical deck validation failed: {report}"), + } + } +} + +impl std::error::Error for CanonicalYamlError {} + +impl From for CanonicalYamlError { + fn from(error: InvalidStableId) -> Self { + Self::StableId(error) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OverlayYaml { + id: String, + kind: String, + #[serde(default)] + translations: Option, + #[serde(default)] + deck: Option, + #[serde(default)] + field_additions: BTreeMap, + #[serde(default)] + field_fills: BTreeMap>, + #[serde(default)] + notes: BTreeMap, + #[serde(default)] + note_types: BTreeMap, + #[serde(default)] + media: BTreeMap, +} + +impl OverlayYaml { + fn into_overlay(self) -> Result { + let mut note_changes = self + .notes + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id.clone(), change.into_note_change(id)?)) + }) + .collect::, CanonicalYamlError>>()?; + let mut note_type_changes = self + .note_types + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id.clone(), change.into_note_type_change(id)?)) + }) + .collect::, CanonicalYamlError>>()?; + + for (note_type_id, additions) in self.field_additions { + additions.apply( + sid(¬e_type_id)?, + &mut note_type_changes, + &mut note_changes, + )?; + } + + apply_field_fills(self.field_fills, &mut note_changes)?; + + Ok(Overlay { + id: sid(&self.id)?, + kind: parse_overlay_kind(&self.kind)?, + translations: self + .translations + .map(TranslationDictionaryYaml::into_translation_dictionary), + deck_change: self + .deck + .map(DeckChangeYaml::into_deck_change) + .transpose()?, + note_changes, + note_type_changes, + media_changes: self + .media + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id.clone(), change.into_media_change(id)?)) + }) + .collect::>()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldAdditionsYaml { + fields: BTreeMap, + #[serde(default)] + values: BTreeMap>, +} + +impl FieldAdditionsYaml { + fn apply( + self, + note_type_id: StableId, + note_type_changes: &mut BTreeMap, + note_changes: &mut BTreeMap, + ) -> Result<(), CanonicalYamlError> { + if self.fields.is_empty() { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id}.fields must not be empty" + ))); + } + + let mut declared_fields = BTreeSet::new(); + let note_type_change = note_type_changes + .entry(note_type_id.clone()) + .or_insert_with(empty_note_type_merge_change); + if note_type_change.intent != ChangeIntent::Merge { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id} can only merge into an existing note type" + ))); + } + + for (field_id, name) in self.fields { + let field_id = sid(&field_id)?; + if !declared_fields.insert(field_id.clone()) { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "duplicate field_additions.{note_type_id}.fields.{field_id}" + ))); + } + if note_type_change.fields.contains_key(&field_id) { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id}.fields.{field_id} conflicts with another field change" + ))); + } + note_type_change.fields.insert( + field_id.clone(), + FieldDefinitionChange { + intent: ChangeIntent::Add, + field: Some(FieldDefinition { id: field_id, name }), + expected_base: None, + }, + ); + } + + for (note_id, values) in self.values { + let note_id = sid(¬e_id)?; + let note_change = note_changes + .entry(note_id.clone()) + .or_insert_with(empty_note_merge_change); + if note_change.intent != ChangeIntent::Merge { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id}.values.{note_id} can only merge into an existing note" + ))); + } + for (field_id, value) in values { + let field_id = sid(&field_id)?; + if !declared_fields.contains(&field_id) { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id}.values.{note_id}.{field_id} has no declared field" + ))); + } + if note_change.fields.contains_key(&field_id) { + return Err(CanonicalYamlError::InvalidFieldAddition(format!( + "field_additions.{note_type_id}.values.{note_id}.{field_id} conflicts with another field change" + ))); + } + note_change.fields.insert( + field_id, + FieldChange { + intent: ChangeIntent::Add, + value: Some(value), + expected_base: None, + }, + ); + } + } + + Ok(()) + } +} + +fn apply_field_fills( + field_fills: BTreeMap>, + note_changes: &mut BTreeMap, +) -> Result<(), CanonicalYamlError> { + for (note_id, fields) in field_fills { + let note_id = sid(¬e_id)?; + if fields.is_empty() { + return Err(CanonicalYamlError::InvalidFieldFill(format!( + "field_fills.{note_id} must not be empty" + ))); + } + let note_change = note_changes + .entry(note_id.clone()) + .or_insert_with(empty_note_merge_change); + if note_change.intent != ChangeIntent::Merge { + return Err(CanonicalYamlError::InvalidFieldFill(format!( + "field_fills.{note_id} can only merge into an existing note" + ))); + } + for (field_id, value) in fields { + let field_id = sid(&field_id)?; + if note_change.fields.contains_key(&field_id) { + return Err(CanonicalYamlError::InvalidFieldFill(format!( + "field_fills.{note_id}.{field_id} conflicts with another field change" + ))); + } + note_change.fields.insert( + field_id, + FieldChange { + intent: ChangeIntent::Replace, + value: Some(value), + expected_base: Some(ExpectedBase::Value(String::new())), + }, + ); + } + } + + Ok(()) +} + +fn empty_note_type_merge_change() -> NoteTypeChange { + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + } +} + +fn empty_note_merge_change() -> NoteChange { + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TranslationDictionaryYaml { + #[serde(default)] + changes: BTreeMap, + #[serde(default)] + additions: BTreeMap, + #[serde(default)] + variables: BTreeMap>, + #[serde(default)] + adapter_ids: BTreeMap>, + #[serde(default)] + require_complete: bool, + #[serde(default)] + ignore_paths: BTreeSet, +} + +impl TranslationDictionaryYaml { + fn into_translation_dictionary(self) -> TranslationDictionary { + TranslationDictionary { + changes: self + .changes + .into_iter() + .map(|(source, change)| (source, change.into_translation_change())) + .collect(), + additions: self.additions, + variables: self.variables, + adapter_ids: self.adapter_ids, + require_complete: self.require_complete, + ignore_paths: self.ignore_paths, + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum TranslationChangeYaml { + Global(String), + AtPaths(BTreeMap), +} + +impl TranslationChangeYaml { + fn into_translation_change(self) -> TranslationChange { + match self { + Self::Global(value) => TranslationChange::Global(value), + Self::AtPaths(paths) => TranslationChange::AtPaths(paths), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeckChangeYaml { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + #[serde(default)] + variables: BTreeMap, + #[serde(default)] + adapter_ids: BTreeMap, +} + +impl DeckChangeYaml { + fn into_deck_change(self) -> Result { + Ok(DeckChange { + name: self + .name + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + description: self + .description + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + variables: self + .variables + .into_iter() + .map(|(key, change)| Ok((key, change.into_property_change()?))) + .collect::>()?, + adapter_ids: self + .adapter_ids + .into_iter() + .map(|(key, change)| Ok((key, change.into_adapter_id_change()?))) + .collect::>()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoteTypeChangeYaml { + intent: String, + #[serde(default)] + note_type: Option, + #[serde(default)] + name: Option, + #[serde(default)] + variables: BTreeMap, + #[serde(default)] + styling: Option, + #[serde(default)] + fields: BTreeMap, + #[serde(default)] + card_templates: BTreeMap, + #[serde(default)] + adapter_ids: BTreeMap, + #[serde(default)] + expected_base: Option, +} + +impl NoteTypeChangeYaml { + fn into_note_type_change(self, id: StableId) -> Result { + Ok(NoteTypeChange { + intent: parse_change_intent(&self.intent)?, + note_type: self + .note_type + .map(|note_type| note_type.into_note_type(id.clone())) + .transpose()?, + name: self + .name + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + variables: self + .variables + .into_iter() + .map(|(key, change)| Ok((key, change.into_property_change()?))) + .collect::>()?, + styling: self + .styling + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + fields: self + .fields + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id.clone(), change.into_field_definition_change(id)?)) + }) + .collect::>()?, + card_templates: self + .card_templates + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id.clone(), change.into_card_template_change(id)?)) + }) + .collect::>()?, + adapter_ids: self + .adapter_ids + .into_iter() + .map(|(key, change)| Ok((key, change.into_adapter_id_change()?))) + .collect::>()?, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CardTemplatePayloadYaml { + name: String, + #[serde(default)] + variables: BTreeMap, + question_format: String, + answer_format: String, + #[serde(default)] + adapter_ids: BTreeMap, +} + +impl CardTemplatePayloadYaml { + fn into_card_template(self, id: StableId) -> Result { + Ok(CardTemplate { + id, + name: self.name, + variables: self.variables, + question_format: self.question_format, + answer_format: self.answer_format, + adapter_ids: adapter_ids_from_map(self.adapter_ids), + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CardTemplateChangeYaml { + intent: String, + #[serde(default)] + template: Option, + #[serde(default)] + insert_after: Option, + #[serde(default)] + name: Option, + #[serde(default)] + variables: BTreeMap, + #[serde(default)] + question_format: Option, + #[serde(default)] + answer_format: Option, + #[serde(default)] + adapter_ids: BTreeMap, + #[serde(default)] + expected_base: Option, +} + +impl CardTemplateChangeYaml { + fn into_card_template_change( + self, + id: StableId, + ) -> Result { + Ok(CardTemplateChange { + intent: parse_change_intent(&self.intent)?, + template: self + .template + .map(|template| template.into_card_template(id)) + .transpose()?, + insert_after: self.insert_after.map(|id| sid(&id)).transpose()?, + name: self + .name + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + variables: self + .variables + .into_iter() + .map(|(key, change)| Ok((key, change.into_property_change()?))) + .collect::>()?, + question_format: self + .question_format + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + answer_format: self + .answer_format + .map(PropertyChangeYaml::into_property_change) + .transpose()?, + adapter_ids: self + .adapter_ids + .into_iter() + .map(|(key, change)| Ok((key, change.into_adapter_id_change()?))) + .collect::>()?, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PropertyChangeYaml { + intent: String, + #[serde(default)] + value: Option, + #[serde(default)] + expected_base: Option, +} + +impl PropertyChangeYaml { + fn into_property_change(self) -> Result { + Ok(PropertyChange { + intent: parse_change_intent(&self.intent)?, + value: self.value, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterIdChangeYaml { + intent: String, + #[serde(default)] + value: Option, + #[serde(default)] + expected_base: Option, +} + +impl AdapterIdChangeYaml { + fn into_adapter_id_change(self) -> Result { + Ok(AdapterIdChange { + intent: parse_change_intent(&self.intent)?, + value: self.value, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldDefinitionChangeYaml { + intent: String, + #[serde(default)] + name: Option, + #[serde(default)] + expected_base: Option, +} + +impl FieldDefinitionChangeYaml { + fn into_field_definition_change( + self, + id: StableId, + ) -> Result { + Ok(FieldDefinitionChange { + intent: parse_change_intent(&self.intent)?, + field: self.name.map(|name| FieldDefinition { id, name }), + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoteChangeYaml { + intent: String, + #[serde(default)] + note: Option, + #[serde(default)] + variables: BTreeMap, + #[serde(default)] + fields: BTreeMap, + #[serde(default)] + tags: BTreeMap, + #[serde(default)] + adapter_ids: BTreeMap, + #[serde(default)] + expected_base: Option, +} + +impl NoteChangeYaml { + fn into_note_change(self, id: StableId) -> Result { + Ok(NoteChange { + intent: parse_change_intent(&self.intent)?, + note: self.note.map(|note| note.into_note(id)).transpose()?, + variables: self + .variables + .into_iter() + .map(|(key, change)| Ok((key, change.into_property_change()?))) + .collect::>()?, + fields: self + .fields + .into_iter() + .map(|(id, change)| { + let id = sid(&id)?; + Ok((id, change.into_field_change()?)) + }) + .collect::>()?, + tags: self + .tags + .into_iter() + .map(|(tag, change)| Ok((tag, change.into_tag_change()?))) + .collect::>()?, + adapter_ids: self + .adapter_ids + .into_iter() + .map(|(key, change)| Ok((key, change.into_adapter_id_change()?))) + .collect::>()?, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldChangeYaml { + intent: String, + #[serde(default)] + value: Option, + #[serde(default)] + expected_base: Option, +} + +impl FieldChangeYaml { + fn into_field_change(self) -> Result { + Ok(FieldChange { + intent: parse_change_intent(&self.intent)?, + value: self.value, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TagChangeYaml { + intent: String, + #[serde(default)] + expected_base: Option, +} + +impl TagChangeYaml { + fn into_tag_change(self) -> Result { + Ok(TagChange { + intent: parse_change_intent(&self.intent)?, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MediaChangeYaml { + intent: String, + #[serde(default)] + path: Option, + #[serde(default)] + sha256: Option, + #[serde(default)] + expected_base: Option, +} + +impl MediaChangeYaml { + fn into_media_change(self, id: StableId) -> Result { + let media = match (self.path, self.sha256) { + (Some(path), Some(sha256)) => Some(MediaReference { id, path, sha256 }), + _ => None, + }; + Ok(MediaChange { + intent: parse_change_intent(&self.intent)?, + media, + expected_base: self + .expected_base + .map(ExpectedBaseYaml::into_expected_base) + .transpose()?, + }) + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ExpectedBaseYaml { + Marker(String), + Value { value: String }, +} + +impl ExpectedBaseYaml { + fn into_expected_base(self) -> Result { + match self { + Self::Marker(marker) if marker == "entity_present" => Ok(ExpectedBase::EntityPresent), + Self::Marker(marker) => Err(CanonicalYamlError::InvalidExpectedBase(marker)), + Self::Value { value } => Ok(ExpectedBase::Value(value)), + } + } +} + +fn parse_overlay_kind(kind: &str) -> Result { + match kind { + "translation" => Ok(OverlayKind::Translation), + "extension" => Ok(OverlayKind::Extension), + "patch" => Ok(OverlayKind::Patch), + "personal" => Ok(OverlayKind::Personal), + other => Err(CanonicalYamlError::InvalidOverlayKind(other.to_owned())), + } +} + +fn parse_change_intent(intent: &str) -> Result { + match intent { + "add" => Ok(ChangeIntent::Add), + "merge" => Ok(ChangeIntent::Merge), + "replace" => Ok(ChangeIntent::Replace), + "remove" => Ok(ChangeIntent::Remove), + "override" => Ok(ChangeIntent::Override), + other => Err(CanonicalYamlError::InvalidChangeIntent(other.to_owned())), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CanonicalDeckYaml { + deck: DeckYaml, + note_types: BTreeMap, + notes: BTreeMap, + #[serde(default)] + media: BTreeMap, + #[serde(default)] + tombstones: Vec, +} + +impl CanonicalDeckYaml { + fn into_deck(self) -> Result { + Ok(CanonicalDeck { + id: sid(&self.deck.id)?, + name: self.deck.name, + description: self.deck.description, + variables: self.deck.variables, + note_types: self + .note_types + .into_iter() + .map(|(id, note_type)| { + let stable_id = sid(&id)?; + Ok((stable_id.clone(), note_type.into_note_type(stable_id)?)) + }) + .collect::>()?, + notes: self + .notes + .into_iter() + .map(|(id, note)| { + let stable_id = sid(&id)?; + Ok((stable_id.clone(), note.into_note(stable_id)?)) + }) + .collect::>()?, + media: self + .media + .into_iter() + .map(|(id, media)| { + let stable_id = sid(&id)?; + Ok((stable_id.clone(), media.into_media(stable_id))) + }) + .collect::>()?, + tombstones: self + .tombstones + .into_iter() + .map(|id| sid(&id)) + .collect::, _>>()?, + adapter_ids: adapter_ids_from_map(self.deck.adapter_ids), + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeckYaml { + id: String, + name: String, + description: String, + #[serde(default)] + variables: BTreeMap, + #[serde(default)] + adapter_ids: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoteTypeYaml { + name: String, + #[serde(default)] + variables: BTreeMap, + field_order: Vec, + fields: BTreeMap, + card_template_order: Vec, + card_templates: BTreeMap, + styling: String, + #[serde(default)] + adapter_ids: BTreeMap, +} + +impl NoteTypeYaml { + fn into_note_type(self, id: StableId) -> Result { + let fields = ordered_values("fields", self.fields, self.field_order, |id, field| { + Ok(FieldDefinition { + id, + name: field.name, + }) + })?; + let card_templates = ordered_values( + "card_templates", + self.card_templates, + self.card_template_order, + |id, template| { + Ok(CardTemplate { + id, + name: template.name, + variables: template.variables, + question_format: template.question_format, + answer_format: template.answer_format, + adapter_ids: adapter_ids_from_map(template.adapter_ids), + }) + }, + )?; + + Ok(NoteType { + id, + name: self.name, + variables: self.variables, + fields, + card_templates, + styling: self.styling, + adapter_ids: adapter_ids_from_map(self.adapter_ids), + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FieldYaml { + name: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CardTemplateYaml { + name: String, + #[serde(default)] + variables: BTreeMap, + question_format: String, + answer_format: String, + #[serde(default)] + adapter_ids: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NoteYaml { + note_type_id: String, + #[serde(default)] + variables: BTreeMap, + fields: BTreeMap, + #[serde(default)] + tags: BTreeSet, + #[serde(default)] + adapter_ids: BTreeMap, +} + +impl NoteYaml { + fn into_note(self, id: StableId) -> Result { + Ok(Note { + id, + note_type_id: sid(&self.note_type_id)?, + variables: self.variables, + fields: self + .fields + .into_iter() + .map(|(id, value)| Ok((sid(&id)?, value))) + .collect::>()?, + tags: self.tags, + adapter_ids: adapter_ids_from_map(self.adapter_ids), + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MediaYaml { + path: String, + sha256: String, +} + +impl MediaYaml { + fn into_media(self, id: StableId) -> MediaReference { + MediaReference { + id, + path: self.path, + sha256: self.sha256, + } + } +} + +fn ordered_values( + section: &'static str, + mut values: BTreeMap, + order: Vec, + convert: impl Fn(StableId, T) -> Result, +) -> Result, CanonicalYamlError> { + let mut ordered = Vec::new(); + for id in order { + let Some(value) = values.remove(&id) else { + return Err(CanonicalYamlError::MissingOrderedEntity { section, id }); + }; + ordered.push(convert(sid(&id)?, value)?); + } + + if let Some(id) = values.into_keys().next() { + return Err(CanonicalYamlError::UnorderedEntity { section, id }); + } + + Ok(ordered) +} + +fn adapter_ids_from_map(map: BTreeMap) -> AdapterIds { + let mut adapter_ids = AdapterIds::new(); + for (key, value) in map { + adapter_ids.insert(key, value); + } + adapter_ids +} + +fn sid(value: &str) -> Result { + StableId::new(value) +} diff --git a/crates/brain-brew-formats/src/crowdanki.rs b/crates/brain-brew-formats/src/crowdanki.rs new file mode 100644 index 0000000..ab337dc --- /dev/null +++ b/crates/brain-brew-formats/src/crowdanki.rs @@ -0,0 +1,1110 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + StableId, ValidationReport, VariableRenderReport, +}; +use serde::{Deserialize, Serialize}; + +/// Normalized CrowdAnki export artifacts and adapter report data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrowdAnkiExport { + pub deck_json: String, + pub omitted_tombstones: Vec, +} + +/// Export a CanonicalDeck to deterministic normalized CrowdAnki `deck.json` bytes. +pub fn export_deck(deck: &CanonicalDeck) -> Result { + deck.validate().map_err(CrowdAnkiError::Validation)?; + let rendered_deck = deck + .render_variables() + .map_err(CrowdAnkiError::VariableRender)?; + rendered_deck + .validate() + .map_err(CrowdAnkiError::Validation)?; + let deck = &rendered_deck; + + let note_models = deck + .note_types + .values() + .map(export_note_model) + .collect::, _>>()?; + + let note_type_uuids = deck + .note_types + .iter() + .map(|(id, note_type)| Ok((id.clone(), crowdanki_note_model_uuid(note_type)?))) + .collect::, CrowdAnkiError>>()?; + + let mut omitted_tombstones = Vec::new(); + let mut notes = Vec::new(); + for (id, note) in &deck.notes { + if deck.tombstones.contains(id) { + omitted_tombstones.push(id.clone()); + continue; + } + notes.push(export_note(note, deck, ¬e_type_uuids)?); + } + + let deck_config_uuid = crowdanki_deck_config_uuid(deck); + let deck_json = CrowdAnkiDeckJson { + type_: "Deck".to_owned(), + children: Vec::new(), + crowdanki_uuid: crowdanki_deck_uuid(deck), + deck_config_uuid: deck_config_uuid.clone(), + deck_configurations: vec![default_deck_config_json( + &deck_config_uuid, + &crowdanki_deck_config_name(deck), + )], + desc: deck.description.clone(), + dyn_: 0, + extend_new: 10, + extend_rev: 50, + media_files: deck + .media + .values() + .map(|media| media.path.clone()) + .collect::>(), + name: deck.name.clone(), + note_models, + notes, + }; + + let mut serialized = serde_json::to_string_pretty(&deck_json).map_err(CrowdAnkiError::Json)?; + serialized.push('\n'); + + Ok(CrowdAnkiExport { + deck_json: serialized, + omitted_tombstones, + }) +} + +/// Import normalized CrowdAnki `deck.json`, accepting generated stable IDs. +pub fn import_deck_accept_suggested_ids(input: &str) -> Result { + let deck_json: CrowdAnkiDeckJson = serde_json::from_str(input).map_err(CrowdAnkiError::Json)?; + deck_json.into_deck() +} + +/// Options for comparing generated CrowdAnki JSON with an expected oracle. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CrowdAnkiParityOptions { + /// JSON path globs explicitly allowed to differ. + pub allowed_path_globs: BTreeSet, +} + +/// A CrowdAnki parity comparison failure report. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct CrowdAnkiParityReport { + pub differences: Vec, +} + +/// One exact JSON difference between expected and actual CrowdAnki output. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct CrowdAnkiParityDifference { + pub path: String, + pub kind: CrowdAnkiParityDifferenceKind, + pub expected: Option, + pub actual: Option, +} + +/// The broad shape of a CrowdAnki JSON parity difference. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CrowdAnkiParityDifferenceKind { + MissingActual, + ExtraActual, + ValueMismatch, + LengthMismatch, +} + +/// Compare two CrowdAnki `deck.json` values exactly, with only explicit path allowlists. +pub fn compare_deck_json_values( + expected: &serde_json::Value, + actual: &serde_json::Value, + options: &CrowdAnkiParityOptions, +) -> Result<(), CrowdAnkiParityReport> { + let mut differences = Vec::new(); + compare_json_value(expected, actual, "$", options, &mut differences); + if differences.is_empty() { + Ok(()) + } else { + Err(CrowdAnkiParityReport { differences }) + } +} + +impl fmt::Display for CrowdAnkiParityReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{} CrowdAnki JSON difference(s)", self.differences.len())?; + let grouped_paths = repeated_difference_groups(&self.differences); + if !grouped_paths.is_empty() { + writeln!(f, "Repeated differences:")?; + for group in &grouped_paths { + writeln!( + f, + "{} × {} ({:?}): expected {}, actual {}", + group.count, + group.path_pattern, + group.kind, + json_value_summary(group.expected.as_ref()), + json_value_summary(group.actual.as_ref()) + )?; + } + } + let grouped_patterns = grouped_paths + .iter() + .map(|group| group.path_pattern.as_str()) + .collect::>(); + let mut shown = 0; + for difference in &self.differences { + if grouped_patterns.contains(normalize_repeated_path(&difference.path).as_str()) { + continue; + } + if shown >= 20 { + break; + } + writeln!( + f, + "{} ({:?}): expected {}, actual {}", + difference.path, + difference.kind, + json_value_summary(difference.expected.as_ref()), + json_value_summary(difference.actual.as_ref()) + )?; + shown += 1; + } + let ungrouped_count = self + .differences + .iter() + .filter(|difference| { + !grouped_patterns.contains(normalize_repeated_path(&difference.path).as_str()) + }) + .count(); + if ungrouped_count > shown { + writeln!(f, "... {} more", ungrouped_count - shown)?; + } + Ok(()) + } +} + +struct RepeatedDifferenceGroup { + path_pattern: String, + kind: CrowdAnkiParityDifferenceKind, + expected: Option, + actual: Option, + count: usize, +} + +fn repeated_difference_groups( + differences: &[CrowdAnkiParityDifference], +) -> Vec { + let mut groups = BTreeMap::<(String, String, String, String), RepeatedDifferenceGroup>::new(); + for difference in differences { + let path_pattern = normalize_repeated_path(&difference.path); + if path_pattern == difference.path { + continue; + } + let key = ( + path_pattern.clone(), + format!("{:?}", difference.kind), + json_value_summary(difference.expected.as_ref()), + json_value_summary(difference.actual.as_ref()), + ); + groups + .entry(key) + .and_modify(|group| group.count += 1) + .or_insert_with(|| RepeatedDifferenceGroup { + path_pattern, + kind: difference.kind.clone(), + expected: difference.expected.clone(), + actual: difference.actual.clone(), + count: 1, + }); + } + + groups + .into_values() + .filter(|group| group.count > 1) + .collect() +} + +fn normalize_repeated_path(path: &str) -> String { + let mut normalized = String::new(); + let mut chars = path.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '[' { + normalized.push(ch); + continue; + } + + let mut bracket = String::from("["); + for bracket_ch in chars.by_ref() { + bracket.push(bracket_ch); + if bracket_ch == ']' { + break; + } + } + if bracket + .chars() + .skip(1) + .all(|ch| ch.is_ascii_digit() || ch == ']') + || bracket.contains('=') + { + normalized.push_str("[*]"); + } else { + normalized.push_str(&bracket); + } + } + normalized +} + +fn compare_json_value( + expected: &serde_json::Value, + actual: &serde_json::Value, + path: &str, + options: &CrowdAnkiParityOptions, + differences: &mut Vec, +) { + if expected == actual || is_allowed_parity_path(options, path) { + return; + } + + match (expected, actual) { + (serde_json::Value::Object(expected), serde_json::Value::Object(actual)) => { + let keys = expected + .keys() + .chain(actual.keys()) + .collect::>(); + for key in keys { + let child_path = json_path_key(path, key); + if is_allowed_parity_path(options, &child_path) { + continue; + } + match (expected.get(key), actual.get(key)) { + (Some(expected), Some(actual)) => { + compare_json_value(expected, actual, &child_path, options, differences); + } + (Some(expected), None) => differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::MissingActual, + expected: Some(expected.clone()), + actual: None, + }), + (None, Some(actual)) => differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::ExtraActual, + expected: None, + actual: Some(actual.clone()), + }), + (None, None) => {} + } + } + } + (serde_json::Value::Array(expected), serde_json::Value::Array(actual)) => { + if compare_json_array_by_identity(expected, actual, path, options, differences) { + return; + } + for index in 0..expected.len().min(actual.len()) { + let child_path = format!("{path}[{index}]"); + compare_json_value( + &expected[index], + &actual[index], + &child_path, + options, + differences, + ); + } + if expected.len() != actual.len() { + let length_path = format!("{path}.length"); + if !is_allowed_parity_path(options, &length_path) { + differences.push(CrowdAnkiParityDifference { + path: length_path, + kind: CrowdAnkiParityDifferenceKind::LengthMismatch, + expected: Some(serde_json::json!(expected.len())), + actual: Some(serde_json::json!(actual.len())), + }); + } + } + for (index, value) in expected.iter().enumerate().skip(actual.len()) { + let child_path = format!("{path}[{index}]"); + if !is_allowed_parity_path(options, &child_path) { + differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::MissingActual, + expected: Some(value.clone()), + actual: None, + }); + } + } + for (index, value) in actual.iter().enumerate().skip(expected.len()) { + let child_path = format!("{path}[{index}]"); + if !is_allowed_parity_path(options, &child_path) { + differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::ExtraActual, + expected: None, + actual: Some(value.clone()), + }); + } + } + } + _ => differences.push(CrowdAnkiParityDifference { + path: path.to_owned(), + kind: CrowdAnkiParityDifferenceKind::ValueMismatch, + expected: Some(expected.clone()), + actual: Some(actual.clone()), + }), + } +} + +fn compare_json_array_by_identity( + expected: &[serde_json::Value], + actual: &[serde_json::Value], + path: &str, + options: &CrowdAnkiParityOptions, + differences: &mut Vec, +) -> bool { + let Some(identity) = array_identity(path) else { + return false; + }; + + let Some(expected_by_key) = array_by_identity(expected, identity) else { + return false; + }; + let Some(actual_by_key) = array_by_identity(actual, identity) else { + return false; + }; + + let keys = expected_by_key + .keys() + .chain(actual_by_key.keys()) + .collect::>(); + for key in keys { + let child_path = format!("{path}[{}={}]", identity.name, json_path_label(key)); + if is_allowed_parity_path(options, &child_path) { + continue; + } + match (expected_by_key.get(key), actual_by_key.get(key)) { + (Some(expected), Some(actual)) => { + compare_json_value(expected, actual, &child_path, options, differences); + } + (Some(expected), None) => differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::MissingActual, + expected: Some((*expected).clone()), + actual: None, + }), + (None, Some(actual)) => differences.push(CrowdAnkiParityDifference { + path: child_path, + kind: CrowdAnkiParityDifferenceKind::ExtraActual, + expected: None, + actual: Some((*actual).clone()), + }), + (None, None) => {} + } + } + + true +} + +#[derive(Clone, Copy)] +struct ArrayIdentity { + name: &'static str, + value: fn(&serde_json::Value) -> Option, +} + +fn array_identity(path: &str) -> Option { + match path { + "$.notes" => Some(ArrayIdentity { + name: "guid", + value: |value| value.get("guid")?.as_str().map(str::to_owned), + }), + "$.note_models" => Some(ArrayIdentity { + name: "model", + value: |value| { + value + .get("crowdanki_uuid") + .and_then(serde_json::Value::as_str) + .or_else(|| value.get("name").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + }, + }), + path if path.ends_with(".flds") => Some(ArrayIdentity { + name: "name", + value: |value| value.get("name")?.as_str().map(str::to_owned), + }), + path if path.ends_with(".tmpls") => Some(ArrayIdentity { + name: "template", + value: |value| { + value + .get("ord") + .and_then(serde_json::Value::as_i64) + .map(|ord| ord.to_string()) + .or_else(|| { + value + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + }, + }), + _ => None, + } +} + +fn array_by_identity( + values: &[serde_json::Value], + identity: ArrayIdentity, +) -> Option> { + let mut by_key = BTreeMap::new(); + for value in values { + let key = (identity.value)(value)?; + if by_key.insert(key, value).is_some() { + return None; + } + } + Some(by_key) +} + +fn json_path_label(value: &str) -> String { + serde_json::to_string(value).expect("serializing a JSON path label cannot fail") +} + +fn is_allowed_parity_path(options: &CrowdAnkiParityOptions, path: &str) -> bool { + options + .allowed_path_globs + .iter() + .any(|pattern| glob_matches(pattern, path)) +} + +fn glob_matches(pattern: &str, value: &str) -> bool { + fn matches_parts(pattern: &[u8], value: &[u8]) -> bool { + match pattern.split_first() { + None => value.is_empty(), + Some((&b'*', rest)) => { + matches_parts(rest, value) + || (!value.is_empty() && matches_parts(pattern, &value[1..])) + } + Some((&expected, rest)) => value.split_first().is_some_and(|(&actual, rest_value)| { + actual == expected && matches_parts(rest, rest_value) + }), + } + } + + matches_parts(pattern.as_bytes(), value.as_bytes()) +} + +fn json_path_key(parent: &str, key: &str) -> String { + if key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + format!("{parent}.{key}") + } else { + format!( + "{parent}[{}]", + serde_json::to_string(key).expect("serializing a JSON key cannot fail") + ) + } +} + +fn json_value_summary(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return "".to_owned(); + }; + let mut summary = serde_json::to_string(value).expect("serializing JSON value cannot fail"); + if summary.len() > 120 { + summary.truncate(117); + summary.push_str("..."); + } + summary +} + +fn export_note_model(note_type: &NoteType) -> Result { + Ok(CrowdAnkiNoteModelJson { + kind: "NoteModel".to_owned(), + crowdanki_uuid: crowdanki_note_model_uuid(note_type)?, + css: note_type.styling.clone(), + flds: note_type + .fields + .iter() + .enumerate() + .map(|(ord, field)| CrowdAnkiFieldJson { + font: "Arial".to_owned(), + media: Vec::new(), + name: field.name.clone(), + ord, + rtl: false, + size: 20, + sticky: false, + }) + .collect(), + latex_post: "\\end{document}".to_owned(), + latex_pre: default_latex_pre(), + latex_svg: false, + name: note_type.name.clone(), + req: Vec::new(), + sortf: 0, + tags: Vec::new(), + tmpls: note_type + .card_templates + .iter() + .enumerate() + .map(|(ord, template)| CrowdAnkiTemplateJson { + afmt: template.answer_format.clone(), + bafmt: String::new(), + bfont: Some(String::new()), + bqfmt: String::new(), + bsize: Some(0), + did: None, + name: template.name.clone(), + ord, + qfmt: template.question_format.clone(), + scratch_pad: Some(0), + }) + .collect(), + model_type: 0, + vers: Vec::new(), + }) +} + +fn export_note( + note: &Note, + deck: &CanonicalDeck, + note_type_uuids: &BTreeMap, +) -> Result { + let note_type = deck.note_types.get(¬e.note_type_id).ok_or_else(|| { + CrowdAnkiError::Unsupported(format!( + "note {} references missing note type {}", + note.id, note.note_type_id + )) + })?; + let note_model_uuid = note_type_uuids + .get(¬e.note_type_id) + .cloned() + .expect("note type uuid was precomputed"); + + let fields = note_type + .fields + .iter() + .map(|field| note.fields.get(&field.id).cloned().unwrap_or_default()) + .collect(); + + Ok(CrowdAnkiNoteJson { + type_: "Note".to_owned(), + data: String::new(), + fields, + flags: 0, + guid: crowdanki_note_guid(note), + note_model_uuid, + tags: note.tags.iter().cloned().collect(), + }) +} + +fn crowdanki_deck_uuid(deck: &CanonicalDeck) -> String { + deck.adapter_ids + .get("crowdanki:uuid") + .map(str::to_owned) + .unwrap_or_else(|| deck.id.to_string()) +} + +fn crowdanki_deck_config_uuid(deck: &CanonicalDeck) -> String { + deck.adapter_ids + .get("crowdanki:deck_config_uuid") + .map(str::to_owned) + .unwrap_or_else(|| format!("{}:deck-config", deck.id)) +} + +fn crowdanki_deck_config_name(deck: &CanonicalDeck) -> String { + deck.adapter_ids + .get("crowdanki:deck_config_name") + .map(str::to_owned) + .unwrap_or_else(|| deck.name.clone()) +} + +fn crowdanki_note_model_uuid(note_type: &NoteType) -> Result { + note_type + .adapter_ids + .get("crowdanki:uuid") + .map(str::to_owned) + .ok_or_else(|| { + CrowdAnkiError::Unsupported(format!( + "note type {} is missing crowdanki:uuid adapter id", + note_type.id + )) + }) +} + +fn crowdanki_note_guid(note: &Note) -> String { + note.adapter_ids + .get("crowdanki:guid") + .map(str::to_owned) + .unwrap_or_else(|| note.id.to_string()) +} + +fn default_latex_pre() -> String { + "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n" + .to_owned() +} + +fn default_deck_config_json(uuid: &str, name: &str) -> serde_json::Value { + serde_json::json!({ + "__type__": "DeckConfig", + "crowdanki_uuid": uuid, + "name": name, + "autoplay": false, + "dyn": false, + "lapse": { + "delays": [10], + "leechAction": 0, + "leechFails": 8, + "minInt": 1, + "mult": 0, + }, + "maxTaken": 60, + "new": { + "bury": true, + "delays": [1, 10], + "initialFactor": 2500, + "ints": [1, 4, 7], + "order": 0, + "perDay": 15, + "separate": true, + }, + "replayq": true, + "rev": { + "bury": true, + "ease4": 1.3, + "fuzz": 0.05, + "ivlFct": 1, + "maxIvl": 36500, + "minSpace": 1, + "perDay": 100, + }, + "timer": 0, + }) +} + +fn validate_supported_deck_configurations( + uuid: &str, + configurations: &[serde_json::Value], +) -> Result { + if configurations.len() != 1 { + return Err(CrowdAnkiError::Unsupported(format!( + "expected one default deck configuration, found {}", + configurations.len() + ))); + } + let Some(name) = configurations[0] + .get("name") + .and_then(serde_json::Value::as_str) + else { + return Err(CrowdAnkiError::Unsupported( + "deck configuration is missing a name".to_owned(), + )); + }; + let expected = default_deck_config_json(uuid, name); + if configurations[0] != expected { + return Err(CrowdAnkiError::Unsupported( + "non-default deck configurations are not modeled yet".to_owned(), + )); + } + Ok(name.to_owned()) +} + +#[derive(Debug)] +pub enum CrowdAnkiError { + Json(serde_json::Error), + StableId(String), + Unsupported(String), + Validation(ValidationReport), + VariableRender(VariableRenderReport), +} + +impl fmt::Display for CrowdAnkiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Json(error) => write!(f, "CrowdAnki JSON error: {error}"), + Self::StableId(id) => write!(f, "generated invalid stable id {id:?}"), + Self::Unsupported(message) => write!(f, "unsupported CrowdAnki data: {message}"), + Self::Validation(report) => write!(f, "imported deck failed validation: {report}"), + Self::VariableRender(report) => write!(f, "deck variable rendering failed: {report}"), + } + } +} + +impl std::error::Error for CrowdAnkiError {} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiDeckJson { + #[serde(rename = "__type__")] + type_: String, + children: Vec, + crowdanki_uuid: String, + deck_config_uuid: String, + deck_configurations: Vec, + desc: String, + #[serde(rename = "dyn")] + dyn_: i64, + #[serde(rename = "extendNew")] + extend_new: i64, + #[serde(rename = "extendRev")] + extend_rev: i64, + media_files: Vec, + name: String, + note_models: Vec, + notes: Vec, +} + +impl CrowdAnkiDeckJson { + fn into_deck(self) -> Result { + if self.type_ != "Deck" { + return Err(CrowdAnkiError::Unsupported(format!( + "expected __type__ Deck, found {}", + self.type_ + ))); + } + if !self.children.is_empty() { + return Err(CrowdAnkiError::Unsupported( + "child decks are not modeled yet".to_owned(), + )); + } + if self.dyn_ != 0 || self.extend_new != 10 || self.extend_rev != 50 { + return Err(CrowdAnkiError::Unsupported(format!( + "non-default deck scheduling header is not modeled yet (dyn={}, extendNew={}, extendRev={})", + self.dyn_, self.extend_new, self.extend_rev + ))); + } + let deck_config_name = validate_supported_deck_configurations( + &self.deck_config_uuid, + &self.deck_configurations, + )?; + + let deck_id = prefixed_stable_id("deck", &self.name)?; + let mut deck_adapter_ids = AdapterIds::new(); + deck_adapter_ids.insert("crowdanki:uuid", self.crowdanki_uuid); + deck_adapter_ids.insert("crowdanki:deck_config_uuid", self.deck_config_uuid); + deck_adapter_ids.insert("crowdanki:deck_config_name", deck_config_name); + + let mut note_type_by_uuid = BTreeMap::new(); + let mut note_types = BTreeMap::new(); + for note_model in self.note_models { + let (uuid, id, note_type) = note_model.into_note_type()?; + note_type_by_uuid.insert(uuid, id.clone()); + note_types.insert(id, note_type); + } + + let notes = self + .notes + .into_iter() + .map(|note| note.into_note(¬e_types, ¬e_type_by_uuid)) + .collect::, _>>()?; + + let media = self + .media_files + .into_iter() + .map(|path| { + let id = prefixed_stable_id("media", &path)?; + Ok(( + id.clone(), + MediaReference { + id, + path, + sha256: String::new(), + }, + )) + }) + .collect::, CrowdAnkiError>>()?; + + let deck = CanonicalDeck { + id: deck_id, + name: self.name, + description: self.desc, + note_types, + notes, + media, + tombstones: BTreeSet::new(), + variables: BTreeMap::new(), + adapter_ids: deck_adapter_ids, + }; + deck.validate().map_err(CrowdAnkiError::Validation)?; + Ok(deck) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiNoteModelJson { + #[serde(rename = "__type__")] + kind: String, + crowdanki_uuid: String, + css: String, + flds: Vec, + #[serde(rename = "latexPost")] + latex_post: String, + #[serde(rename = "latexPre")] + latex_pre: String, + #[serde(rename = "latexsvg")] + latex_svg: bool, + name: String, + req: Vec, + sortf: usize, + tags: Vec, + tmpls: Vec, + #[serde(rename = "type")] + model_type: i64, + vers: Vec, +} + +impl CrowdAnkiNoteModelJson { + fn validate_supported_defaults(&self) -> Result<(), CrowdAnkiError> { + if self.latex_post != "\\end{document}" + || self.latex_pre != default_latex_pre() + || self.latex_svg + || !self.req.is_empty() + || self.sortf != 0 + || !self.tags.is_empty() + || !self.vers.is_empty() + { + return Err(CrowdAnkiError::Unsupported(format!( + "note model {} has non-default CrowdAnki options that are not modeled yet", + self.name + ))); + } + Ok(()) + } + + fn into_note_type(self) -> Result<(String, StableId, NoteType), CrowdAnkiError> { + if self.kind != "NoteModel" { + return Err(CrowdAnkiError::Unsupported(format!( + "expected note model __type__ NoteModel, found {}", + self.kind + ))); + } + if self.model_type != 0 { + return Err(CrowdAnkiError::Unsupported(format!( + "only standard note models are supported, found type {}", + self.model_type + ))); + } + self.validate_supported_defaults()?; + + let id = prefixed_stable_id("note-type", &self.name)?; + let mut adapter_ids = AdapterIds::new(); + adapter_ids.insert("crowdanki:uuid", self.crowdanki_uuid.clone()); + + let fields = self + .flds + .into_iter() + .enumerate() + .map(|(index, field)| { + field.validate_supported_defaults(index)?; + Ok(FieldDefinition { + id: prefixed_stable_id("field", &field.name)?, + name: field.name, + }) + }) + .collect::, CrowdAnkiError>>()?; + + let mut templates = self.tmpls; + templates.sort_by_key(|template| template.ord); + let card_templates = templates + .into_iter() + .map(|template| { + template.validate_supported_defaults()?; + Ok(CardTemplate { + id: prefixed_stable_id("template", &template.name)?, + name: template.name, + variables: BTreeMap::new(), + question_format: template.qfmt, + answer_format: template.afmt, + adapter_ids: AdapterIds::new(), + }) + }) + .collect::, CrowdAnkiError>>()?; + + let note_type = NoteType { + id: id.clone(), + name: self.name, + variables: BTreeMap::new(), + fields, + card_templates, + styling: self.css, + adapter_ids, + }; + + Ok((self.crowdanki_uuid, id, note_type)) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiFieldJson { + font: String, + media: Vec, + name: String, + ord: usize, + rtl: bool, + size: usize, + sticky: bool, +} + +impl CrowdAnkiFieldJson { + fn validate_supported_defaults(&self, expected_ord: usize) -> Result<(), CrowdAnkiError> { + if self.font != "Arial" + || !self.media.is_empty() + || self.ord != expected_ord + || self.rtl + || self.size != 20 + || self.sticky + { + return Err(CrowdAnkiError::Unsupported(format!( + "field {} has non-default CrowdAnki options that are not modeled yet", + self.name + ))); + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiTemplateJson { + afmt: String, + bafmt: String, + bfont: Option, + bqfmt: String, + bsize: Option, + did: Option, + name: String, + ord: usize, + qfmt: String, + #[serde(rename = "scratchPad")] + scratch_pad: Option, +} + +impl CrowdAnkiTemplateJson { + fn validate_supported_defaults(&self) -> Result<(), CrowdAnkiError> { + if self.bfont.as_deref().unwrap_or_default() != "" + || self.bsize.unwrap_or_default() != 0 + || self.scratch_pad.unwrap_or_default() != 0 + { + return Err(CrowdAnkiError::Unsupported(format!( + "card template {} has non-default browser options that are not modeled yet", + self.name + ))); + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiNoteJson { + #[serde(rename = "__type__")] + type_: String, + data: String, + fields: Vec, + flags: i64, + guid: String, + note_model_uuid: String, + tags: Vec, +} + +impl CrowdAnkiNoteJson { + fn into_note( + self, + note_types: &BTreeMap, + note_type_by_uuid: &BTreeMap, + ) -> Result<(StableId, Note), CrowdAnkiError> { + if self.type_ != "Note" { + return Err(CrowdAnkiError::Unsupported(format!( + "expected note __type__ Note, found {}", + self.type_ + ))); + } + if !self.data.is_empty() || self.flags != 0 { + return Err(CrowdAnkiError::Unsupported(format!( + "note {} has non-default data/flags that are not modeled yet", + self.guid + ))); + } + let note_type_id = note_type_by_uuid + .get(&self.note_model_uuid) + .ok_or_else(|| { + CrowdAnkiError::Unsupported(format!( + "note references missing note_model_uuid {}", + self.note_model_uuid + )) + })? + .clone(); + let note_type = note_types + .get(¬e_type_id) + .expect("note type id came from note type map"); + if self.fields.len() != note_type.fields.len() { + return Err(CrowdAnkiError::Unsupported(format!( + "note {} has {} fields but note type {} has {} fields", + self.guid, + self.fields.len(), + note_type.id, + note_type.fields.len() + ))); + } + + let first_field = self + .fields + .first() + .map(String::as_str) + .unwrap_or(&self.guid); + let id = prefixed_stable_id("note", first_field)?; + let fields = note_type + .fields + .iter() + .zip(self.fields) + .map(|(field, value)| (field.id.clone(), value)) + .collect(); + let mut adapter_ids = AdapterIds::new(); + adapter_ids.insert("crowdanki:guid", self.guid); + + Ok(( + id.clone(), + Note { + id, + note_type_id, + variables: BTreeMap::new(), + fields, + tags: self.tags.into_iter().collect(), + adapter_ids, + }, + )) + } +} + +fn prefixed_stable_id(prefix: &str, source: &str) -> Result { + let slug = slugify(source); + let id = format!("{prefix}.{slug}"); + StableId::new(&id).map_err(|_| CrowdAnkiError::StableId(id)) +} + +fn slugify(source: &str) -> String { + let mut slug = String::new(); + let mut last_was_separator = false; + for ch in source.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_was_separator = false; + } else if !last_was_separator && !slug.is_empty() { + slug.push('-'); + last_was_separator = true; + } + } + while slug.ends_with('-') { + slug.pop(); + } + if slug.is_empty() { + "unnamed".to_owned() + } else { + slug + } +} diff --git a/crates/brain-brew-formats/src/lib.rs b/crates/brain-brew-formats/src/lib.rs new file mode 100644 index 0000000..0a3fe38 --- /dev/null +++ b/crates/brain-brew-formats/src/lib.rs @@ -0,0 +1,31 @@ +//! Reusable format codecs for Brain Brew. +//! +//! This crate contains strict CanonicalDeck YAML support, CrowdAnki codecs, and +//! media helpers. It depends on `brain-brew-core`, but does not own domain +//! behavior. + +pub mod canonical_yaml; +pub mod crowdanki; +pub mod lockfile; +pub mod manifest; +pub mod media; + +pub use brain_brew_core as core; + +/// Name of the formats crate. +pub const CRATE_NAME: &str = env!("CARGO_PKG_NAME"); + +#[cfg(test)] +mod tests { + use super::{CRATE_NAME, core}; + + #[test] + fn exposes_formats_crate_name() { + assert_eq!(CRATE_NAME, "brain-brew-formats"); + } + + #[test] + fn can_reach_core_crate() { + assert_eq!(core::CRATE_NAME, "brain-brew-core"); + } +} diff --git a/crates/brain-brew-formats/src/lockfile.rs b/crates/brain-brew-formats/src/lockfile.rs new file mode 100644 index 0000000..929401e --- /dev/null +++ b/crates/brain-brew-formats/src/lockfile.rs @@ -0,0 +1,238 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +/// Reproducible source lock for a set of Federated Deck package inputs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederationLock { + pub version: u32, + pub packages: BTreeMap, +} + +/// One locked package input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockedPackage { + pub manifest: String, + pub package: LockedPackageMetadata, + pub original: Option, + pub locked: LockedSource, +} + +/// Package metadata captured at lock time. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockedPackageMetadata { + pub version: String, +} + +/// Original or locked source reference for one package input. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LockedSource { + pub source_type: String, + pub url: Option, + pub path: Option, + pub reference: Option, + pub rev: Option, + pub nar_hash: Option, +} + +/// Parse a federation lock file from strict YAML. +pub fn from_str(input: &str) -> Result { + let yaml: FederationLockYaml = serde_yaml::from_str(input).map_err(LockfileError::Parse)?; + yaml.into_lock() +} + +/// Parse and re-emit a federation lock file using deterministic formatting. +pub fn format_str(input: &str) -> Result { + Ok(to_string(&from_str(input)?)) +} + +/// Emit a federation lock file as deterministic YAML. +pub fn to_string(lock: &FederationLock) -> String { + let mut out = String::new(); + out.push_str(&format!("version: {}\n", lock.version)); + if lock.packages.is_empty() { + out.push_str("packages: {}\n"); + return out; + } + out.push_str("packages:\n"); + for (id, package) in &lock.packages { + out.push_str(&format!(" {id}:\n")); + out.push_str(&format!( + " manifest: {}\n", + yaml_scalar(&package.manifest) + )); + out.push_str(" package:\n"); + out.push_str(&format!( + " version: {}\n", + yaml_scalar(&package.package.version) + )); + if let Some(original) = &package.original { + out.push_str(" original:\n"); + write_source(&mut out, " ", original); + } + out.push_str(" locked:\n"); + write_source(&mut out, " ", &package.locked); + } + out +} + +fn write_source(out: &mut String, indent: &str, source: &LockedSource) { + out.push_str(&format!( + "{indent}type: {}\n", + yaml_scalar(&source.source_type) + )); + if let Some(url) = &source.url { + out.push_str(&format!("{indent}url: {}\n", yaml_scalar(url))); + } + if let Some(path) = &source.path { + out.push_str(&format!("{indent}path: {}\n", yaml_scalar(path))); + } + if let Some(reference) = &source.reference { + out.push_str(&format!("{indent}ref: {}\n", yaml_scalar(reference))); + } + if let Some(rev) = &source.rev { + out.push_str(&format!("{indent}rev: {}\n", yaml_scalar(rev))); + } + if let Some(nar_hash) = &source.nar_hash { + out.push_str(&format!("{indent}nar_hash: {}\n", yaml_scalar(nar_hash))); + } +} + +fn yaml_scalar(value: &str) -> String { + if !value.is_empty() + && !value.starts_with([ + ' ', '-', '?', ':', '@', '`', '&', '*', '!', '|', '>', '#', '{', '[', ',', + ]) + && !value.ends_with(' ') + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, ' ' | '.' | ',' | '_' | '-' | '/' | ':') + }) + && !value.chars().all(|ch| ch.is_ascii_digit()) + && !matches!( + value, + "true" | "false" | "True" | "False" | "TRUE" | "FALSE" | "null" | "Null" | "NULL" + ) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "''")) + } +} + +#[derive(Debug)] +pub enum LockfileError { + Parse(serde_yaml::Error), + UnsupportedVersion(u32), + MissingLockedSource(String), +} + +impl fmt::Display for LockfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(error) => write!(f, "failed to parse lock YAML: {error}"), + Self::UnsupportedVersion(version) => { + write!(f, "unsupported federation lock version {version}") + } + Self::MissingLockedSource(package) => { + write!(f, "locked package {package} must include a locked source") + } + } + } +} + +impl std::error::Error for LockfileError {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FederationLockYaml { + version: u32, + #[serde(default)] + packages: BTreeMap, +} + +impl FederationLockYaml { + fn into_lock(self) -> Result { + if self.version != 1 { + return Err(LockfileError::UnsupportedVersion(self.version)); + } + let packages = self + .packages + .into_iter() + .map(|(id, package)| Ok((id.clone(), package.into_locked_package(id)?))) + .collect::>()?; + Ok(FederationLock { + version: self.version, + packages, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LockedPackageYaml { + manifest: String, + package: LockedPackageMetadataYaml, + #[serde(default)] + original: Option, + #[serde(default)] + locked: Option, +} + +impl LockedPackageYaml { + fn into_locked_package(self, id: String) -> Result { + let Some(locked) = self.locked else { + return Err(LockfileError::MissingLockedSource(id)); + }; + Ok(LockedPackage { + manifest: self.manifest, + package: self.package.into_metadata(), + original: self.original.map(LockedSourceYaml::into_source), + locked: locked.into_source(), + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LockedPackageMetadataYaml { + version: String, +} + +impl LockedPackageMetadataYaml { + fn into_metadata(self) -> LockedPackageMetadata { + LockedPackageMetadata { + version: self.version, + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LockedSourceYaml { + #[serde(rename = "type")] + source_type: String, + #[serde(default)] + url: Option, + #[serde(default)] + path: Option, + #[serde(default, rename = "ref")] + reference: Option, + #[serde(default)] + rev: Option, + #[serde(default)] + nar_hash: Option, +} + +impl LockedSourceYaml { + fn into_source(self) -> LockedSource { + LockedSource { + source_type: self.source_type, + url: self.url, + path: self.path, + reference: self.reference, + rev: self.rev, + nar_hash: self.nar_hash, + } + } +} diff --git a/crates/brain-brew-formats/src/manifest.rs b/crates/brain-brew-formats/src/manifest.rs new file mode 100644 index 0000000..3aa8af4 --- /dev/null +++ b/crates/brain-brew-formats/src/manifest.rs @@ -0,0 +1,405 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::Deserialize; + +/// Public manifest for a Federated Deck workspace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederatedDeckManifest { + pub package: Option, + pub base: String, + pub overlays: BTreeMap, + pub targets: BTreeMap, +} + +impl FederatedDeckManifest { + /// Expand one target into the deterministic overlay stack implied by its dependencies. + pub fn expand_target(&self, target: &str) -> Result { + let target_entry = self + .targets + .get(target) + .ok_or_else(|| ManifestError::MissingTarget(target.to_owned()))?; + let mut visited = BTreeSet::new(); + let mut stack = Vec::new(); + let mut overlays = Vec::new(); + + for overlay in &target_entry.overlays { + self.visit_overlay(overlay, &mut visited, &mut stack, &mut overlays)?; + } + + Ok(ExpandedTarget { + name: target.to_owned(), + base: self.base.clone(), + extends: target_entry.extends.clone(), + overlays, + }) + } + + fn visit_overlay( + &self, + overlay: &str, + visited: &mut BTreeSet, + stack: &mut Vec, + expanded: &mut Vec, + ) -> Result<(), ManifestError> { + if visited.contains(overlay) { + return Ok(()); + } + if stack.iter().any(|candidate| candidate == overlay) { + let mut cycle = stack.clone(); + cycle.push(overlay.to_owned()); + return Err(ManifestError::DependencyCycle(cycle)); + } + + let entry = self + .overlays + .get(overlay) + .ok_or_else(|| ManifestError::MissingOverlay(overlay.to_owned()))?; + stack.push(overlay.to_owned()); + for dependency in &entry.depends_on { + self.visit_overlay(dependency, visited, stack, expanded)?; + } + stack.pop(); + + visited.insert(overlay.to_owned()); + expanded.push(ExpandedOverlay { + id: overlay.to_owned(), + file: entry.file.clone(), + }); + Ok(()) + } +} + +/// Package identity and dependency metadata for a Federated Deck workspace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageMetadata { + pub id: String, + pub version: String, + pub compatible_base_versions: Vec, + pub depends_on: Vec, +} + +/// One overlay available in a Federated Deck manifest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OverlayManifestEntry { + pub file: String, + pub kind: Option, + pub depends_on: Vec, +} + +/// One named composition goal in a Federated Deck manifest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildTarget { + pub extends: Option, + pub overlays: Vec, + pub exports: TargetExports, +} + +/// Optional reproducibility checks and default outputs for one target. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TargetExports { + pub crowdanki: Option, +} + +impl TargetExports { + pub fn is_empty(&self) -> bool { + self.crowdanki.is_none() + } +} + +/// CrowdAnki export defaults and golden-file verification for one target. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrowdAnkiTargetExport { + pub out: Option, + pub golden: Option, + pub golden_allowlist: Vec, +} + +/// Expanded target ready for filesystem loading and composition. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpandedTarget { + pub name: String, + pub base: String, + pub extends: Option, + pub overlays: Vec, +} + +/// One overlay in an expanded deterministic overlay stack. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpandedOverlay { + pub id: String, + pub file: String, +} + +/// Parse a Federated Deck manifest from strict YAML. +pub fn from_str(input: &str) -> Result { + let yaml: ManifestYaml = serde_yaml::from_str(input).map_err(ManifestError::Parse)?; + Ok(yaml.into_manifest()) +} + +/// Parse and re-emit a Federated Deck manifest using deterministic formatting. +pub fn format_str(input: &str) -> Result { + let manifest = from_str(input)?; + Ok(to_string(&manifest)) +} + +/// Emit a Federated Deck manifest as deterministic YAML. +pub fn to_string(manifest: &FederatedDeckManifest) -> String { + let mut out = String::new(); + if let Some(package) = &manifest.package { + out.push_str("package:\n"); + out.push_str(&format!(" id: {}\n", yaml_scalar(&package.id))); + out.push_str(&format!(" version: {}\n", yaml_scalar(&package.version))); + if !package.compatible_base_versions.is_empty() { + out.push_str(" compatible_base_versions:\n"); + for version in &package.compatible_base_versions { + out.push_str(&format!(" - {}\n", yaml_scalar(version))); + } + } + if !package.depends_on.is_empty() { + out.push_str(" depends_on:\n"); + for dependency in &package.depends_on { + out.push_str(&format!(" - {}\n", yaml_scalar(dependency))); + } + } + } + out.push_str(&format!("base: {}\n", yaml_scalar(&manifest.base))); + + if manifest.overlays.is_empty() { + out.push_str("overlays: {}\n"); + } else { + out.push_str("overlays:\n"); + for (id, overlay) in &manifest.overlays { + out.push_str(&format!(" {id}:\n")); + out.push_str(&format!(" file: {}\n", yaml_scalar(&overlay.file))); + if let Some(kind) = &overlay.kind { + out.push_str(&format!(" kind: {}\n", yaml_scalar(kind))); + } + if !overlay.depends_on.is_empty() { + out.push_str(" depends_on:\n"); + for dependency in &overlay.depends_on { + out.push_str(&format!(" - {}\n", yaml_scalar(dependency))); + } + } + } + } + + if manifest.targets.is_empty() { + out.push_str("targets: {}\n"); + } else { + out.push_str("targets:\n"); + for (id, target) in &manifest.targets { + out.push_str(&format!(" {id}:\n")); + if let Some(extends) = &target.extends { + out.push_str(&format!(" extends: {}\n", yaml_scalar(extends))); + } + if target.overlays.is_empty() { + out.push_str(" overlays: []\n"); + } else { + out.push_str(" overlays:\n"); + for overlay in &target.overlays { + out.push_str(&format!(" - {}\n", yaml_scalar(overlay))); + } + } + if !target.exports.is_empty() { + out.push_str(" exports:\n"); + if let Some(export) = &target.exports.crowdanki { + out.push_str(" crowdanki:\n"); + if let Some(path) = &export.out { + out.push_str(&format!(" out: {}\n", yaml_scalar(path))); + } + if let Some(path) = &export.golden { + out.push_str(&format!(" golden: {}\n", yaml_scalar(path))); + } + if !export.golden_allowlist.is_empty() { + out.push_str(" golden_allowlist:\n"); + for path in &export.golden_allowlist { + out.push_str(&format!(" - {}\n", yaml_scalar(path))); + } + } + } + } + } + } + out +} + +fn yaml_scalar(value: &str) -> String { + if !value.is_empty() + && !value.starts_with([ + ' ', '-', '?', ':', '@', '`', '&', '*', '!', '|', '>', '#', '{', '[', ',', + ]) + && !value.ends_with(' ') + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() || matches!(ch, ' ' | '.' | ',' | '_' | '-' | '/' | ':') + }) + && !value.chars().all(|ch| ch.is_ascii_digit()) + && !matches!( + value, + "true" | "false" | "True" | "False" | "TRUE" | "FALSE" | "null" | "Null" | "NULL" + ) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "''")) + } +} + +#[derive(Debug)] +pub enum ManifestError { + Parse(serde_yaml::Error), + MissingTarget(String), + MissingOverlay(String), + DependencyCycle(Vec), +} + +impl fmt::Display for ManifestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(error) => write!(f, "failed to parse manifest YAML: {error}"), + Self::MissingTarget(target) => write!(f, "manifest target {target:?} does not exist"), + Self::MissingOverlay(overlay) => { + write!(f, "manifest overlay {overlay:?} does not exist") + } + Self::DependencyCycle(cycle) => { + write!( + f, + "manifest overlay dependency cycle: {}", + cycle.join(" -> ") + ) + } + } + } +} + +impl std::error::Error for ManifestError {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ManifestYaml { + #[serde(default)] + package: Option, + base: String, + #[serde(default)] + overlays: BTreeMap, + #[serde(default)] + targets: BTreeMap, +} + +impl ManifestYaml { + fn into_manifest(self) -> FederatedDeckManifest { + FederatedDeckManifest { + package: self.package.map(PackageMetadataYaml::into_metadata), + base: self.base, + overlays: self + .overlays + .into_iter() + .map(|(id, overlay)| (id, overlay.into_entry())) + .collect(), + targets: self + .targets + .into_iter() + .map(|(id, target)| (id, target.into_target())) + .collect(), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PackageMetadataYaml { + id: String, + version: String, + #[serde(default)] + compatible_base_versions: Vec, + #[serde(default)] + depends_on: Vec, +} + +impl PackageMetadataYaml { + fn into_metadata(self) -> PackageMetadata { + PackageMetadata { + id: self.id, + version: self.version, + compatible_base_versions: self.compatible_base_versions, + depends_on: self.depends_on, + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OverlayManifestEntryYaml { + file: String, + #[serde(default)] + kind: Option, + #[serde(default)] + depends_on: Vec, +} + +impl OverlayManifestEntryYaml { + fn into_entry(self) -> OverlayManifestEntry { + OverlayManifestEntry { + file: self.file, + kind: self.kind, + depends_on: self.depends_on, + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BuildTargetYaml { + #[serde(default)] + extends: Option, + #[serde(default)] + overlays: Vec, + #[serde(default)] + exports: TargetExportsYaml, +} + +impl BuildTargetYaml { + fn into_target(self) -> BuildTarget { + BuildTarget { + extends: self.extends, + overlays: self.overlays, + exports: self.exports.into_exports(), + } + } +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct TargetExportsYaml { + #[serde(default)] + crowdanki: Option, +} + +impl TargetExportsYaml { + fn into_exports(self) -> TargetExports { + TargetExports { + crowdanki: self.crowdanki.map(CrowdAnkiTargetExportYaml::into_export), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CrowdAnkiTargetExportYaml { + #[serde(default)] + out: Option, + #[serde(default)] + golden: Option, + #[serde(default)] + golden_allowlist: Vec, +} + +impl CrowdAnkiTargetExportYaml { + fn into_export(self) -> CrowdAnkiTargetExport { + CrowdAnkiTargetExport { + out: self.out, + golden: self.golden, + golden_allowlist: self.golden_allowlist, + } + } +} diff --git a/crates/brain-brew-formats/src/media.rs b/crates/brain-brew-formats/src/media.rs new file mode 100644 index 0000000..c9d5111 --- /dev/null +++ b/crates/brain-brew-formats/src/media.rs @@ -0,0 +1,235 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use brain_brew_core::CanonicalDeck; +use sha2::{Digest, Sha256}; + +/// Extract Anki-compatible media paths used by note fields and card templates. +pub fn referenced_paths(deck: &CanonicalDeck) -> BTreeSet { + let mut paths = BTreeSet::new(); + + for note in deck.notes.values() { + for value in note.fields.values() { + extract_from_text(value, &mut paths); + } + } + + for note_type in deck.note_types.values() { + extract_from_text(¬e_type.styling, &mut paths); + for template in ¬e_type.card_templates { + extract_from_text(&template.question_format, &mut paths); + extract_from_text(&template.answer_format, &mut paths); + } + } + + paths +} + +/// Compute a lowercase SHA-256 hex digest. +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Validate content hashes using caller-supplied media asset bytes. +pub fn validate_hashes( + deck: &CanonicalDeck, + assets: &BTreeMap>, +) -> Result<(), MediaValidationReport> { + let mut errors = Vec::new(); + + for media in deck.media.values() { + if media.sha256.is_empty() { + continue; + } + let Some(bytes) = assets.get(&media.path) else { + errors.push(MediaValidationError { + kind: MediaValidationErrorKind::MissingAsset, + path: media.path.clone(), + message: format!( + "media asset {} was not supplied for hash validation", + media.path + ), + }); + continue; + }; + let actual = sha256_hex(bytes); + if actual != media.sha256 { + errors.push(MediaValidationError { + kind: MediaValidationErrorKind::HashMismatch, + path: media.path.clone(), + message: format!( + "media asset {} has sha256 {}, expected {}", + media.path, actual, media.sha256 + ), + }); + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(MediaValidationReport { errors }) + } +} + +/// Validate that every used media path is declared and every declaration is used. +pub fn validate_references(deck: &CanonicalDeck) -> Result<(), MediaValidationReport> { + let used = referenced_paths(deck); + let declared = deck + .media + .values() + .map(|media| media.path.clone()) + .collect::>(); + let mut errors = Vec::new(); + + for path in used.difference(&declared) { + errors.push(MediaValidationError { + kind: MediaValidationErrorKind::MissingReference, + path: path.clone(), + message: format!("media path {path} is used but not declared"), + }); + } + + for path in declared.difference(&used) { + errors.push(MediaValidationError { + kind: MediaValidationErrorKind::UnusedReference, + path: path.clone(), + message: format!("media path {path} is declared but not used"), + }); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(MediaValidationReport { errors }) + } +} + +fn extract_from_text(text: &str, paths: &mut BTreeSet) { + extract_sound_references(text, paths); + extract_attribute_references(text, "src=", paths); + extract_attribute_references(text, "href=", paths); + extract_css_url_references(text, paths); +} + +fn extract_sound_references(text: &str, paths: &mut BTreeSet) { + let mut rest = text; + while let Some(start) = rest.find("[sound:") { + rest = &rest[start + "[sound:".len()..]; + let Some(end) = rest.find(']') else { + break; + }; + let path = rest[..end].trim(); + if !path.is_empty() { + paths.insert(path.to_owned()); + } + rest = &rest[end + 1..]; + } +} + +fn extract_attribute_references(text: &str, attribute: &str, paths: &mut BTreeSet) { + let mut rest = text; + while let Some(start) = rest.find(attribute) { + rest = &rest[start + attribute.len()..]; + let Some((path, consumed)) = consume_media_path(rest, |ch| ch.is_whitespace() || ch == '>') + else { + break; + }; + insert_media_path(path, paths); + rest = &rest[consumed..]; + } +} + +fn extract_css_url_references(text: &str, paths: &mut BTreeSet) { + let mut rest = text; + while let Some(start) = rest.find("url(") { + rest = &rest[start + "url(".len()..]; + let Some((path, consumed)) = consume_media_path(rest, |ch| ch == ')') else { + break; + }; + insert_media_path(path, paths); + rest = &rest[consumed..]; + } +} + +fn consume_media_path(rest: &str, unquoted_end: impl Fn(char) -> bool) -> Option<(&str, usize)> { + let trimmed = rest.trim_start(); + let consumed_whitespace = rest.len() - trimmed.len(); + let first = trimmed.chars().next()?; + let (path, consumed) = if first == '"' || first == '\'' { + let quote_len = first.len_utf8(); + let after_quote = &trimmed[quote_len..]; + let end = after_quote.find(first)?; + (&after_quote[..end], quote_len + end + quote_len) + } else { + let end = trimmed.find(unquoted_end).unwrap_or(trimmed.len()); + (&trimmed[..end], end) + }; + Some((path.trim(), consumed_whitespace + consumed)) +} + +fn insert_media_path(path: &str, paths: &mut BTreeSet) { + if !path.is_empty() && !path.starts_with("#") && !has_uri_scheme(path) { + paths.insert(path.to_owned()); + } +} + +fn has_uri_scheme(path: &str) -> bool { + let Some(colon) = path.find(':') else { + return false; + }; + let scheme = &path[..colon]; + !scheme.is_empty() + && scheme + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic()) + && scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) +} + +/// A media reference validation report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaValidationReport { + pub errors: Vec, +} + +impl MediaValidationReport { + /// Returns true when the report contains at least one error of the given kind. + pub fn has_kind(&self, kind: MediaValidationErrorKind) -> bool { + self.errors.iter().any(|error| error.kind == kind) + } +} + +impl fmt::Display for MediaValidationReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, error) in self.errors.iter().enumerate() { + if index > 0 { + writeln!(f)?; + } + write!(f, "{}: {}", error.path, error.message)?; + } + Ok(()) + } +} + +impl std::error::Error for MediaValidationReport {} + +/// One media validation error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaValidationError { + pub kind: MediaValidationErrorKind, + pub path: String, + pub message: String, +} + +/// Machine-readable media validation error kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MediaValidationErrorKind { + MissingReference, + UnusedReference, + MissingAsset, + HashMismatch, +} diff --git a/crates/brain-brew-formats/tests/canonical_yaml.rs b/crates/brain-brew-formats/tests/canonical_yaml.rs new file mode 100644 index 0000000..13ffbb8 --- /dev/null +++ b/crates/brain-brew-formats/tests/canonical_yaml.rs @@ -0,0 +1,319 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + StableId, +}; +use brain_brew_formats::{canonical_yaml, crowdanki}; + +#[test] +fn emits_canonical_deck_yaml_with_explicit_order_arrays() { + let yaml = canonical_yaml::to_string(&ug_style_deck()).expect("deck emits"); + + assert_eq!(yaml, EXPECTED_CANONICAL_YAML); +} + +#[test] +fn parses_emitted_yaml_back_to_semantically_equal_deck() { + let original = ug_style_deck(); + let yaml = canonical_yaml::to_string(&original).expect("deck emits"); + + let parsed = canonical_yaml::from_str(&yaml).expect("emitted yaml parses"); + + assert_eq!(parsed, original); + assert!(parsed.semantic_diff(&original).is_empty()); +} + +#[test] +fn formatter_canonicalizes_valid_yaml_bytes() { + let messy_yaml = r#"deck: + description: A geography deck fixture. + id: deck.ultimate-geography + name: Ultimate Geography + adapter_ids: {} +note_types: + note-type.country: + adapter_ids: + crowdanki:model_id: '1548959259107' + name: Ultimate Geography Country + styling: | + .card { font-family: sans-serif; } + field_order: [field.country, field.capital, field.flag] + fields: + field.flag: { name: Flag } + field.capital: { name: Capital } + field.country: { name: Country } + card_template_order: + - template.country-to-capital + card_templates: + template.country-to-capital: + adapter_ids: {} + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' +notes: + note.finland: + adapter_ids: + crowdanki:guid: ug-finland-guid + fields: + field.flag: '' + field.capital: Helsinki + field.country: Finland + note_type_id: note-type.country + tags: [Europe, Nordic] +media: + media.flag.finland: + path: flags/fi.png + sha256: 0123456789abcdef +tombstones: [] +"#; + + let formatted = canonical_yaml::format_str(messy_yaml).expect("valid yaml formats"); + + assert_eq!(formatted, EXPECTED_CANONICAL_YAML); +} + +#[test] +fn translation_dictionary_overlay_translates_variables_fields_and_adapter_ids() { + let deck = canonical_yaml::from_str( + r#"deck: + id: deck.translation-fixture + name: Ultimate Geography + description: A geography deck fixture. + variables: + label.deck: Ultimate Geography + adapter_ids: + crowdanki:uuid: deck-en +note_types: + note-type.country: + name: Ultimate Geography + variables: + label.capital: Capital + field_order: + - field.country + - field.capital + fields: + field.capital: + name: Capital + field.country: + name: Country + card_template_order: + - template.country-capital + card_templates: + template.country-capital: + name: Country - Capital + question_format: '
${label.capital}
{{Country}}' + answer_format: '
${label.capital}: {{Capital}}
' + adapter_ids: {} + styling: '' + adapter_ids: + crowdanki:uuid: model-en +notes: + note.denmark: + note_type_id: note-type.country + fields: + field.capital: Copenhagen + field.country: Denmark + tags: [] + adapter_ids: + crowdanki:guid: note-guid-en +media: {} +tombstones: [] +"#, + ) + .expect("deck parses"); + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.translation.da +kind: translation +translations: + changes: + Copenhagen: København + Denmark: Danmark + Ultimate Geography: 'Ultimate Geography [DA]' + variables: + label.capital: + Capital: Hovedstad + adapter_ids: + crowdanki:guid: + note-guid-en: note-guid-da + crowdanki:uuid: + deck-en: deck-da + model-en: model-da +"#, + ) + .expect("translation overlay parses"); + + let translated = deck.compose(&[overlay]).expect("translation composes"); + assert_eq!(translated.name, "Ultimate Geography [DA]"); + let note_type = &translated.note_types[&sid("note-type.country")]; + assert_eq!(note_type.variables["label.capital"], "Hovedstad"); + assert_eq!( + note_type.adapter_ids.get("crowdanki:uuid"), + Some("model-da") + ); + let note = &translated.notes[&sid("note.denmark")]; + assert_eq!(note.fields[&sid("field.country")], "Danmark"); + assert_eq!(note.fields[&sid("field.capital")], "København"); + assert_eq!(note.adapter_ids.get("crowdanki:guid"), Some("note-guid-da")); + + let exported = crowdanki::export_deck(&translated).expect("translated deck exports"); + let json: serde_json::Value = serde_json::from_str(&exported.deck_json).unwrap(); + assert_eq!(json["crowdanki_uuid"], "deck-da"); + assert_eq!( + json["note_models"][0]["tmpls"][0]["qfmt"], + "
Hovedstad
{{Country}}" + ); + assert_eq!(json["notes"][0]["guid"], "note-guid-da"); +} + +#[test] +fn translation_dictionary_rejects_legacy_text_key() { + let error = canonical_yaml::overlay_from_str( + r#"id: overlay.translation.da +kind: translation +translations: + text: + Denmark: Danmark +"#, + ) + .expect_err("legacy text key is not accepted"); + + assert!(error.to_string().contains("text")); +} + +#[test] +fn parser_rejects_unknown_fields_in_canonical_yaml() { + let yaml = EXPECTED_CANONICAL_YAML.replace( + "name: Ultimate Geography\n", + "name: Ultimate Geography\n unsupported: true\n", + ); + + let error = canonical_yaml::from_str(&yaml).expect_err("unknown fields must fail"); + + assert!(error.to_string().contains("unsupported")); +} + +const EXPECTED_CANONICAL_YAML: &str = r#"deck: + id: deck.ultimate-geography + name: Ultimate Geography + description: A geography deck fixture. + adapter_ids: {} +note_types: + note-type.country: + name: Ultimate Geography Country + field_order: + - field.country + - field.capital + - field.flag + fields: + field.capital: + name: Capital + field.country: + name: Country + field.flag: + name: Flag + card_template_order: + - template.country-to-capital + card_templates: + template.country-to-capital: + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' + adapter_ids: {} + styling: | + .card { font-family: sans-serif; } + adapter_ids: + crowdanki:model_id: '1548959259107' +notes: + note.finland: + note_type_id: note-type.country + fields: + field.capital: Helsinki + field.country: Finland + field.flag: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-finland-guid +media: + media.flag.finland: + path: flags/fi.png + sha256: 0123456789abcdef +tombstones: [] +"#; + +fn ug_style_deck() -> CanonicalDeck { + let deck_adapter_ids = AdapterIds::new(); + let mut note_type_adapter_ids = AdapterIds::new(); + note_type_adapter_ids.insert("crowdanki:model_id", "1548959259107"); + + let note_type = NoteType { + id: sid("note-type.country"), + name: "Ultimate Geography Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.capital"), + name: "Capital".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-to-capital"), + name: "Country - Capital".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{FrontSide}}
{{Capital}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: note_type_adapter_ids, + }; + + let mut note_adapter_ids = AdapterIds::new(); + note_adapter_ids.insert("crowdanki:guid", "ug-finland-guid"); + + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.capital"), "Helsinki".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: note_adapter_ids, + }; + + CanonicalDeck { + id: sid("deck.ultimate-geography"), + name: "Ultimate Geography".to_owned(), + description: "A geography deck fixture.".to_owned(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([( + sid("media.flag.finland"), + MediaReference { + id: sid("media.flag.finland"), + path: "flags/fi.png".to_owned(), + sha256: "0123456789abcdef".to_owned(), + }, + )]), + tombstones: BTreeSet::new(), + adapter_ids: deck_adapter_ids, + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-formats/tests/crowdanki.rs b/crates/brain-brew-formats/tests/crowdanki.rs new file mode 100644 index 0000000..a10058f --- /dev/null +++ b/crates/brain-brew-formats/tests/crowdanki.rs @@ -0,0 +1,433 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + StableId, +}; +use brain_brew_formats::crowdanki; + +#[test] +fn exports_deterministic_crowdanki_json_preserving_adapter_identities() { + let export = crowdanki::export_deck(&ug_style_deck()).expect("deck exports"); + + assert_eq!(export.deck_json, EXPECTED_CROWDANKI_JSON); + assert!(export.omitted_tombstones.is_empty()); +} + +#[test] +fn import_export_round_trip_is_semantically_equal_when_suggested_ids_match_source() { + let original = ug_style_deck(); + let export = crowdanki::export_deck(&original).expect("deck exports"); + + let imported = crowdanki::import_deck_accept_suggested_ids(&export.deck_json) + .expect("exported CrowdAnki imports"); + + assert!(original.semantic_diff(&imported).is_empty()); +} + +#[test] +fn import_preserves_crowdanki_adapter_identities() { + let imported = crowdanki::import_deck_accept_suggested_ids(EXPECTED_CROWDANKI_JSON) + .expect("CrowdAnki imports"); + + assert_eq!( + imported.adapter_ids.get("crowdanki:uuid"), + Some("43c5ba66-9a65-11e8-90c9-a0481cc15658") + ); + assert_eq!( + imported.adapter_ids.get("crowdanki:deck_config_uuid"), + Some("deck.ultimate-geography:deck-config") + ); + assert_eq!( + imported.adapter_ids.get("crowdanki:deck_config_name"), + Some("Ultimate Geography") + ); + assert_eq!( + imported + .note_types + .get(&sid("note-type.country")) + .unwrap() + .adapter_ids + .get("crowdanki:uuid"), + Some("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + ); + assert_eq!( + imported + .notes + .get(&sid("note.finland")) + .unwrap() + .adapter_ids + .get("crowdanki:guid"), + Some("ug-finland-guid") + ); +} + +#[test] +fn crowdanki_parity_comparator_accepts_exact_match() { + let expected: serde_json::Value = serde_json::json!({ + "name": "Deck", + "notes": [{"guid": "abc", "fields": ["A"]}] + }); + let actual = expected.clone(); + + crowdanki::compare_deck_json_values( + &expected, + &actual, + &crowdanki::CrowdAnkiParityOptions::default(), + ) + .expect("exact JSON matches"); +} + +#[test] +fn crowdanki_parity_comparator_reports_json_paths() { + let expected: serde_json::Value = serde_json::json!({ + "desc": "Expected description", + "name": "Deck", + "notes": [{"guid": "abc", "fields": ["A"]}] + }); + let actual: serde_json::Value = serde_json::json!({ + "name": "Deck", + "notes": [{"guid": "abc", "fields": ["B"]}], + "deck_config_uuid": "legacy-default" + }); + + let report = crowdanki::compare_deck_json_values( + &expected, + &actual, + &crowdanki::CrowdAnkiParityOptions::default(), + ) + .expect_err("differences are reported"); + + assert!( + report + .differences + .iter() + .any(|difference| difference.path == "$.deck_config_uuid") + ); + assert!( + report + .differences + .iter() + .any(|difference| difference.path == "$.desc") + ); + assert!( + report + .differences + .iter() + .any(|difference| difference.path == "$.notes[guid=\"abc\"].fields[0]") + ); +} + +#[test] +fn crowdanki_parity_comparator_accepts_allowlisted_paths() { + let expected: serde_json::Value = serde_json::json!({ + "name": "Deck", + "notes": [{"guid": "abc", "fields": ["A"]}] + }); + let actual: serde_json::Value = serde_json::json!({ + "name": "Deck", + "notes": [{"guid": "abc", "fields": ["A"], "flags": 0}], + "deck_config_uuid": "legacy-default" + }); + let options = crowdanki::CrowdAnkiParityOptions { + allowed_path_globs: BTreeSet::from([ + "$.deck_config_uuid".to_owned(), + "$.notes[*].flags".to_owned(), + ]), + }; + + crowdanki::compare_deck_json_values(&expected, &actual, &options) + .expect("allowlisted JSON paths may differ"); +} + +#[test] +fn crowdanki_parity_comparator_matches_reordered_identity_arrays() { + let expected: serde_json::Value = serde_json::json!({ + "notes": [ + {"guid": "a", "fields": ["A"]}, + {"guid": "b", "fields": ["B"]} + ], + "note_models": [{ + "crowdanki_uuid": "model-1", + "flds": [{"name": "Country"}, {"name": "Capital"}], + "tmpls": [{"ord": 0, "name": "A"}, {"ord": 1, "name": "B"}] + }] + }); + let actual: serde_json::Value = serde_json::json!({ + "note_models": [{ + "crowdanki_uuid": "model-1", + "tmpls": [{"ord": 1, "name": "B"}, {"ord": 0, "name": "A"}], + "flds": [{"name": "Capital"}, {"name": "Country"}] + }], + "notes": [ + {"guid": "b", "fields": ["B"]}, + {"guid": "a", "fields": ["A"]} + ] + }); + + crowdanki::compare_deck_json_values( + &expected, + &actual, + &crowdanki::CrowdAnkiParityOptions::default(), + ) + .expect("identity-keyed arrays may reorder without a parity difference"); +} + +#[test] +fn crowdanki_parity_report_summarizes_repeated_defaults_and_serializes_to_json() { + let expected: serde_json::Value = serde_json::json!({ + "notes": [ + {"guid": "a", "fields": ["A"]}, + {"guid": "b", "fields": ["B"]}, + {"guid": "c", "fields": ["C"]} + ] + }); + let actual: serde_json::Value = serde_json::json!({ + "notes": [ + {"guid": "a", "fields": ["A"], "flags": 0}, + {"guid": "b", "fields": ["B"], "flags": 0}, + {"guid": "c", "fields": ["C"], "flags": 0} + ] + }); + + let report = crowdanki::compare_deck_json_values( + &expected, + &actual, + &crowdanki::CrowdAnkiParityOptions::default(), + ) + .expect_err("extra defaults are reported"); + let human = report.to_string(); + assert!(human.contains("Repeated differences")); + assert!(human.contains("3 × $.notes[*].flags")); + + let json = serde_json::to_value(&report).expect("report serializes"); + assert_eq!(json["differences"][0]["kind"], "extra_actual"); + assert_eq!(json["differences"].as_array().unwrap().len(), 3); +} + +#[test] +fn export_omits_tombstoned_notes_and_reports_their_stable_ids() { + let mut deck = ug_style_deck(); + deck.tombstones.insert(sid("note.finland")); + + let export = crowdanki::export_deck(&deck).expect("deck exports"); + + assert_eq!(export.omitted_tombstones, vec![sid("note.finland")]); + assert!(!export.deck_json.contains("ug-finland-guid")); +} + +const EXPECTED_CROWDANKI_JSON: &str = r#"{ + "__type__": "Deck", + "children": [], + "crowdanki_uuid": "43c5ba66-9a65-11e8-90c9-a0481cc15658", + "deck_config_uuid": "deck.ultimate-geography:deck-config", + "deck_configurations": [ + { + "__type__": "DeckConfig", + "autoplay": false, + "crowdanki_uuid": "deck.ultimate-geography:deck-config", + "dyn": false, + "lapse": { + "delays": [ + 10 + ], + "leechAction": 0, + "leechFails": 8, + "minInt": 1, + "mult": 0 + }, + "maxTaken": 60, + "name": "Ultimate Geography", + "new": { + "bury": true, + "delays": [ + 1, + 10 + ], + "initialFactor": 2500, + "ints": [ + 1, + 4, + 7 + ], + "order": 0, + "perDay": 15, + "separate": true + }, + "replayq": true, + "rev": { + "bury": true, + "ease4": 1.3, + "fuzz": 0.05, + "ivlFct": 1, + "maxIvl": 36500, + "minSpace": 1, + "perDay": 100 + }, + "timer": 0 + } + ], + "desc": "A geography deck fixture.", + "dyn": 0, + "extendNew": 10, + "extendRev": 50, + "media_files": [ + "flags/fi.png" + ], + "name": "Ultimate Geography", + "note_models": [ + { + "__type__": "NoteModel", + "crowdanki_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "css": ".card { font-family: sans-serif; }\n", + "flds": [ + { + "font": "Arial", + "media": [], + "name": "Country", + "ord": 0, + "rtl": false, + "size": 20, + "sticky": false + }, + { + "font": "Arial", + "media": [], + "name": "Capital", + "ord": 1, + "rtl": false, + "size": 20, + "sticky": false + }, + { + "font": "Arial", + "media": [], + "name": "Flag", + "ord": 2, + "rtl": false, + "size": 20, + "sticky": false + } + ], + "latexPost": "\\end{document}", + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "latexsvg": false, + "name": "Country", + "req": [], + "sortf": 0, + "tags": [], + "tmpls": [ + { + "afmt": "{{FrontSide}}
{{Capital}}", + "bafmt": "", + "bfont": "", + "bqfmt": "", + "bsize": 0, + "did": null, + "name": "Country - Capital", + "ord": 0, + "qfmt": "{{Country}}", + "scratchPad": 0 + } + ], + "type": 0, + "vers": [] + } + ], + "notes": [ + { + "__type__": "Note", + "data": "", + "fields": [ + "Finland", + "Helsinki", + "" + ], + "flags": 0, + "guid": "ug-finland-guid", + "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "tags": [ + "Europe", + "Nordic" + ] + } + ] +} +"#; + +fn ug_style_deck() -> CanonicalDeck { + let mut deck_adapter_ids = AdapterIds::new(); + deck_adapter_ids.insert("crowdanki:uuid", "43c5ba66-9a65-11e8-90c9-a0481cc15658"); + + let mut note_type_adapter_ids = AdapterIds::new(); + note_type_adapter_ids.insert("crowdanki:uuid", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + + let note_type = NoteType { + id: sid("note-type.country"), + name: "Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.capital"), + name: "Capital".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-capital"), + name: "Country - Capital".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}}".to_owned(), + answer_format: "{{FrontSide}}
{{Capital}}".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: ".card { font-family: sans-serif; }\n".to_owned(), + adapter_ids: note_type_adapter_ids, + }; + + let mut note_adapter_ids = AdapterIds::new(); + note_adapter_ids.insert("crowdanki:guid", "ug-finland-guid"); + + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.capital"), "Helsinki".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::from(["Europe".to_owned(), "Nordic".to_owned()]), + adapter_ids: note_adapter_ids, + }; + + CanonicalDeck { + id: sid("deck.ultimate-geography"), + name: "Ultimate Geography".to_owned(), + description: "A geography deck fixture.".to_owned(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([( + sid("media.flags-fi-png"), + MediaReference { + id: sid("media.flags-fi-png"), + path: "flags/fi.png".to_owned(), + sha256: String::new(), + }, + )]), + tombstones: BTreeSet::new(), + adapter_ids: deck_adapter_ids, + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-formats/tests/lockfile_yaml.rs b/crates/brain-brew-formats/tests/lockfile_yaml.rs new file mode 100644 index 0000000..4c907e5 --- /dev/null +++ b/crates/brain-brew-formats/tests/lockfile_yaml.rs @@ -0,0 +1,65 @@ +use brain_brew_formats::lockfile; + +#[test] +fn parses_and_formats_federation_lock_with_git_nar_hash() { + let formatted = lockfile::format_str( + r#" +packages: + anki-geo.ultimate-geography: + locked: + nar_hash: sha256-example + rev: ccf150a1b21e + url: https://github.com/anki-geo/ultimate-geography.git + type: git + original: + ref: main + url: https://github.com/anki-geo/ultimate-geography.git + type: git + package: + version: 0.1.0 + manifest: brainbrew.yaml +version: 1 +"#, + ) + .expect("lock formats"); + + assert_eq!( + formatted, + r#"version: 1 +packages: + anki-geo.ultimate-geography: + manifest: brainbrew.yaml + package: + version: 0.1.0 + original: + type: git + url: https://github.com/anki-geo/ultimate-geography.git + ref: main + locked: + type: git + url: https://github.com/anki-geo/ultimate-geography.git + rev: ccf150a1b21e + nar_hash: sha256-example +"# + ); + + let lock = lockfile::from_str(&formatted).expect("formatted lock parses"); + let package = &lock.packages["anki-geo.ultimate-geography"]; + assert_eq!(package.package.version, "0.1.0"); + assert_eq!(package.locked.source_type, "git"); + assert_eq!(package.locked.rev.as_deref(), Some("ccf150a1b21e")); + assert_eq!(package.locked.nar_hash.as_deref(), Some("sha256-example")); +} + +#[test] +fn rejects_unknown_lock_fields() { + let error = lockfile::from_str( + r#" +version: 1 +unknown: true +"#, + ) + .expect_err("unknown fields are rejected"); + + assert!(error.to_string().contains("unknown field `unknown`")); +} diff --git a/crates/brain-brew-formats/tests/manifest_yaml.rs b/crates/brain-brew-formats/tests/manifest_yaml.rs new file mode 100644 index 0000000..bdaa96b --- /dev/null +++ b/crates/brain-brew-formats/tests/manifest_yaml.rs @@ -0,0 +1,289 @@ +use brain_brew_formats::manifest; + +#[test] +fn expands_manifest_target_dependencies_in_deterministic_order() { + let manifest = manifest::from_str( + r#" +base: deck.yaml +overlays: + lang.de: + file: overlays/languages/de.yaml + kind: translation + variant.extended.de: + file: overlays/variants/extended.de.yaml + kind: extension + depends_on: + - lang.de + patch.capital: + file: overlays/patches/capital.yaml + kind: patch +targets: + de-extended-patched: + overlays: + - variant.extended.de + - patch.capital +"#, + ) + .expect("manifest parses"); + + let target = manifest + .expand_target("de-extended-patched") + .expect("target expands"); + + assert_eq!(target.base, "deck.yaml"); + assert_eq!(target.extends, None); + assert_eq!( + target + .overlays + .iter() + .map(|overlay| overlay.id.as_str()) + .collect::>(), + vec!["lang.de", "variant.extended.de", "patch.capital"] + ); + assert_eq!( + target + .overlays + .iter() + .map(|overlay| overlay.file.as_str()) + .collect::>(), + vec![ + "overlays/languages/de.yaml", + "overlays/variants/extended.de.yaml", + "overlays/patches/capital.yaml" + ] + ); +} + +#[test] +fn parses_package_metadata_and_target_export_checks() { + let manifest = manifest::from_str( + r#" +package: + id: anki-geo.ultimate-geography + version: 0.1.0 + compatible_base_versions: + - '>=0.1,<0.2' + depends_on: + - anki-geo.shared-geography@0.1.0 +base: deck.yaml +targets: + en-standard: + overlays: [] + exports: + crowdanki: + out: build/en-standard + golden: goldens/en-standard/deck.json + golden_allowlist: + - $.deck_config_uuid +"#, + ) + .expect("manifest parses"); + + let package = manifest.package.expect("package metadata parsed"); + assert_eq!(package.id, "anki-geo.ultimate-geography"); + assert_eq!(package.version, "0.1.0"); + assert_eq!(package.compatible_base_versions, vec![">=0.1,<0.2"]); + assert_eq!(package.depends_on, vec!["anki-geo.shared-geography@0.1.0"]); + + let export = manifest.targets["en-standard"] + .exports + .crowdanki + .as_ref() + .expect("crowdanki export config parsed"); + assert_eq!(export.out.as_deref(), Some("build/en-standard")); + assert_eq!( + export.golden.as_deref(), + Some("goldens/en-standard/deck.json") + ); + assert_eq!(export.golden_allowlist, vec!["$.deck_config_uuid"]); +} + +#[test] +fn formatter_canonicalizes_manifest_yaml() { + let formatted = manifest::format_str( + r#" +targets: + de-extended: + overlays: [overlay.variant.extended.de] +overlays: + overlay.variant.extended.de: + depends_on: [overlay.translation.de] + kind: extension + file: overlays/variants/extended/de.yaml + overlay.translation.de: + file: overlays/languages/de.yaml + kind: translation +base: deck.yaml +"#, + ) + .expect("manifest formats"); + + assert_eq!( + formatted, + r#"base: deck.yaml +overlays: + overlay.translation.de: + file: overlays/languages/de.yaml + kind: translation + overlay.variant.extended.de: + file: overlays/variants/extended/de.yaml + kind: extension + depends_on: + - overlay.translation.de +targets: + de-extended: + overlays: + - overlay.variant.extended.de +"# + ); +} + +#[test] +fn parses_and_formats_target_extends_for_package_federation() { + let formatted = manifest::format_str( + r#" +base: deck.yaml +overlays: + overlay.extension.america: + file: overlays/america.yaml + kind: extension +targets: + en-america: + overlays: [overlay.extension.america] + extends: anki-geo.ultimate-geography:en-standard +"#, + ) + .expect("manifest formats"); + + assert_eq!( + formatted, + r#"base: deck.yaml +overlays: + overlay.extension.america: + file: overlays/america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:en-standard + overlays: + - overlay.extension.america +"# + ); + let manifest = manifest::from_str(&formatted).expect("manifest parses"); + let target = manifest.expand_target("en-america").unwrap(); + assert_eq!( + target.extends.as_deref(), + Some("anki-geo.ultimate-geography:en-standard") + ); +} + +#[test] +fn formatter_emits_package_metadata_and_target_exports() { + let formatted = manifest::format_str( + r#" +targets: + en-standard: + exports: + crowdanki: + golden_allowlist: ['$.deck_config_uuid'] + golden: goldens/en-standard/deck.json + out: build/en-standard + overlays: [] +package: + depends_on: [anki-geo.shared-geography@0.1.0] + compatible_base_versions: ['>=0.1,<0.2'] + version: 0.1.0 + id: anki-geo.ultimate-geography +base: deck.yaml +"#, + ) + .expect("manifest formats"); + + assert_eq!( + formatted, + r#"package: + id: anki-geo.ultimate-geography + version: 0.1.0 + compatible_base_versions: + - '>=0.1,<0.2' + depends_on: + - 'anki-geo.shared-geography@0.1.0' +base: deck.yaml +overlays: {} +targets: + en-standard: + overlays: [] + exports: + crowdanki: + out: build/en-standard + golden: goldens/en-standard/deck.json + golden_allowlist: + - '$.deck_config_uuid' +"# + ); +} + +#[test] +fn rejects_manifest_unknown_fields() { + let error = manifest::from_str( + r#" +base: deck.yaml +unknown: true +"#, + ) + .expect_err("unknown fields are rejected"); + + assert!(error.to_string().contains("unknown field `unknown`")); +} + +#[test] +fn reports_missing_overlay_references() { + let manifest = manifest::from_str( + r#" +base: deck.yaml +targets: + broken: + overlays: + - missing.overlay +"#, + ) + .expect("manifest parses"); + + let error = manifest + .expand_target("broken") + .expect_err("missing overlay is reported"); + + assert_eq!( + error.to_string(), + "manifest overlay \"missing.overlay\" does not exist" + ); +} + +#[test] +fn reports_overlay_dependency_cycles() { + let manifest = manifest::from_str( + r#" +base: deck.yaml +overlays: + a: + file: a.yaml + depends_on: [b] + b: + file: b.yaml + depends_on: [a] +targets: + cyclic: + overlays: [a] +"#, + ) + .expect("manifest parses"); + + let error = manifest + .expand_target("cyclic") + .expect_err("cycle is reported"); + + assert_eq!( + error.to_string(), + "manifest overlay dependency cycle: a -> b -> a" + ); +} diff --git a/crates/brain-brew-formats/tests/media_references.rs b/crates/brain-brew-formats/tests/media_references.rs new file mode 100644 index 0000000..26d99b8 --- /dev/null +++ b/crates/brain-brew-formats/tests/media_references.rs @@ -0,0 +1,222 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use brain_brew_core::{ + AdapterIds, CanonicalDeck, CardTemplate, FieldDefinition, MediaReference, Note, NoteType, + StableId, +}; +use brain_brew_formats::media::{self, MediaValidationErrorKind}; + +#[test] +fn extracts_media_references_from_fields_and_templates() { + let deck = media_deck(); + + let paths = media::referenced_paths(&deck); + + assert!(paths.contains("flags/fi.png")); + assert!(paths.contains("audio/fi.mp3")); + assert!(paths.contains("maps/fi.svg")); +} + +#[test] +fn extracts_multiple_img_sources_from_one_field() { + let mut deck = media_deck(); + deck.notes + .get_mut(&sid("note.finland")) + .unwrap() + .fields + .insert( + sid("field.flag"), + "".to_owned(), + ); + + let paths = media::referenced_paths(&deck); + + assert!(paths.contains("flags/fi-blur.png")); + assert!(paths.contains("flags/fi.png")); +} + +#[test] +fn extracts_css_url_media_from_templates_and_styling() { + let mut deck = media_deck(); + let note_type = deck.note_types.get_mut(&sid("note-type.country")).unwrap(); + note_type.styling = "@import url(\"css/maps.css\");".to_owned(); + note_type.card_templates[0].question_format = + "sourcemailanswer{{Country}}".to_owned(); + note_type.card_templates[0].answer_format = + "{{Flag}}".to_owned(); + + let paths = media::referenced_paths(&deck); + + assert!(paths.contains("css/maps.css")); + assert!(paths.contains("css/interactive.css")); + assert!(paths.contains("css/review.css")); + assert!(!paths.contains("https://example.com")); + assert!(!paths.contains("mailto:geo@example.com")); + assert!(!paths.contains("#answer")); +} + +#[test] +fn validation_accepts_declared_media_references() { + let deck = media_deck(); + + assert!(media::validate_references(&deck).is_ok()); +} + +#[test] +fn validation_reports_missing_media_reference_paths() { + let mut deck = media_deck(); + deck.media.remove(&sid("media.flags-fi-png")); + + let report = media::validate_references(&deck).expect_err("missing media must fail"); + + assert!(report.has_kind(MediaValidationErrorKind::MissingReference)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "flags/fi.png") + ); +} + +#[test] +fn validates_media_asset_hashes_from_supplied_bytes() { + let mut deck = media_deck(); + deck.media + .get_mut(&sid("media.flags-fi-png")) + .unwrap() + .sha256 = media::sha256_hex(b"flag-bytes"); + deck.media + .get_mut(&sid("media.audio-fi-mp3")) + .unwrap() + .sha256 = media::sha256_hex(b"audio-bytes"); + deck.media + .get_mut(&sid("media.maps-fi-svg")) + .unwrap() + .sha256 = media::sha256_hex(b"map-bytes"); + let assets = BTreeMap::from([ + ("flags/fi.png".to_owned(), b"flag-bytes".to_vec()), + ("audio/fi.mp3".to_owned(), b"audio-bytes".to_vec()), + ("maps/fi.svg".to_owned(), b"map-bytes".to_vec()), + ]); + + assert!(media::validate_hashes(&deck, &assets).is_ok()); +} + +#[test] +fn reports_media_hash_mismatches() { + let mut deck = media_deck(); + deck.media + .get_mut(&sid("media.flags-fi-png")) + .unwrap() + .sha256 = media::sha256_hex(b"expected"); + let assets = BTreeMap::from([("flags/fi.png".to_owned(), b"actual".to_vec())]); + + let report = media::validate_hashes(&deck, &assets).expect_err("hash mismatch must fail"); + + assert!(report.has_kind(MediaValidationErrorKind::HashMismatch)); + assert!( + report + .errors + .iter() + .any(|error| error.path == "flags/fi.png") + ); +} + +#[test] +fn validation_reports_unused_media_references() { + let mut deck = media_deck(); + deck.media.insert( + sid("media.unused"), + MediaReference { + id: sid("media.unused"), + path: "unused.png".to_owned(), + sha256: "abc".to_owned(), + }, + ); + + let report = media::validate_references(&deck).expect_err("unused media must fail"); + + assert!(report.has_kind(MediaValidationErrorKind::UnusedReference)); + assert!(report.errors.iter().any(|error| error.path == "unused.png")); +} + +fn media_deck() -> CanonicalDeck { + let note_type = NoteType { + id: sid("note-type.country"), + name: "Country".to_owned(), + variables: BTreeMap::new(), + fields: vec![ + FieldDefinition { + id: sid("field.country"), + name: "Country".to_owned(), + }, + FieldDefinition { + id: sid("field.flag"), + name: "Flag".to_owned(), + }, + ], + card_templates: vec![CardTemplate { + id: sid("template.country-flag"), + name: "Country - Flag".to_owned(), + variables: BTreeMap::new(), + question_format: "{{Country}} ".to_owned(), + answer_format: "{{Flag}} [sound:audio/fi.mp3]".to_owned(), + adapter_ids: AdapterIds::new(), + }], + styling: String::new(), + adapter_ids: AdapterIds::new(), + }; + + let note = Note { + id: sid("note.finland"), + note_type_id: sid("note-type.country"), + variables: BTreeMap::new(), + fields: BTreeMap::from([ + (sid("field.country"), "Finland".to_owned()), + (sid("field.flag"), "".to_owned()), + ]), + tags: BTreeSet::new(), + adapter_ids: AdapterIds::new(), + }; + + CanonicalDeck { + id: sid("deck.media"), + name: "Media".to_owned(), + description: String::new(), + variables: BTreeMap::new(), + note_types: BTreeMap::from([(note_type.id.clone(), note_type)]), + notes: BTreeMap::from([(note.id.clone(), note)]), + media: BTreeMap::from([ + ( + sid("media.flags-fi-png"), + MediaReference { + id: sid("media.flags-fi-png"), + path: "flags/fi.png".to_owned(), + sha256: "abc".to_owned(), + }, + ), + ( + sid("media.audio-fi-mp3"), + MediaReference { + id: sid("media.audio-fi-mp3"), + path: "audio/fi.mp3".to_owned(), + sha256: "def".to_owned(), + }, + ), + ( + sid("media.maps-fi-svg"), + MediaReference { + id: sid("media.maps-fi-svg"), + path: "maps/fi.svg".to_owned(), + sha256: "ghi".to_owned(), + }, + ), + ]), + tombstones: BTreeSet::new(), + adapter_ids: AdapterIds::new(), + } +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-formats/tests/overlay_yaml.rs b/crates/brain-brew-formats/tests/overlay_yaml.rs new file mode 100644 index 0000000..c9ca4ee --- /dev/null +++ b/crates/brain-brew-formats/tests/overlay_yaml.rs @@ -0,0 +1,736 @@ +use brain_brew_core::{ChangeIntent, ExpectedBase, OverlayKind, StableId}; +use brain_brew_formats::canonical_yaml; + +#[test] +fn formatter_canonicalizes_overlay_yaml() { + let formatted = canonical_yaml::overlay_format_str( + r#"kind: extension +id: overlay.extension.extended +note_types: + note-type.country: + card_templates: + template.country-flag: + template: + answer_format: '{{Flag}}' + question_format: '{{Country}}' + name: Country - Flag + insert_after: template.capital-country + intent: add + styling: + value: | + .card { font-family: serif; } + intent: replace + expected_base: + value: | + .card { font-family: sans-serif; } + intent: merge +"#, + ) + .expect("overlay formats"); + + assert!(formatted.starts_with("id: overlay.extension.extended\nkind: extension\n")); + assert!(formatted.contains(" styling:\n intent: replace\n")); + assert!(formatted.contains(" template.country-flag:\n intent: add\n")); + assert!(formatted.contains(" insert_after: template.capital-country\n")); + canonical_yaml::overlay_from_str(&formatted).expect("formatted overlay parses"); +} + +#[test] +fn parses_sparse_overlay_yaml_with_field_expected_base() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.patch.capital +kind: patch +notes: + note.finland: + intent: merge + fields: + field.capital: + intent: replace + value: Helsingfors + expected_base: + value: Helsinki +"#, + ) + .expect("overlay parses"); + + assert_eq!(overlay.id, sid("overlay.patch.capital")); + assert_eq!(overlay.kind, OverlayKind::Patch); + let note_change = overlay.note_changes.get(&sid("note.finland")).unwrap(); + assert_eq!(note_change.intent, ChangeIntent::Merge); + let field_change = note_change.fields.get(&sid("field.capital")).unwrap(); + assert_eq!(field_change.intent, ChangeIntent::Replace); + assert_eq!(field_change.value.as_deref(), Some("Helsingfors")); + assert_eq!( + field_change.expected_base, + Some(ExpectedBase::Value("Helsinki".to_owned())) + ); +} + +#[test] +fn parses_note_type_field_addition_overlay() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.population +kind: extension +note_types: + note-type.country: + intent: merge + fields: + field.population: + intent: add + name: Population +notes: {} +"#, + ) + .expect("overlay parses"); + + assert_eq!(overlay.kind, OverlayKind::Extension); + let note_type_change = overlay + .note_type_changes + .get(&sid("note-type.country")) + .unwrap(); + let field_change = note_type_change + .fields + .get(&sid("field.population")) + .unwrap(); + assert_eq!(field_change.intent, ChangeIntent::Add); + assert_eq!(field_change.field.as_ref().unwrap().name, "Population"); +} + +#[test] +fn parses_note_type_addition_overlay_with_payload() { + let formatted = canonical_yaml::overlay_format_str( + r#"id: overlay.extension.regions +kind: extension +note_types: + note-type.region: + intent: add + note_type: + name: Region Geography + field_order: + - field.region + - field.map + fields: + field.map: + name: Map + field.region: + name: Region + card_template_order: + - template.region-map + card_templates: + template.region-map: + name: Region - Map + question_format: '{{Region}}' + answer_format: '{{Map}}' + styling: | + .card { font-family: sans-serif; } +"#, + ) + .expect("overlay formats"); + + let overlay = canonical_yaml::overlay_from_str(&formatted).expect("formatted overlay parses"); + let change = overlay + .note_type_changes + .get(&sid("note-type.region")) + .unwrap(); + assert_eq!(change.intent, ChangeIntent::Add); + let note_type = change.note_type.as_ref().unwrap(); + assert_eq!(note_type.name, "Region Geography"); + assert_eq!(note_type.fields[0].id, sid("field.region")); + assert_eq!(note_type.card_templates[0].id, sid("template.region-map")); + assert!(formatted.contains(" note_type:\n name: Region Geography\n")); +} + +#[test] +fn parses_field_additions_shorthand_for_multiple_fields() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.population +kind: extension +field_additions: + note-type.country: + fields: + field.population: Population + field.area: Area + values: + note.australia: + field.population: 25.0 million + field.area: 7.69 million km² + note.austria: + field.population: 16.0 million +"#, + ) + .expect("overlay parses"); + + let note_type_change = overlay + .note_type_changes + .get(&sid("note-type.country")) + .unwrap(); + assert_eq!(note_type_change.intent, ChangeIntent::Merge); + assert_eq!( + note_type_change + .fields + .get(&sid("field.population")) + .unwrap() + .field + .as_ref() + .unwrap() + .name, + "Population" + ); + assert_eq!( + note_type_change + .fields + .get(&sid("field.area")) + .unwrap() + .field + .as_ref() + .unwrap() + .name, + "Area" + ); + + let note_change = overlay.note_changes.get(&sid("note.australia")).unwrap(); + assert_eq!(note_change.intent, ChangeIntent::Merge); + assert_eq!( + note_change + .fields + .get(&sid("field.population")) + .unwrap() + .value + .as_deref(), + Some("25.0 million") + ); + assert_eq!( + note_change + .fields + .get(&sid("field.area")) + .unwrap() + .value + .as_deref(), + Some("7.69 million km²") + ); +} + +#[test] +fn field_additions_shorthand_matches_verbose_overlay_semantics() { + let base = canonical_yaml::from_str( + r#"deck: + id: deck.demo + name: Demo + description: '' +note_types: + note-type.country: + name: Country + field_order: + - field.country + fields: + field.country: + name: Country + card_template_order: + - template.country + card_templates: + template.country: + name: Country + question_format: '{{Country}}' + answer_format: '{{Country}}' + styling: '' +notes: + note.australia: + note_type_id: note-type.country + fields: + field.country: Australia + tags: [] + note.austria: + note_type_id: note-type.country + fields: + field.country: Austria + tags: [] +media: {} +tombstones: [] +"#, + ) + .expect("base parses"); + let concise = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.population +kind: extension +field_additions: + note-type.country: + fields: + field.population: Population + field.area: Area + values: + note.australia: + field.population: 25.0 million + field.area: 7.69 million km² + note.austria: + field.population: 16.0 million + field.area: 83,879 km² +"#, + ) + .expect("concise overlay parses"); + let verbose = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.population +kind: extension +note_types: + note-type.country: + intent: merge + fields: + field.population: + intent: add + name: Population + field.area: + intent: add + name: Area +notes: + note.australia: + intent: merge + fields: + field.population: + intent: add + value: 25.0 million + field.area: + intent: add + value: 7.69 million km² + note.austria: + intent: merge + fields: + field.population: + intent: add + value: 16.0 million + field.area: + intent: add + value: 83,879 km² +"#, + ) + .expect("verbose overlay parses"); + + assert_eq!(concise, verbose); + let concise_deck = base.compose(&[concise]).expect("concise composes"); + let verbose_deck = base.compose(&[verbose]).expect("verbose composes"); + assert!(concise_deck.semantic_diff(&verbose_deck).is_empty()); +} + +#[test] +fn formatter_prefers_field_additions_shorthand() { + let formatted = canonical_yaml::overlay_format_str( + r#"id: overlay.extension.population +kind: extension +note_types: + note-type.country: + intent: merge + fields: + field.population: + intent: add + name: Population +notes: + note.australia: + intent: merge + fields: + field.population: + intent: add + value: 25.0 million +"#, + ) + .expect("overlay formats"); + + assert_eq!( + formatted, + "id: overlay.extension.population\nkind: extension\nfield_additions:\n note-type.country:\n fields:\n field.population: Population\n values:\n note.australia:\n field.population: 25.0 million\n" + ); +} + +#[test] +fn parses_field_fills_shorthand_for_existing_blank_fields() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.hardcore.fills.en +kind: extension +field_fills: + note.anguilla: + field.capital: The Valley + field.flag: '' +"#, + ) + .expect("overlay parses"); + + let note_change = overlay.note_changes.get(&sid("note.anguilla")).unwrap(); + assert_eq!(note_change.intent, ChangeIntent::Merge); + let capital = note_change.fields.get(&sid("field.capital")).unwrap(); + assert_eq!(capital.intent, ChangeIntent::Replace); + assert_eq!(capital.value.as_deref(), Some("The Valley")); + assert_eq!( + capital.expected_base, + Some(ExpectedBase::Value(String::new())) + ); +} + +#[test] +fn field_fills_shorthand_matches_verbose_overlay_semantics() { + let concise = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.hardcore.fills.en +kind: extension +field_fills: + note.anguilla: + field.capital: The Valley + field.flag: '' +"#, + ) + .expect("concise overlay parses"); + let verbose = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.hardcore.fills.en +kind: extension +notes: + note.anguilla: + intent: merge + fields: + field.capital: + intent: replace + value: The Valley + expected_base: + value: '' + field.flag: + intent: replace + value: '' + expected_base: + value: '' +"#, + ) + .expect("verbose overlay parses"); + + assert_eq!(concise, verbose); +} + +#[test] +fn formatter_prefers_field_fills_shorthand() { + let formatted = canonical_yaml::overlay_format_str( + r#"id: overlay.extension.hardcore.fills.en +kind: extension +notes: + note.anguilla: + intent: merge + fields: + field.capital: + intent: replace + value: The Valley + expected_base: + value: '' +"#, + ) + .expect("overlay formats"); + + assert_eq!( + formatted, + "id: overlay.extension.hardcore.fills.en\nkind: extension\nfield_fills:\n note.anguilla:\n field.capital: The Valley\n" + ); +} + +#[test] +fn formatter_orders_translation_dictionary_sections_deterministically() { + let formatted = canonical_yaml::overlay_format_str( + r#"id: overlay.translation.de +kind: translation +translations: + additions: + notes.note.anguilla.fields.field.capital: The Valley + adapter_ids: + crowdanki:guid: + old-guid: new-guid + changes: + Germany: Deutschland +"#, + ) + .expect("overlay formats"); + + assert!( + formatted.find(" changes:\n").unwrap() < formatted.find(" additions:\n").unwrap(), + "changes are emitted before additions" + ); + assert!( + formatted.find(" additions:\n").unwrap() < formatted.find(" adapter_ids:\n").unwrap(), + "additions are emitted before adapter_ids" + ); +} + +#[test] +fn parses_metadata_and_adapter_id_overlay_changes() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.translation.de +kind: translation +deck: + name: + intent: replace + value: Ultimate Geography [DE] + expected_base: + value: Ultimate Geography + adapter_ids: + crowdanki:uuid: + intent: replace + value: de-deck-uuid + expected_base: + value: en-deck-uuid +note_types: + note-type.country: + intent: merge + name: + intent: replace + value: Ultimate Geography [DE] + expected_base: + value: Ultimate Geography + adapter_ids: + crowdanki:model_id: + intent: replace + value: de-model-id + expected_base: + value: en-model-id + card_templates: + template.country-capital: + intent: merge + adapter_ids: + crowdanki:ord: + intent: add + value: '0' +notes: + note.finland: + intent: merge + adapter_ids: + crowdanki:guid: + intent: replace + value: ug-finland-de-guid + expected_base: + value: ug-finland-guid +"#, + ) + .expect("overlay parses"); + + let deck_change = overlay.deck_change.as_ref().unwrap(); + assert_eq!( + deck_change.name.as_ref().unwrap().value.as_deref(), + Some("Ultimate Geography [DE]") + ); + assert_eq!( + deck_change + .adapter_ids + .get("crowdanki:uuid") + .unwrap() + .value + .as_deref(), + Some("de-deck-uuid") + ); + + let note_type_change = overlay + .note_type_changes + .get(&sid("note-type.country")) + .unwrap(); + assert_eq!( + note_type_change.name.as_ref().unwrap().value.as_deref(), + Some("Ultimate Geography [DE]") + ); + assert_eq!( + note_type_change + .adapter_ids + .get("crowdanki:model_id") + .unwrap() + .value + .as_deref(), + Some("de-model-id") + ); + assert_eq!( + note_type_change + .card_templates + .get(&sid("template.country-capital")) + .unwrap() + .adapter_ids + .get("crowdanki:ord") + .unwrap() + .value + .as_deref(), + Some("0") + ); + + let note_change = overlay.note_changes.get(&sid("note.finland")).unwrap(); + assert_eq!( + note_change + .adapter_ids + .get("crowdanki:guid") + .unwrap() + .value + .as_deref(), + Some("ug-finland-de-guid") + ); +} + +#[test] +fn parses_add_note_overlay_payload() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.sweden +kind: extension +notes: + note.sweden: + intent: add + note: + note_type_id: note-type.country + fields: + field.country: Sweden + field.capital: Stockholm + tags: [Europe, Nordic] + adapter_ids: + crowdanki:guid: ug-sweden-guid +"#, + ) + .expect("overlay parses"); + + let note_change = overlay.note_changes.get(&sid("note.sweden")).unwrap(); + let note = note_change.note.as_ref().unwrap(); + assert_eq!(note.id, sid("note.sweden")); + assert_eq!(note.note_type_id, sid("note-type.country")); + assert_eq!(note.fields.get(&sid("field.capital")).unwrap(), "Stockholm"); + assert_eq!( + note.adapter_ids.get("crowdanki:guid"), + Some("ug-sweden-guid") + ); +} + +#[test] +fn parses_card_template_and_styling_overlay_changes() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.extended +kind: extension +note_types: + note-type.country: + intent: merge + styling: + intent: replace + value: | + .card { font-family: serif; } + expected_base: + value: | + .card { font-family: sans-serif; } + card_templates: + template.country-flag: + intent: add + insert_after: template.capital-country + template: + name: Country - Flag + question_format: '{{Country}}' + answer_format: '{{Flag}}' + adapter_ids: {} + template.country-capital: + intent: merge + question_format: + intent: replace + value: '{{Land}}' + expected_base: + value: '{{Country}}' +"#, + ) + .expect("overlay parses"); + + let note_type_change = overlay + .note_type_changes + .get(&sid("note-type.country")) + .unwrap(); + assert_eq!( + note_type_change.styling.as_ref().unwrap().value.as_deref(), + Some(".card { font-family: serif; }\n") + ); + let add_template = note_type_change + .card_templates + .get(&sid("template.country-flag")) + .unwrap(); + assert_eq!( + add_template.insert_after, + Some(sid("template.capital-country")) + ); + assert_eq!( + add_template.template.as_ref().unwrap().name, + "Country - Flag" + ); + let replace_template = note_type_change + .card_templates + .get(&sid("template.country-capital")) + .unwrap(); + assert_eq!( + replace_template + .question_format + .as_ref() + .unwrap() + .value + .as_deref(), + Some("{{Land}}") + ); +} + +#[test] +fn parses_tag_and_media_overlay_changes() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.extension.tags-media +kind: extension +notes: + note.finland: + intent: merge + tags: + UG::Nordic: + intent: add + Nordic: + intent: remove + expected_base: entity_present +media: + media.flag.sweden: + intent: add + path: flags/se.png + sha256: abcdef +"#, + ) + .expect("overlay parses"); + + let note_change = overlay.note_changes.get(&sid("note.finland")).unwrap(); + assert_eq!( + note_change.tags.get("UG::Nordic").unwrap().intent, + ChangeIntent::Add + ); + assert_eq!( + note_change.tags.get("Nordic").unwrap().expected_base, + Some(ExpectedBase::EntityPresent) + ); + + let media_change = overlay + .media_changes + .get(&sid("media.flag.sweden")) + .unwrap(); + assert_eq!(media_change.intent, ChangeIntent::Add); + assert_eq!(media_change.media.as_ref().unwrap().path, "flags/se.png"); +} + +#[test] +fn parses_remove_overlay_with_entity_present_expected_base() { + let overlay = canonical_yaml::overlay_from_str( + r#"id: overlay.patch.remove-finland +kind: patch +notes: + note.finland: + intent: remove + expected_base: entity_present +"#, + ) + .expect("overlay parses"); + + let note_change = overlay.note_changes.get(&sid("note.finland")).unwrap(); + assert_eq!(note_change.intent, ChangeIntent::Remove); + assert_eq!(note_change.expected_base, Some(ExpectedBase::EntityPresent)); +} + +#[test] +fn rejects_unknown_overlay_fields() { + let error = canonical_yaml::overlay_from_str( + r#"id: overlay.patch.capital +kind: patch +unsupported: true +notes: {} +"#, + ) + .expect_err("unknown overlay fields must fail"); + + assert!(error.to_string().contains("unsupported")); +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} diff --git a/crates/brain-brew-formats/tests/ultimate_geography_fixture.rs b/crates/brain-brew-formats/tests/ultimate_geography_fixture.rs new file mode 100644 index 0000000..cf6d591 --- /dev/null +++ b/crates/brain-brew-formats/tests/ultimate_geography_fixture.rs @@ -0,0 +1,1453 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::core::{ + AdapterIdChange, AdapterIds, CanonicalDeck, CardTemplateChange, ChangeIntent, DeckChange, + ExpectedBase, FieldChange, FieldDefinition, FieldDefinitionChange, MediaChange, MediaReference, + Note, NoteChange, NoteTypeChange, Overlay, OverlayKind, PropertyChange, StableId, TagChange, + TranslationChange, +}; +use brain_brew_formats::{canonical_yaml, crowdanki, manifest, media}; + +#[test] +fn ultimate_geography_fixture_manifest_composes_all_targets() { + let root = fixture_root(); + let manifest = read_manifest(&root); + let base_path = root.join(&manifest.base); + let base_source = fs::read_to_string(&base_path).unwrap(); + assert_eq!( + canonical_yaml::format_str(&base_source).unwrap(), + base_source, + "{} is not canonicalized", + base_path.display() + ); + + let package = manifest + .package + .as_ref() + .expect("fixture has package metadata"); + assert_eq!(package.id, "anki-geo.ultimate-geography"); + assert_eq!(package.version, "0.1.0"); + + assert_eq!(manifest.targets.len(), 71); + for target in manifest.targets.keys() { + assert!( + manifest.targets[target].exports.is_empty(), + "{target} relies on the manifest-target default CrowdAnki output path" + ); + + let deck = compose_target(&root, &manifest, target); + deck.validate() + .unwrap_or_else(|error| panic!("{target} validates: {error}")); + media::validate_references(&deck) + .unwrap_or_else(|error| panic!("{target} media references validate: {error}")); + } +} + +#[test] +fn ultimate_geography_hardcore_extension_builds_on_main_deck_without_erasing_base_content() { + let root = fixture_root(); + let manifest = read_manifest(&root); + + let english = compose_target(&root, &manifest, "en-hardcore-standard"); + assert_eq!(english.notes.len(), 336); + assert!(english.notes.contains_key(&sid("note.pitcairn-islands"))); + assert_eq!( + english.notes[&sid("note.anguilla")].fields[&sid("field.capital")], + "The Valley" + ); + assert_eq!( + english.notes[&sid("note.anguilla")].fields[&sid("field.map")], + "", + "Hardcore fills extra fields without blanking the main deck's existing map card" + ); + assert!( + english.notes[&sid("note.anguilla")] + .tags + .contains("UG::Overlapping") + ); + + let german = compose_target(&root, &manifest, "de-hardcore-standard"); + assert_eq!( + german.notes[&sid("note.pitcairn-islands")].fields[&sid("field.country")], + "Pitcairninseln" + ); + assert_eq!( + german.notes[&sid("note.canary-islands")].fields[&sid("field.capital")], + "Santa Cruz de Tenerife, Las Palmas de Gran Canaria" + ); + + let extended = compose_target(&root, &manifest, "de-hardcore-extended"); + assert_eq!( + extended.note_types[&sid("note-type.ultimate-geography")] + .card_templates + .len(), + 6, + "Hardcore composes with the shared Extended variant" + ); +} + +#[test] +fn ultimate_geography_translation_overlays_use_dictionaries_not_template_copies() { + let root = fixture_root(); + let manifest = read_manifest(&root); + for overlay_ref in manifest + .overlays + .values() + .filter(|overlay| overlay.kind.as_deref() == Some("translation")) + { + let overlay = canonical_yaml::overlay_from_str( + &fs::read_to_string(root.join(&overlay_ref.file)).unwrap(), + ) + .unwrap_or_else(|error| panic!("{} parses: {error}", overlay_ref.file)); + let translations = overlay + .translations + .as_ref() + .unwrap_or_else(|| panic!("{} uses translation dictionary", overlay_ref.file)); + assert!( + !translations.changes.is_empty() + || !translations.additions.is_empty() + || !translations.variables.is_empty() + || !translations.adapter_ids.is_empty(), + "{} has translation dictionary entries", + overlay_ref.file + ); + assert!( + overlay.note_changes.is_empty(), + "{} uses dictionary changes/additions instead of per-note field replacements", + overlay_ref.file + ); + assert!( + translations.changes.values().all(|change| match change { + TranslationChange::Global(_) => true, + TranslationChange::AtPaths(paths) => paths + .keys() + .all(|path| path != "note_types.note-type.ultimate-geography.name"), + }), + "{} translates note type names through variables instead of path-scoped metadata changes", + overlay_ref.file + ); + assert!( + overlay + .note_type_changes + .values() + .all(|change| change.name.is_none()), + "{} translates note type names through variables instead of path-scoped metadata changes", + overlay_ref.file + ); + assert!( + overlay + .note_type_changes + .values() + .all(|change| change.card_templates.is_empty()), + "{} does not copy standard card template HTML", + overlay_ref.file + ); + if overlay_ref + .file + .starts_with("overlays/extensions/hardcore/translations/") + { + assert!( + !overlay_ref.file.ends_with("/en.yaml"), + "English Hardcore field content is not a translation overlay" + ); + assert!( + translations.additions.is_empty(), + "{} uses field_fills for extension-owned blank field content instead of translations.additions", + overlay_ref.file + ); + } + } + + for overlay_ref in manifest.overlays.values().filter(|overlay| { + overlay + .file + .starts_with("overlays/extensions/hardcore/field-fills/") + }) { + assert_eq!( + overlay_ref.kind.as_deref(), + Some("extension"), + "{} is extension content, not translation content", + overlay_ref.file + ); + let overlay = canonical_yaml::overlay_from_str( + &fs::read_to_string(root.join(&overlay_ref.file)).unwrap(), + ) + .unwrap_or_else(|error| panic!("{} parses: {error}", overlay_ref.file)); + assert_eq!(overlay.kind, OverlayKind::Extension); + assert!( + overlay.translations.is_none(), + "{} has field_fills rather than a translation dictionary", + overlay_ref.file + ); + assert!( + !overlay.note_changes.is_empty(), + "{} lowers field_fills into checked note field changes", + overlay_ref.file + ); + } + + for overlay_ref in manifest + .overlays + .values() + .filter(|overlay| overlay.file.starts_with("overlays/variants/extended/")) + { + let overlay = canonical_yaml::overlay_from_str( + &fs::read_to_string(root.join(&overlay_ref.file)).unwrap(), + ) + .unwrap_or_else(|error| panic!("{} parses: {error}", overlay_ref.file)); + assert!( + overlay + .note_type_changes + .values() + .all(|change| change.card_templates.is_empty()), + "{} carries only language-specific extended metadata; shared card templates live in overlays/variants/extended.yaml", + overlay_ref.file + ); + } +} + +#[test] +fn ultimate_geography_fixture_exports_match_release_oracle_semantics_when_available() { + let oracle_root = ultimate_geography_release_oracle_root(); + if !oracle_root + .join("Ultimate Geography [EN]/deck.json") + .exists() + { + eprintln!( + "skipping Ultimate Geography release parity check; {} is missing. Run `scripts/fetch_ug_release_oracle.py --tag v5.3` or set BRAINBREW_UG_CROWDANKI_ORACLE to a CrowdAnki oracle root.", + oracle_root.display() + ); + return; + } + + let root = fixture_root(); + let manifest = read_manifest(&root); + for target in manifest + .targets + .keys() + .filter(|target| matches!(target_parts(target).1, "standard" | "extended")) + { + let deck = compose_target(&root, &manifest, target); + let export = crowdanki::export_deck(&deck) + .unwrap_or_else(|error| panic!("{target} exports to CrowdAnki: {error}")); + let new: serde_json::Value = serde_json::from_str(&export.deck_json).unwrap(); + let old: serde_json::Value = serde_json::from_str( + &fs::read_to_string(release_oracle_deck_json_path(&oracle_root, target)).unwrap(), + ) + .unwrap(); + + assert_crowdanki_semantic_subset_eq(&old, &new, target); + } +} + +#[test] +fn ug_regression_deck_metadata_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("deck name", |target, deck, _json| { + let new_name = format!("Regression Deck {target}"); + let mut deck_change = empty_deck_change(); + deck_change.name = Some(replace_property(new_name.clone(), deck.name.clone())); + let mut overlay = empty_overlay(&format!("overlay.regression.deck-name.{target}")); + overlay.deck_change = Some(deck_change); + MutationExpectation::new(overlay, vec![expected_json("/name", new_name)]) + }); + + assert_all_targets_export_exact_diffs("deck description", |target, deck, _json| { + let new_description = format!("Regression description for {target}"); + let mut deck_change = empty_deck_change(); + deck_change.description = Some(replace_property( + new_description.clone(), + deck.description.clone(), + )); + let mut overlay = empty_overlay(&format!("overlay.regression.deck-description.{target}")); + overlay.deck_change = Some(deck_change); + MutationExpectation::new(overlay, vec![expected_json("/desc", new_description)]) + }); + + assert_all_targets_export_exact_diffs("deck uuid", |target, deck, _json| { + let current_uuid = deck + .adapter_ids + .get("crowdanki:uuid") + .expect("UG fixture deck has CrowdAnki UUID") + .to_owned(); + let new_uuid = "11111111-1111-1111-1111-111111111111".to_owned(); + let mut deck_change = empty_deck_change(); + deck_change.adapter_ids.insert( + "crowdanki:uuid".to_owned(), + replace_adapter_id(new_uuid.clone(), current_uuid), + ); + let mut overlay = empty_overlay(&format!("overlay.regression.deck-uuid.{target}")); + overlay.deck_change = Some(deck_change); + MutationExpectation::new(overlay, vec![expected_json("/crowdanki_uuid", new_uuid)]) + }); + + assert_all_targets_export_exact_diffs("deck config name", |target, deck, _json| { + let current_name = deck + .adapter_ids + .get("crowdanki:deck_config_name") + .expect("UG fixture deck has CrowdAnki deck config name") + .to_owned(); + let new_name = format!("Regression Deck Config {target}"); + let mut deck_change = empty_deck_change(); + deck_change.adapter_ids.insert( + "crowdanki:deck_config_name".to_owned(), + replace_adapter_id(new_name.clone(), current_name), + ); + let mut overlay = empty_overlay(&format!("overlay.regression.deck-config-name.{target}")); + overlay.deck_change = Some(deck_change); + MutationExpectation::new( + overlay, + vec![expected_json("/deck_configurations/0/name", new_name)], + ) + }); + + assert_all_targets_export_exact_diffs("deck config uuid", |target, deck, _json| { + let current_uuid = deck + .adapter_ids + .get("crowdanki:deck_config_uuid") + .expect("UG fixture deck has CrowdAnki deck config UUID") + .to_owned(); + let new_uuid = "33333333-3333-3333-3333-333333333333".to_owned(); + let mut deck_change = empty_deck_change(); + deck_change.adapter_ids.insert( + "crowdanki:deck_config_uuid".to_owned(), + replace_adapter_id(new_uuid.clone(), current_uuid), + ); + let mut overlay = empty_overlay(&format!("overlay.regression.deck-config-uuid.{target}")); + overlay.deck_change = Some(deck_change); + MutationExpectation::new( + overlay, + vec![ + expected_json("/deck_config_uuid", new_uuid.clone()), + expected_json("/deck_configurations/0/crowdanki_uuid", new_uuid), + ], + ) + }); +} + +#[test] +fn ug_regression_note_type_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("note type name", |target, deck, _json| { + let note_type = ug_note_type(deck); + let new_name = format!("Regression Note Type {target}"); + let mut note_type_change = empty_note_type_change(); + note_type_change.name = Some(replace_property(new_name.clone(), note_type.name.clone())); + let mut overlay = empty_overlay(&format!("overlay.regression.note-type-name.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json("/note_models/0/name", new_name)], + ) + }); + + assert_all_targets_export_exact_diffs( + "note type variable rendered name", + |target, deck, _json| { + let note_type = ug_note_type(deck); + let old_source_name = note_type + .variables + .get("note-type.name") + .expect("UG note type has source name variable") + .clone(); + let suffix = note_type + .variables + .get("variant.name-suffix") + .map(String::as_str) + .unwrap_or_default(); + let new_source_name = format!("Regression Variable Name {target}"); + let mut note_type_change = empty_note_type_change(); + note_type_change.variables.insert( + "note-type.name".to_owned(), + replace_property(new_source_name.clone(), old_source_name), + ); + let mut overlay = + empty_overlay(&format!("overlay.regression.note-type-variable.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + "/note_models/0/name", + format!("{new_source_name}{suffix}"), + )], + ) + }, + ); + + assert_all_targets_export_exact_diffs( + "note type variable rendered templates", + |target, deck, baseline_json| { + let note_type = ug_note_type(deck); + let old_label = note_type + .variables + .get("label.capital") + .expect("UG note type has capital label variable") + .clone(); + let new_label = format!("Regression Capital Label {target}"); + let mut note_type_change = empty_note_type_change(); + note_type_change.variables.insert( + "label.capital".to_owned(), + replace_property(new_label.clone(), old_label.clone()), + ); + let mut overlay = empty_overlay(&format!( + "overlay.regression.note-type-template-variable.{target}" + )); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + + let mut expected = Vec::new(); + for (template_index, template) in baseline_json["note_models"][0]["tmpls"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + for property in ["qfmt", "afmt"] { + let current = template[property].as_str().unwrap(); + let old_fragment = format!("
{old_label}
"); + if current.contains(&old_fragment) { + let new_fragment = format!("
{new_label}
"); + expected.push(expected_json( + &format!("/note_models/0/tmpls/{template_index}/{property}"), + current.replace(&old_fragment, &new_fragment), + )); + } + } + } + MutationExpectation::new(overlay, expected) + }, + ); + + assert_all_targets_export_exact_diffs("note type styling", |target, deck, _json| { + let note_type = ug_note_type(deck); + let new_css = format!(".card {{ color: #123456; }} /* {target} */"); + let mut note_type_change = empty_note_type_change(); + note_type_change.styling = + Some(replace_property(new_css.clone(), note_type.styling.clone())); + let mut overlay = empty_overlay(&format!("overlay.regression.note-type-styling.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new(overlay, vec![expected_json("/note_models/0/css", new_css)]) + }); + + assert_all_targets_export_exact_diffs("note type uuid", |target, deck, baseline_json| { + let note_type = ug_note_type(deck); + let current_uuid = note_type + .adapter_ids + .get("crowdanki:uuid") + .expect("UG note type has CrowdAnki UUID") + .to_owned(); + let new_uuid = "22222222-2222-2222-2222-222222222222".to_owned(); + let mut note_type_change = empty_note_type_change(); + note_type_change.adapter_ids.insert( + "crowdanki:uuid".to_owned(), + replace_adapter_id(new_uuid.clone(), current_uuid), + ); + let mut overlay = empty_overlay(&format!("overlay.regression.note-type-uuid.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + + let mut expected = vec![expected_json( + "/note_models/0/crowdanki_uuid", + new_uuid.clone(), + )]; + for note_index in 0..baseline_json["notes"].as_array().unwrap().len() { + expected.push(expected_json( + &format!("/notes/{note_index}/note_model_uuid"), + new_uuid.clone(), + )); + } + MutationExpectation::new(overlay, expected) + }); +} + +#[test] +fn ug_regression_field_definition_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("field definition name", |target, deck, _json| { + let note_type = ug_note_type(deck); + let field_id = sid("field.capital"); + assert!( + note_type.fields.iter().any(|field| field.id == field_id), + "UG note type has capital field" + ); + let new_name = format!("Regression Capital Field {target}"); + let mut note_type_change = empty_note_type_change(); + note_type_change.fields.insert( + field_id.clone(), + FieldDefinitionChange { + intent: ChangeIntent::Override, + field: Some(FieldDefinition { + id: field_id, + name: new_name.clone(), + }), + expected_base: Some(ExpectedBase::EntityPresent), + }, + ); + let mut overlay = empty_overlay(&format!("overlay.regression.field-name.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!( + "/note_models/0/flds/{}/name", + field_index(deck, "field.capital") + ), + new_name, + )], + ) + }); + + assert_all_targets_export_exact_diffs("field addition", |target, deck, baseline_json| { + let note_type = ug_note_type(deck); + let field_id = sid("field.zzz-regression"); + let field_name = format!("Regression Added Field {target}"); + let field_count = note_type.fields.len(); + let finland_guid = note_guid(deck, "note.finland"); + let finland_value = format!("Regression field value {target}"); + + let mut note_type_change = empty_note_type_change(); + note_type_change.fields.insert( + field_id.clone(), + FieldDefinitionChange { + intent: ChangeIntent::Add, + field: Some(FieldDefinition { + id: field_id.clone(), + name: field_name.clone(), + }), + expected_base: None, + }, + ); + + let mut note_change = empty_note_change(); + note_change.fields.insert( + field_id, + FieldChange { + intent: ChangeIntent::Add, + value: Some(finland_value.clone()), + expected_base: None, + }, + ); + + let mut overlay = empty_overlay(&format!("overlay.regression.field-addition.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + overlay + .note_changes + .insert(sid("note.finland"), note_change); + + let mut expected = vec![ExpectedJsonValue { + path: format!("/note_models/0/flds/{field_count}"), + value: Some(serde_json::json!({ + "font": "Arial", + "media": [], + "name": field_name, + "ord": field_count, + "rtl": false, + "size": 20, + "sticky": false, + })), + }]; + for (note_index, note) in baseline_json["notes"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let value = if note["guid"].as_str() == Some(finland_guid.as_str()) { + finland_value.clone() + } else { + String::new() + }; + expected.push(expected_json( + &format!("/notes/{note_index}/fields/{field_count}"), + value, + )); + } + MutationExpectation::new(overlay, expected) + }); +} + +#[test] +fn ug_regression_card_template_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("template name", |target, deck, _json| { + let note_type = ug_note_type(deck); + let template_id = sid("template.country-capital"); + let template = note_type + .card_templates + .iter() + .find(|template| template.id == template_id) + .expect("UG note type has Country - Capital template"); + let new_name = format!("Regression Template Name {target}"); + let mut template_change = empty_card_template_change(); + template_change.name = Some(replace_property(new_name.clone(), template.name.clone())); + let mut note_type_change = empty_note_type_change(); + note_type_change + .card_templates + .insert(template_id, template_change); + let mut overlay = empty_overlay(&format!("overlay.regression.template-name.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!( + "/note_models/0/tmpls/{}/name", + template_index(deck, "template.country-capital") + ), + new_name, + )], + ) + }); + + assert_all_targets_export_exact_diffs("template question", |target, deck, _json| { + let note_type = ug_note_type(deck); + let template_id = sid("template.country-capital"); + let template = note_type + .card_templates + .iter() + .find(|template| template.id == template_id) + .expect("UG note type has Country - Capital template"); + let new_question = format!("
Regression question {target}
"); + let mut template_change = empty_card_template_change(); + template_change.question_format = Some(replace_property( + new_question.clone(), + template.question_format.clone(), + )); + let mut note_type_change = empty_note_type_change(); + note_type_change + .card_templates + .insert(template_id, template_change); + let mut overlay = empty_overlay(&format!("overlay.regression.template-question.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!( + "/note_models/0/tmpls/{}/qfmt", + template_index(deck, "template.country-capital") + ), + new_question, + )], + ) + }); + + assert_all_targets_export_exact_diffs("template answer", |target, deck, _json| { + let note_type = ug_note_type(deck); + let template_id = sid("template.country-capital"); + let template = note_type + .card_templates + .iter() + .find(|template| template.id == template_id) + .expect("UG note type has Country - Capital template"); + let new_answer = format!("
Regression answer {target}
"); + let mut template_change = empty_card_template_change(); + template_change.answer_format = Some(replace_property( + new_answer.clone(), + template.answer_format.clone(), + )); + let mut note_type_change = empty_note_type_change(); + note_type_change + .card_templates + .insert(template_id, template_change); + let mut overlay = empty_overlay(&format!("overlay.regression.template-answer.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!( + "/note_models/0/tmpls/{}/afmt", + template_index(deck, "template.country-capital") + ), + new_answer, + )], + ) + }); + + assert_all_targets_export_exact_diffs( + "template variable rendered question", + |target, deck, _json| { + let note_type = ug_note_type(deck); + let template_id = sid("template.country-capital"); + let template = note_type + .card_templates + .iter() + .find(|template| template.id == template_id) + .expect("UG note type has Country - Capital template"); + let rendered_value = format!("Regression template variable {target}"); + let mut template_change = empty_card_template_change(); + template_change.variables.insert( + "regression.template".to_owned(), + PropertyChange { + intent: ChangeIntent::Add, + value: Some(rendered_value.clone()), + expected_base: None, + }, + ); + template_change.question_format = Some(replace_property( + "${regression.template}".to_owned(), + template.question_format.clone(), + )); + let mut note_type_change = empty_note_type_change(); + note_type_change + .card_templates + .insert(template_id, template_change); + let mut overlay = + empty_overlay(&format!("overlay.regression.template-variable.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!( + "/note_models/0/tmpls/{}/qfmt", + template_index(deck, "template.country-capital") + ), + rendered_value, + )], + ) + }, + ); + + assert_all_targets_export_exact_diffs("template addition", |target, deck, _json| { + let note_type = ug_note_type(deck); + let template_count = note_type.card_templates.len(); + let template_id = sid("template.zzz-regression"); + let mut template_change = empty_card_template_change(); + template_change.intent = ChangeIntent::Add; + template_change.insert_after = note_type + .card_templates + .last() + .map(|template| template.id.clone()); + template_change.template = Some(brain_brew_formats::core::CardTemplate { + id: template_id.clone(), + name: format!("Regression Added Template {target}"), + variables: BTreeMap::new(), + question_format: format!("Regression added question {target}"), + answer_format: format!("Regression added answer {target}"), + adapter_ids: AdapterIds::new(), + }); + let expected_template = template_change.template.as_ref().unwrap().clone(); + let mut note_type_change = empty_note_type_change(); + note_type_change + .card_templates + .insert(template_id, template_change); + let mut overlay = empty_overlay(&format!("overlay.regression.template-addition.{target}")); + overlay + .note_type_changes + .insert(note_type.id.clone(), note_type_change); + MutationExpectation::new( + overlay, + vec![ExpectedJsonValue { + path: format!("/note_models/0/tmpls/{template_count}"), + value: Some(serde_json::json!({ + "afmt": expected_template.answer_format, + "bafmt": "", + "bfont": "", + "bqfmt": "", + "bsize": 0, + "did": null, + "name": expected_template.name, + "ord": template_count, + "qfmt": expected_template.question_format, + "scratchPad": 0, + })), + }], + ) + }); +} + +#[test] +fn ug_regression_note_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("note field", |target, deck, _json| { + let note_id = sid("note.finland"); + let field_id = sid("field.capital"); + let current_value = deck.notes[¬e_id].fields[&field_id].clone(); + let new_value = format!("Regression capital {target}"); + let mut note_change = empty_note_change(); + note_change.fields.insert( + field_id, + FieldChange { + intent: ChangeIntent::Override, + value: Some(new_value.clone()), + expected_base: Some(ExpectedBase::Value(current_value)), + }, + ); + let mut overlay = empty_overlay(&format!("overlay.regression.note-field.{target}")); + overlay.note_changes.insert(note_id, note_change); + MutationExpectation::new( + overlay, + vec![expected_json( + ¬e_field_json_path(deck, "note.finland", "field.capital"), + new_value, + )], + ) + }); + + assert_all_targets_export_exact_diffs("note variable rendered field", |target, deck, _json| { + let note_id = sid("note.finland"); + let field_id = sid("field.country"); + let current_value = deck.notes[¬e_id].fields[&field_id].clone(); + let rendered_value = format!("Regression rendered country {target}"); + let mut note_change = empty_note_change(); + note_change.variables.insert( + "regression.country".to_owned(), + PropertyChange { + intent: ChangeIntent::Add, + value: Some(rendered_value.clone()), + expected_base: None, + }, + ); + note_change.fields.insert( + field_id, + FieldChange { + intent: ChangeIntent::Override, + value: Some("${regression.country}".to_owned()), + expected_base: Some(ExpectedBase::Value(current_value)), + }, + ); + let mut overlay = empty_overlay(&format!("overlay.regression.note-variable.{target}")); + overlay.note_changes.insert(note_id, note_change); + MutationExpectation::new( + overlay, + vec![expected_json( + ¬e_field_json_path(deck, "note.finland", "field.country"), + rendered_value, + )], + ) + }); + + assert_all_targets_export_exact_diffs("note tag", |target, deck, baseline_json| { + let tag = "ZZZ::Regression".to_owned(); + let mut note_change = empty_note_change(); + note_change.tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Add, + expected_base: None, + }, + ); + let mut overlay = empty_overlay(&format!("overlay.regression.note-tag.{target}")); + overlay + .note_changes + .insert(sid("note.finland"), note_change); + let note_index = note_json_index(baseline_json, ¬e_guid(deck, "note.finland")); + let tag_index = baseline_json["notes"][note_index]["tags"] + .as_array() + .unwrap() + .len(); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!("/notes/{note_index}/tags/{tag_index}"), + tag, + )], + ) + }); + + assert_all_targets_export_exact_diffs("note guid", |target, deck, baseline_json| { + let note_id = sid("note.finland"); + let current_guid = note_guid(deck, "note.finland"); + let new_guid = format!("regression-guid-{target}"); + let mut note_change = empty_note_change(); + note_change.adapter_ids.insert( + "crowdanki:guid".to_owned(), + replace_adapter_id(new_guid.clone(), current_guid.clone()), + ); + let mut overlay = empty_overlay(&format!("overlay.regression.note-guid.{target}")); + overlay.note_changes.insert(note_id, note_change); + let note_index = note_json_index(baseline_json, ¤t_guid); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!("/notes/{note_index}/guid"), + new_guid, + )], + ) + }); + + assert_all_targets_export_exact_diffs("note removal", |target, deck, baseline_json| { + let (note_id, note) = deck + .notes + .iter() + .rev() + .find(|(note_id, _)| !deck.tombstones.contains(note_id)) + .expect("UG fixture has at least one exported note"); + let note_index = baseline_json["notes"].as_array().unwrap().len() - 1; + assert_eq!( + baseline_json["notes"][note_index]["guid"].as_str(), + note.adapter_ids.get("crowdanki:guid"), + "test removes the final exported note so the exact CrowdAnki diff is one missing array item" + ); + let mut note_change = empty_note_change(); + note_change.intent = ChangeIntent::Remove; + note_change.expected_base = Some(ExpectedBase::EntityPresent); + let mut overlay = empty_overlay(&format!("overlay.regression.note-removal.{target}")); + overlay.note_changes.insert(note_id.clone(), note_change); + MutationExpectation::new( + overlay, + vec![expected_absent(&format!("/notes/{note_index}"))], + ) + }); + + assert_all_targets_export_exact_diffs("note addition", |target, deck, baseline_json| { + let note_type = ug_note_type(deck); + let note_id = sid("note.zzz-regression"); + let guid = format!("regression-added-note-{target}"); + let mut adapter_ids = AdapterIds::new(); + adapter_ids.insert("crowdanki:guid", guid.clone()); + let fields = note_type + .fields + .iter() + .map(|field| { + ( + field.id.clone(), + format!("Regression {} {target}", field.name), + ) + }) + .collect::>(); + let expected_fields = note_type + .fields + .iter() + .map(|field| fields[&field.id].clone()) + .collect::>(); + let note = Note { + id: note_id.clone(), + note_type_id: note_type.id.clone(), + variables: BTreeMap::new(), + fields, + tags: BTreeSet::from(["ZZZ::Regression".to_owned()]), + adapter_ids, + }; + let mut note_change = empty_note_change(); + note_change.intent = ChangeIntent::Add; + note_change.note = Some(note); + let mut overlay = empty_overlay(&format!("overlay.regression.note-addition.{target}")); + overlay.note_changes.insert(note_id, note_change); + let note_index = baseline_json["notes"].as_array().unwrap().len(); + MutationExpectation::new( + overlay, + vec![ExpectedJsonValue { + path: format!("/notes/{note_index}"), + value: Some(serde_json::json!({ + "__type__": "Note", + "data": "", + "fields": expected_fields, + "flags": 0, + "guid": guid, + "note_model_uuid": baseline_json["note_models"][0]["crowdanki_uuid"].as_str().unwrap(), + "tags": ["ZZZ::Regression"], + })), + }], + ) + }); +} + +#[test] +fn ug_regression_media_changes_flow_to_crowdanki_for_every_target() { + assert_all_targets_export_exact_diffs("media addition", |target, _deck, baseline_json| { + let path = format!("zzzz-regression-{target}.css"); + let mut overlay = empty_overlay(&format!("overlay.regression.media-addition.{target}")); + overlay.media_changes.insert( + sid("media.zzzz-regression"), + MediaChange { + intent: ChangeIntent::Add, + media: Some(MediaReference { + id: sid("media.zzzz-regression"), + path: path.clone(), + sha256: String::new(), + }), + expected_base: None, + }, + ); + let media_index = baseline_json["media_files"].as_array().unwrap().len(); + MutationExpectation::new( + overlay, + vec![expected_json(&format!("/media_files/{media_index}"), path)], + ) + }); + + assert_all_targets_export_exact_diffs("media path override", |target, deck, baseline_json| { + let (media_id, media) = deck.media.iter().next_back().expect("UG fixture has media"); + let media_index = baseline_json["media_files"].as_array().unwrap().len() - 1; + assert_eq!( + baseline_json["media_files"][media_index].as_str(), + Some(media.path.as_str()), + "test updates the final exported media path so the exact CrowdAnki diff is one array item" + ); + let new_path = format!("zzzz-regression-override-{target}.css"); + let mut overlay = empty_overlay(&format!("overlay.regression.media-path.{target}")); + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Override, + media: Some(MediaReference { + id: media_id.clone(), + path: new_path.clone(), + sha256: media.sha256.clone(), + }), + expected_base: Some(ExpectedBase::EntityPresent), + }, + ); + MutationExpectation::new( + overlay, + vec![expected_json( + &format!("/media_files/{media_index}"), + new_path, + )], + ) + }); +} + +fn compose_target( + root: &Path, + manifest: &manifest::FederatedDeckManifest, + target: &str, +) -> CanonicalDeck { + compose_target_with_extra_overlay(root, manifest, target, None) +} + +fn compose_target_with_extra_overlay( + root: &Path, + manifest: &manifest::FederatedDeckManifest, + target: &str, + extra_overlay: Option, +) -> CanonicalDeck { + let expanded = manifest + .expand_target(target) + .unwrap_or_else(|error| panic!("{target} expands: {error}")); + let base = canonical_yaml::from_str(&fs::read_to_string(root.join(&expanded.base)).unwrap()) + .unwrap_or_else(|error| panic!("{target} base parses: {error}")); + let mut overlays = expanded + .overlays + .iter() + .map(|overlay| { + canonical_yaml::overlay_from_str(&fs::read_to_string(root.join(&overlay.file)).unwrap()) + .unwrap_or_else(|error| panic!("{target} overlay {} parses: {error}", overlay.id)) + }) + .collect::>(); + if let Some(extra_overlay) = extra_overlay { + overlays.push(extra_overlay); + } + base.compose(&overlays) + .unwrap_or_else(|error| panic!("{target} composes: {error}")) +} + +struct MutationExpectation { + overlay: Overlay, + expected: Vec, +} + +impl MutationExpectation { + fn new(overlay: Overlay, expected: Vec) -> Self { + Self { overlay, expected } + } +} + +struct ExpectedJsonValue { + path: String, + value: Option, +} + +fn assert_all_targets_export_exact_diffs( + case_name: &str, + build: impl Fn(&str, &CanonicalDeck, &serde_json::Value) -> MutationExpectation, +) { + let root = fixture_root(); + let manifest = read_manifest(&root); + for target in manifest.targets.keys() { + let baseline_deck = compose_target(&root, &manifest, target); + let baseline_json = exported_json(&baseline_deck); + let expectation = build(target, &baseline_deck, &baseline_json); + assert!( + !expectation.expected.is_empty(), + "{case_name} for {target} must expect at least one CrowdAnki difference" + ); + + let changed_deck = + compose_target_with_extra_overlay(&root, &manifest, target, Some(expectation.overlay)); + let changed_json = exported_json(&changed_deck); + let actual_paths = json_diff_paths(&baseline_json, &changed_json); + let expected_paths = expectation + .expected + .iter() + .map(|expected| expected.path.clone()) + .collect::>(); + assert_eq!( + actual_paths, expected_paths, + "{case_name} for {target} changed unexpected CrowdAnki JSON paths" + ); + for expected in expectation.expected { + match expected.value { + Some(value) => assert_eq!( + changed_json.pointer(&expected.path), + Some(&value), + "{case_name} for {target} expected CrowdAnki value at {}", + expected.path + ), + None => assert!( + changed_json.pointer(&expected.path).is_none(), + "{case_name} for {target} expected no CrowdAnki value at {}", + expected.path + ), + } + } + } +} + +fn exported_json(deck: &CanonicalDeck) -> serde_json::Value { + let export = crowdanki::export_deck(deck).expect("deck exports to CrowdAnki"); + serde_json::from_str(&export.deck_json).expect("CrowdAnki export is JSON") +} + +fn json_diff_paths(left: &serde_json::Value, right: &serde_json::Value) -> BTreeSet { + let mut paths = BTreeSet::new(); + collect_json_diff_paths(left, right, "", &mut paths); + paths +} + +fn collect_json_diff_paths( + left: &serde_json::Value, + right: &serde_json::Value, + path: &str, + paths: &mut BTreeSet, +) { + match (left, right) { + (serde_json::Value::Object(left), serde_json::Value::Object(right)) => { + let keys = left.keys().chain(right.keys()).collect::>(); + for key in keys { + let child_path = format!("{path}/{}", json_pointer_token(key)); + match (left.get(key), right.get(key)) { + (Some(left), Some(right)) => { + collect_json_diff_paths(left, right, &child_path, paths) + } + _ => { + paths.insert(child_path); + } + } + } + } + (serde_json::Value::Array(left), serde_json::Value::Array(right)) => { + for index in 0..left.len().max(right.len()) { + let child_path = format!("{path}/{index}"); + match (left.get(index), right.get(index)) { + (Some(left), Some(right)) => { + collect_json_diff_paths(left, right, &child_path, paths) + } + _ => { + paths.insert(child_path); + } + } + } + } + _ if left == right => {} + _ => { + paths.insert(if path.is_empty() { + "/".to_owned() + } else { + path.to_owned() + }); + } + } +} + +fn json_pointer_token(token: &str) -> String { + token.replace('~', "~0").replace('/', "~1") +} + +fn expected_json(path: &str, value: impl Into) -> ExpectedJsonValue { + ExpectedJsonValue { + path: path.to_owned(), + value: Some(value.into()), + } +} + +fn expected_absent(path: &str) -> ExpectedJsonValue { + ExpectedJsonValue { + path: path.to_owned(), + value: None, + } +} + +fn empty_overlay(id: &str) -> Overlay { + Overlay { + id: sid(id), + kind: OverlayKind::Patch, + translations: None, + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + } +} + +fn empty_deck_change() -> DeckChange { + DeckChange { + name: None, + description: None, + variables: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + } +} + +fn empty_note_type_change() -> NoteTypeChange { + NoteTypeChange { + intent: ChangeIntent::Merge, + note_type: None, + name: None, + variables: BTreeMap::new(), + styling: None, + fields: BTreeMap::new(), + card_templates: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + } +} + +fn empty_card_template_change() -> CardTemplateChange { + CardTemplateChange { + intent: ChangeIntent::Merge, + template: None, + insert_after: None, + name: None, + variables: BTreeMap::new(), + question_format: None, + answer_format: None, + adapter_ids: BTreeMap::new(), + expected_base: None, + } +} + +fn empty_note_change() -> NoteChange { + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + } +} + +fn replace_property(value: String, expected_base: String) -> PropertyChange { + PropertyChange { + intent: ChangeIntent::Override, + value: Some(value), + expected_base: Some(ExpectedBase::Value(expected_base)), + } +} + +fn replace_adapter_id(value: String, expected_base: String) -> AdapterIdChange { + AdapterIdChange { + intent: ChangeIntent::Override, + value: Some(value), + expected_base: Some(ExpectedBase::Value(expected_base)), + } +} + +fn ug_note_type(deck: &CanonicalDeck) -> &brain_brew_formats::core::NoteType { + deck.note_types + .get(&sid("note-type.ultimate-geography")) + .expect("UG fixture has ultimate geography note type") +} + +fn field_index(deck: &CanonicalDeck, field_id: &str) -> usize { + let field_id = sid(field_id); + ug_note_type(deck) + .fields + .iter() + .position(|field| field.id == field_id) + .unwrap_or_else(|| panic!("field {field_id} exists")) +} + +fn template_index(deck: &CanonicalDeck, template_id: &str) -> usize { + let template_id = sid(template_id); + ug_note_type(deck) + .card_templates + .iter() + .position(|template| template.id == template_id) + .unwrap_or_else(|| panic!("template {template_id} exists")) +} + +fn note_guid(deck: &CanonicalDeck, note_id: &str) -> String { + deck.notes[&sid(note_id)] + .adapter_ids + .get("crowdanki:guid") + .unwrap_or_else(|| panic!("{note_id} has CrowdAnki guid")) + .to_owned() +} + +fn note_json_index(json: &serde_json::Value, guid: &str) -> usize { + json["notes"] + .as_array() + .unwrap() + .iter() + .position(|note| note["guid"].as_str() == Some(guid)) + .unwrap_or_else(|| panic!("CrowdAnki note {guid} exists")) +} + +fn note_field_json_path(deck: &CanonicalDeck, note_id: &str, field_id: &str) -> String { + let json = exported_json(deck); + let note_index = note_json_index(&json, ¬e_guid(deck, note_id)); + let field_index = field_index(deck, field_id); + format!("/notes/{note_index}/fields/{field_index}") +} + +fn read_manifest(root: &Path) -> manifest::FederatedDeckManifest { + manifest::from_str(&fs::read_to_string(root.join("brainbrew.yaml")).unwrap()) + .expect("manifest parses") +} + +fn release_oracle_deck_json_path(root: &Path, target: &str) -> PathBuf { + let (language, variant) = target_parts(target); + let suffix = if variant == "extended" { + " [Extended]" + } else { + "" + }; + root.join(format!("Ultimate Geography [{language}]{suffix}/deck.json")) +} + +fn target_parts(target: &str) -> (&str, &str) { + if let Some(variant) = target.strip_prefix("zh-tw-") { + return ("ZH-TW", variant); + } + let (language, variant) = target.split_once('-').unwrap(); + (language.to_ascii_uppercase().leak(), variant) +} + +fn assert_crowdanki_semantic_subset_eq( + old: &serde_json::Value, + new: &serde_json::Value, + target: &str, +) { + assert_eq!(new["name"], old["name"], "{target} deck name"); + assert_eq!( + new["crowdanki_uuid"], old["crowdanki_uuid"], + "{target} deck UUID" + ); + assert_eq!(new["desc"], old["desc"], "{target} deck description"); + + assert_eq!( + string_set(new["media_files"].as_array().unwrap()), + string_set(old["media_files"].as_array().unwrap()), + "{target} media files" + ); + + let old_model = &old["note_models"][0]; + let new_model = &new["note_models"][0]; + assert_eq!(new_model["name"], old_model["name"], "{target} model name"); + assert_eq!( + new_model["crowdanki_uuid"], old_model["crowdanki_uuid"], + "{target} model UUID" + ); + assert_eq!(new_model["css"], old_model["css"], "{target} CSS"); + assert_eq!( + field_names(new_model), + field_names(old_model), + "{target} fields" + ); + assert_eq!( + templates_by_ord(new_model), + templates_by_ord(old_model), + "{target} templates" + ); + + let old_notes = notes_by_guid(old); + let new_notes = notes_by_guid(new); + assert_eq!(new_notes.len(), old_notes.len(), "{target} note count"); + for (guid, old_note) in old_notes { + let new_note = new_notes + .get(&guid) + .unwrap_or_else(|| panic!("{target} missing note {guid}")); + assert_eq!( + new_note["note_model_uuid"], old_note["note_model_uuid"], + "{target} note model differs for {guid}" + ); + assert_eq!( + new_note["fields"], old_note["fields"], + "{target} fields differ for {guid}" + ); + assert_eq!( + string_set(new_note["tags"].as_array().unwrap()), + string_set(old_note["tags"].as_array().unwrap()), + "{target} tags differ for {guid}" + ); + } +} + +fn field_names(model: &serde_json::Value) -> Vec { + model["flds"] + .as_array() + .unwrap() + .iter() + .map(|field| field["name"].as_str().unwrap().to_owned()) + .collect() +} + +fn templates_by_ord(model: &serde_json::Value) -> BTreeMap { + model["tmpls"] + .as_array() + .unwrap() + .iter() + .map(|template| { + ( + template["ord"].as_i64().unwrap(), + ( + template["name"].as_str().unwrap().to_owned(), + template["qfmt"].as_str().unwrap().to_owned(), + template["afmt"].as_str().unwrap().to_owned(), + ), + ) + }) + .collect() +} + +fn notes_by_guid(deck: &serde_json::Value) -> BTreeMap { + deck["notes"] + .as_array() + .unwrap() + .iter() + .map(|note| (note["guid"].as_str().unwrap().to_owned(), note)) + .collect() +} + +fn string_set(values: &[serde_json::Value]) -> BTreeSet { + values + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect() +} + +fn sid(value: &str) -> StableId { + StableId::new(value).expect("test stable id is valid") +} + +fn fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/ultimate-geography") +} + +fn ultimate_geography_release_oracle_root() -> PathBuf { + std::env::var_os("BRAINBREW_UG_CROWDANKI_ORACLE") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.cache/brainbrew/ug-release-oracle/v5.3/crowdanki") + }) +} diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..f9e5a5c --- /dev/null +++ b/devbox.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", + "packages": [ + "rustc@latest", + "cargo@latest", + "rustfmt@latest", + "clippy@latest", + "nodejs@20", + "cargo-dist@0.30.4" + ], + "shell": { + "init_hook": [ + "echo 'Brain Brew dev environment ready' > /dev/null" + ], + "scripts": { + "fmt": [ + "cargo fmt --all" + ], + "fmt:check": [ + "cargo fmt --all -- --check" + ], + "check": [ + "cargo check --workspace --all-targets" + ], + "test": [ + "cargo test --workspace --all-targets" + ], + "clippy": [ + "cargo clippy --workspace --all-targets -- -D warnings" + ], + "docs:install": [ + "npm --prefix documentation install" + ], + "docs:start": [ + "npm --prefix documentation run start" + ], + "docs:build": [ + "npm --prefix documentation run build" + ], + "dist:generate": [ + "dist generate" + ], + "dist:plan": [ + "dist manifest --tag v1.0.0-alpha.1 --artifacts=all --no-local-paths --output-format=json" + ], + "ci": [ + "cargo fmt --all -- --check", + "cargo test --workspace --all-targets", + "cargo clippy --workspace --all-targets -- -D warnings" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..f6ff2a8 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,343 @@ +{ + "lockfile_version": "1", + "packages": { + "cargo-dist@0.30.4": { + "last_modified": "2026-04-23T13:07:47Z", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#cargo-dist", + "source": "devbox-search", + "version": "0.30.4", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d1648d8yiyq1bghrs4l407lxpqfh3rnj-cargo-dist-0.30.4", + "default": true + } + ], + "store_path": "/nix/store/d1648d8yiyq1bghrs4l407lxpqfh3rnj-cargo-dist-0.30.4" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qggdjivh3vma106jq4gncn2zmmp6gapr-cargo-dist-0.30.4", + "default": true + } + ], + "store_path": "/nix/store/qggdjivh3vma106jq4gncn2zmmp6gapr-cargo-dist-0.30.4" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ayfz60j7fr9pbfl0gj9h1v0nkzlh5dga-cargo-dist-0.30.4", + "default": true + } + ], + "store_path": "/nix/store/ayfz60j7fr9pbfl0gj9h1v0nkzlh5dga-cargo-dist-0.30.4" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/j5sfaksgwvpn3d935wkqmvjm3nf9d3ha-cargo-dist-0.30.4", + "default": true + } + ], + "store_path": "/nix/store/j5sfaksgwvpn3d935wkqmvjm3nf9d3ha-cargo-dist-0.30.4" + } + } + }, + "cargo@latest": { + "last_modified": "2026-04-23T13:07:47Z", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#cargo", + "source": "devbox-search", + "version": "1.94.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/mlrgd5zljyrxl15ryn31w15w48f6yii5-cargo-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/mlrgd5zljyrxl15ryn31w15w48f6yii5-cargo-1.94.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fvmmpmsik6551c334wgvx8smifj2fibf-cargo-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/fvmmpmsik6551c334wgvx8smifj2fibf-cargo-1.94.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/10bhh3gsrw2xfg3fr53pip6rdzcz2sfp-cargo-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/10bhh3gsrw2xfg3fr53pip6rdzcz2sfp-cargo-1.94.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z382dzkk7snk51ka6n4f3b953dcdm8fc-cargo-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/z382dzkk7snk51ka6n4f3b953dcdm8fc-cargo-1.94.1" + } + } + }, + "clippy@latest": { + "last_modified": "2026-04-23T13:07:47Z", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#clippy", + "source": "devbox-search", + "version": "1.94.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z9jrilf49s8prkiicxlfapbi6hj9dz03-clippy-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/z9jrilf49s8prkiicxlfapbi6hj9dz03-clippy-1.94.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/0ag0s1wxh9l5fldh6j0318qpmzy7sn5r-clippy-1.94.1", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/svpaxsn34mgdzhgyrgdvw68fl14g1s8x-clippy-1.94.1-debug" + } + ], + "store_path": "/nix/store/0ag0s1wxh9l5fldh6j0318qpmzy7sn5r-clippy-1.94.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/874dlfzia6nhc8f9iwqnh8cg79ga3rw4-clippy-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/874dlfzia6nhc8f9iwqnh8cg79ga3rw4-clippy-1.94.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/wavkh1sj2fb7cclydhj1q15k7dcha91x-clippy-1.94.1", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/2970l2zp3wwb361mabagg9gfgnwkn9s2-clippy-1.94.1-debug" + } + ], + "store_path": "/nix/store/wavkh1sj2fb7cclydhj1q15k7dcha91x-clippy-1.94.1" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-05-15T18:21:44Z", + "resolved": "github:NixOS/nixpkgs/d233902339c02a9c334e7e593de68855ad26c4cb?lastModified=1778869304&narHash=sha256-30sZNZoA1cqF5JNO9fVX%2BwgiQYjB7HJqqJ4ztCDeBZE%3D" + }, + "nodejs@20": { + "last_modified": "2026-04-23T13:07:47Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#nodejs_20", + "source": "devbox-search", + "version": "20.20.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gvr230jrjzznrhlmfymjj37x0dx3srvv-nodejs-20.20.2", + "default": true + } + ], + "store_path": "/nix/store/gvr230jrjzznrhlmfymjj37x0dx3srvv-nodejs-20.20.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/jx5whkya850cy6a1fjf74p5dfqlcp1cq-nodejs-20.20.2", + "default": true + } + ], + "store_path": "/nix/store/jx5whkya850cy6a1fjf74p5dfqlcp1cq-nodejs-20.20.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ggqdy8b4b62gr1ak2v41dka5s1dmflzk-nodejs-20.20.2", + "default": true + } + ], + "store_path": "/nix/store/ggqdy8b4b62gr1ak2v41dka5s1dmflzk-nodejs-20.20.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/nr0dnrnibq8114xa0i7zr7qg6n05q03g-nodejs-20.20.2", + "default": true + } + ], + "store_path": "/nix/store/nr0dnrnibq8114xa0i7zr7qg6n05q03g-nodejs-20.20.2" + } + } + }, + "rustc@latest": { + "last_modified": "2026-04-23T13:07:47Z", + "plugin_version": "0.0.1", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#rustc", + "source": "devbox-search", + "version": "1.94.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ldg6i5q2wwb0j4nnd1939cn7hdlsf8xl-rustc-wrapper-1.94.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/gljrw9zcrgnbmwgdxpw75klxf26i81sa-rustc-wrapper-1.94.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/d0h4z9rx30ipgxb0r8pp2iy01fqmbvdn-rustc-wrapper-1.94.1-doc" + } + ], + "store_path": "/nix/store/ldg6i5q2wwb0j4nnd1939cn7hdlsf8xl-rustc-wrapper-1.94.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ixn1awaxpvayfp5na0b2gr0kianim6g8-rustc-wrapper-1.94.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/fvzjq79fryqp523s5dyqfwyfj2wi8afb-rustc-wrapper-1.94.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/impmc1i7ifkbd4wvlxcybi0jhrafwxgb-rustc-wrapper-1.94.1-doc" + } + ], + "store_path": "/nix/store/ixn1awaxpvayfp5na0b2gr0kianim6g8-rustc-wrapper-1.94.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/szlbg4mqziywqn196x89q6n7xqdbx7dz-rustc-wrapper-1.94.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/wzrjq7km3gicbw2ag873hd9yf8qk2rn8-rustc-wrapper-1.94.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/f2iqkj8xgpjqsh9idpywrg7bgh16jw2i-rustc-wrapper-1.94.1-doc" + } + ], + "store_path": "/nix/store/szlbg4mqziywqn196x89q6n7xqdbx7dz-rustc-wrapper-1.94.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ph8jb0mw89p4lfshpp36z70l6r4kg3vh-rustc-wrapper-1.94.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/h68fmikviqzaspyx3ccghx6cvr69cds3-rustc-wrapper-1.94.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/kbnirgis5p5wlqb3wk11vgxa0c966lck-rustc-wrapper-1.94.1-doc" + } + ], + "store_path": "/nix/store/ph8jb0mw89p4lfshpp36z70l6r4kg3vh-rustc-wrapper-1.94.1" + } + } + }, + "rustfmt@latest": { + "last_modified": "2026-04-23T13:07:47Z", + "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#rustfmt", + "source": "devbox-search", + "version": "1.94.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/371msib7kcxjsjhck6562pfa0q9nn8mw-rustfmt-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/371msib7kcxjsjhck6562pfa0q9nn8mw-rustfmt-1.94.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/y466x0p4r8qjz5z4qpcjd16y8vmlzhl3-rustfmt-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/y466x0p4r8qjz5z4qpcjd16y8vmlzhl3-rustfmt-1.94.1" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/xz887wb672i5sb1p5yj8mjm1190np784-rustfmt-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/xz887wb672i5sb1p5yj8mjm1190np784-rustfmt-1.94.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/rsshbz486a0ka567awpbkbawaqdr40pd-rustfmt-1.94.1", + "default": true + } + ], + "store_path": "/nix/store/rsshbz486a0ka567awpbkbawaqdr40pd-rustfmt-1.94.1" + } + } + } + } +} diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..98a0411 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,22 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.30.4" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell", "homebrew"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Publish the generated Homebrew formula to jeprecated/homebrew-tap +publish-jobs = ["homebrew"] +tap = "jeprecated/homebrew-tap" +# Publish preview releases to the tap while Brain Brew is still pre-1.0. +publish-prereleases = true +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false diff --git a/documentation/README.md b/documentation/README.md new file mode 100644 index 0000000..7f499ea --- /dev/null +++ b/documentation/README.md @@ -0,0 +1,18 @@ +# Brain Brew documentation site + +This directory contains the Docusaurus documentation site for Brain Brew. + +```bash +npm install +npm run start +npm run build +``` + +From the repository root you can also use Devbox: + +```bash +devbox run docs:install +devbox run docs:build +``` + +The source pages live under `documentation/docs/`. The generated site is not committed. diff --git a/documentation/docs/authoring/diff-explain.md b/documentation/docs/authoring/diff-explain.md new file mode 100644 index 0000000..3b9453a --- /dev/null +++ b/documentation/docs/authoring/diff-explain.md @@ -0,0 +1,58 @@ +--- +title: Diff and explain +--- + +# Diff and explain + +Use `explain` to understand a target. Use `diff` to compare two decks or draft an overlay. + +## Explain a target + +```bash +brainbrew explain --manifest brainbrew.yaml --target de-extended +``` + +Human output shows the expanded overlay stack and semantic changes. + +For tools and UIs: + +```bash +brainbrew explain --manifest brainbrew.yaml --target de-extended --json +``` + +## Semantic diff + +```bash +brainbrew diff deck.yaml edited.yaml +``` + +Example output: + +```text +1 semantic change + +~ notes.note.finland.fields.field.capital + - Helsinki + + Helsingfors +``` + +The path uses stable IDs, not row numbers or raw YAML positions. + +## JSON diff + +```bash +brainbrew diff deck.yaml edited.yaml --json +``` + +Use JSON when another tool needs to render or inspect changes. + +## Draft an overlay + +```bash +brainbrew diff deck.yaml edited.yaml \ + --as-overlay \ + --id overlay.patch.capitals \ + --kind patch > overlays/patches/capitals.yaml +``` + +Review the generated overlay before committing. Destructive changes include `expected_base` values. diff --git a/documentation/docs/authoring/extensions.md b/documentation/docs/authoring/extensions.md new file mode 100644 index 0000000..ebe460c --- /dev/null +++ b/documentation/docs/authoring/extensions.md @@ -0,0 +1,81 @@ +--- +title: Extension overlays +--- + +# Extension overlays + +An extension overlay adds optional content or structure without copying the full deck. + +## Add fields and values + +Use `field_additions` when an extension adds fields to an existing note type and optionally fills them on existing notes. Existing notes that do not provide a value for a new field receive a blank value automatically. + +```yaml +id: overlay.extension.population +kind: extension +field_additions: + note-type.country: + fields: + field.population: Population + field.area: Area + values: + note.france: + field.population: 68 million + field.area: 643,801 km² + note.germany: + field.population: 84 million + field.area: 357,592 km² +``` + +## Add notes + +```yaml +id: overlay.extension.regions +kind: extension +notes: + note.brittany: + intent: add + note: + note_type_id: note-type.country + fields: + field.country: Brittany + field.capital: Rennes + tags: + - Europe + adapter_ids: {} +``` + +## Add card templates + +```yaml +id: overlay.variant.extended +kind: extension +note_types: + note-type.country: + intent: merge + card_templates: + template.capital-to-country: + intent: add + insert_after: template.country-to-capital + template: + name: Capital → Country + question_format: '{{Capital}}' + answer_format: '{{Country}}' + adapter_ids: {} +``` + +## Shared extension, small language residues + +For variants such as “Extended”, put shared structure in one overlay: + +```text +overlays/variants/extended.yaml +``` + +Put only real language-specific residue in per-language files: + +```text +overlays/variants/extended/de.yaml +``` + +Avoid copying full template HTML just to translate labels. Use source variables and translation dictionaries instead. diff --git a/documentation/docs/authoring/field-fills.md b/documentation/docs/authoring/field-fills.md new file mode 100644 index 0000000..10143cc --- /dev/null +++ b/documentation/docs/authoring/field-fills.md @@ -0,0 +1,63 @@ +--- +title: Field fills +--- + +# Field fills + +`field_fills` is an overlay shorthand for filling existing blank note fields. + +Use it when content belongs to an extension or patch, not to a translation dictionary. + +## Example + +```yaml +id: overlay.extension.hardcore.field-fills.en +kind: extension +field_fills: + note.anguilla: + field.capital: The Valley + field.flag: '' + note.canary-islands: + field.capital: Santa Cruz de Tenerife, Las Palmas + field.capital-info: The capital is shared between the two cities of Santa Cruz de Tenerife and Las Palmas. +``` + +This lowers to explicit checked changes: + +```yaml +notes: + note.anguilla: + intent: merge + fields: + field.capital: + intent: replace + value: The Valley + expected_base: + value: '' +``` + +If upstream later fills `field.capital`, composition fails instead of overwriting it. + +## When to use it + +Use `field_fills` for: + +- adding extension-owned content to blank fields on existing notes; +- preserving a non-destructive “only if blank” policy; +- language-specific extension content such as Hardcore Geography's filled capitals/flags. + +Do not use it for: + +- adding new field definitions — use [`field_additions`](extensions.md#add-fields-and-values); +- translating non-blank source text — use [`translations.changes`](translations.md#basic-dictionary); +- adding new notes — use `notes` with `intent: add`. + +## Why not `translations.additions`? + +A path-indexed value is not automatically a translation. + +`translations.additions` says “this blank localized text belongs to a translation overlay.” + +`field_fills` says “this extension or patch fills a blank field with new content.” + +Keeping them separate makes English extension content possible without inventing an English translation overlay. diff --git a/documentation/docs/authoring/manifests-targets.md b/documentation/docs/authoring/manifests-targets.md new file mode 100644 index 0000000..a7711b3 --- /dev/null +++ b/documentation/docs/authoring/manifests-targets.md @@ -0,0 +1,95 @@ +--- +title: Manifests and targets +--- + +# Manifests and targets + +`brainbrew.yaml` names the reproducible build targets in a workspace. + +## Minimal manifest + +```yaml +package: + id: example.capitals + version: 0.1.0 +base: deck.yaml +overlays: {} +targets: + en-standard: + overlays: [] +``` + +## Overlay catalog + +The catalog gives every overlay a stable reference: + +```yaml +overlays: + overlay.translation.de: + file: overlays/languages/de.yaml + kind: translation + overlay.variant.extended: + file: overlays/variants/extended.yaml + kind: extension +``` + +The manifest `kind` should match the overlay file's `kind`. + +## Overlay dependencies + +Dependencies are inclusion dependencies. Selecting the dependent overlay selects its dependencies first. + +```yaml +overlays: + overlay.variant.extended.de: + file: overlays/variants/extended/de.yaml + kind: extension + depends_on: + - overlay.translation.de + - overlay.variant.extended +``` + +The expanded stack is deterministic: + +```bash +brainbrew explain --manifest brainbrew.yaml --target de-extended +``` + +## Targets + +A target is a named composition goal. + +```yaml +targets: + de-extended: + overlays: + - overlay.variant.extended.de + exports: + crowdanki: + out: build/crowdanki/de-extended +``` + +Users and CI select targets instead of memorizing overlay paths. + +## Package-qualified targets + +A downstream package can extend an upstream target: + +```yaml +targets: + en-america: + extends: anki-geo.ultimate-geography:en-standard + overlays: + - overlay.extension.america +``` + +It may also mix package-qualified overlays: + +```yaml +targets: + en-mixed: + extends: anki-geo.ultimate-geography:en-standard + overlays: + - anki-geo.america:overlay.extension.america + - anki-geo.mountains:overlay.extension.rockies +``` diff --git a/documentation/docs/authoring/packages-locking.md b/documentation/docs/authoring/packages-locking.md new file mode 100644 index 0000000..3b1ff77 --- /dev/null +++ b/documentation/docs/authoring/packages-locking.md @@ -0,0 +1,84 @@ +--- +title: Packages and lock files +--- + +# Packages and lock files + +Federated packages let one repository compose with another without copying upstream source. + +## Downstream package + +```yaml +package: + id: anki-geo.america + version: 0.1.0 + depends_on: + - anki-geo.ultimate-geography@0.1.0 +base: deck.yaml +overlays: + overlay.extension.america: + file: overlays/america.yaml + kind: extension +targets: + en-america: + extends: anki-geo.ultimate-geography:en-standard + overlays: + - overlay.extension.america +``` + +## Local includes + +During local development, include another manifest explicitly: + +```bash +brainbrew compose \ + --manifest america/brainbrew.yaml \ + --include ultimate-geography/brainbrew.yaml \ + --target en-america +``` + +Or discover sibling packages: + +```bash +brainbrew targets --package-root ../anki-geo-packages +``` + +## Lock an upstream package + +```bash +brainbrew lock update \ + --package anki-geo.ultimate-geography \ + --git https://github.com/anki-geo/ultimate-geography.git \ + --ref main + +brainbrew lock verify +``` + +After locking, normal commands resolve packages from `brainbrew.lock` automatically: + +```bash +brainbrew compose --manifest america/brainbrew.yaml --target en-america +brainbrew verify --manifest america/brainbrew.yaml --all-targets +``` + +## Supported source inputs + +```bash +brainbrew lock update --package pkg.id --path ../pkg +brainbrew lock update --package pkg.id --tarball https://example.org/pkg.tar.gz +brainbrew lock update --package pkg.id --git https://github.com/owner/repo.git --ref main +``` + +The CLI computes `nar_hash` in Rust and does not require Nix at runtime. + +## Review after updates + +When upstream changes, rerun: + +```bash +brainbrew lock verify +brainbrew verify --manifest brainbrew.yaml --all-targets +brainbrew explain --manifest brainbrew.yaml --target en-america +``` + +Expected failures are the review surface: stale translation entries, expected-base mismatches, missing targets, media mismatches, or changed golden exports. diff --git a/documentation/docs/authoring/translations.md b/documentation/docs/authoring/translations.md new file mode 100644 index 0000000..bdeed94 --- /dev/null +++ b/documentation/docs/authoring/translations.md @@ -0,0 +1,98 @@ +--- +title: Translation overlays +--- + +# Translation overlays + +A translation overlay changes deck language or localized text. It should not add unrelated extension content. + +## Basic dictionary + +```yaml +id: overlay.translation.de +kind: translation +translations: + changes: + Germany: Deutschland + Austria: Österreich +``` + +The source key is the expected base. If `Germany` no longer exists where the overlay expects it, composition fails with a stale translation entry. + +## Path-scoped translations + +Use a path when the same source text needs different translations in different places. + +```yaml +translations: + changes: + Overseas territory of the United Kingdom.: + notes.note.bermuda.fields.field.country-info: Britisches Überseegebiet. + notes.note.falkland-islands.fields.field.country-info: Britisches Überseegebiet des Vereinigten Königreichs. +``` + +## Blank localized text + +Use `translations.additions` only when blank localized text genuinely belongs to the translation overlay. + +```yaml +translations: + additions: + notes.note.united-kingdom.fields.field.country-info: Offiziell das Vereinigte Königreich Großbritannien und Nordirland. +``` + +If an extension fills blank fields with new content, use [`field_fills`](field-fills.md) instead. + +## Translate source variables + +Variables keep card templates shared across languages. + +Base source: + +```yaml +note_types: + note-type.country: + variables: + label.capital: Capital + label.location: Location + card_templates: + template.map: + question_format: '
${label.location}
{{Map}}' +``` + +Translation overlay: + +```yaml +translations: + variables: + label.capital: + Capital: Hauptstadt + label.location: + Location: Lage +``` + +Prefer variable translations over copying whole card templates per language. + +## Translate adapter IDs + +Legacy translated decks may already have different CrowdAnki GUIDs. + +```yaml +translations: + adapter_ids: + crowdanki:guid: + english-guid: german-guid +``` + +## Deterministic section order + +The formatter emits translation dictionary sections in this order: + +1. `require_complete` +2. `ignore_paths` +3. `changes` +4. `additions` +5. `variables` +6. `adapter_ids` + +A file with no `changes` starts at the next non-empty section. That is still deterministic. diff --git a/documentation/docs/authoring/verify-export.md b/documentation/docs/authoring/verify-export.md new file mode 100644 index 0000000..4563e2d --- /dev/null +++ b/documentation/docs/authoring/verify-export.md @@ -0,0 +1,87 @@ +--- +title: Verify and export +--- + +# Verify and export + +Verification is the CI gate for a Federated Deck workspace. + +## Verify one target + +```bash +brainbrew verify --manifest brainbrew.yaml --target de-standard +``` + +## Verify every target + +```bash +brainbrew verify --manifest brainbrew.yaml --all-targets +``` + +Verification checks: + +1. manifest parsing and formatting; +2. base deck parsing and formatting; +3. overlay parsing and formatting; +4. lock-file package resolution and hashes; +5. dependency expansion; +6. target composition; +7. Canonical Deck validation; +8. configured CrowdAnki golden checks. + +## Verify media + +```bash +brainbrew verify --manifest brainbrew.yaml --all-targets --media-root media/ +``` + +With `--media-root`, Brain Brew checks that referenced media files exist and match their declared SHA-256 hashes. + +## Export CrowdAnki + +```bash +brainbrew export crowdanki \ + --manifest brainbrew.yaml \ + --target de-standard \ + --out build/crowdanki/de-standard +``` + +With media copied into the CrowdAnki folder's `media/` subdirectory: + +```bash +brainbrew export crowdanki \ + --manifest brainbrew.yaml \ + --target de-standard \ + --media-root media/ \ + --out build/crowdanki/de-standard +``` + +## Default and configured export paths + +```yaml +targets: + de-standard: + overlays: + - overlay.translation.de + exports: + crowdanki: + out: build/crowdanki/de-standard + golden: goldens/de-standard/deck.json +``` + +When `--out` is omitted, Brain Brew uses `exports.crowdanki.out` when configured; otherwise it defaults to `build/crowdanki/`. For example: + +```bash +brainbrew export crowdanki --manifest brainbrew.yaml --target de-standard +``` + +## Golden checks + +When `golden` is configured, `verify` compares generated CrowdAnki JSON against the golden as parsed JSON. + +Use `golden_allowlist` only after reviewing concrete differences: + +```yaml +golden_allowlist: + - note_models[0].latex_pre +``` diff --git a/documentation/docs/authoring/workspace.md b/documentation/docs/authoring/workspace.md new file mode 100644 index 0000000..0fbb2b7 --- /dev/null +++ b/documentation/docs/authoring/workspace.md @@ -0,0 +1,65 @@ +--- +title: Workspace layout +--- + +# Workspace layout + +A Federated Deck workspace contains a manifest, a base deck, and overlays. + +```text +my-deck/ + brainbrew.yaml + deck.yaml + overlays/ + languages/de.yaml + variants/extended.yaml + variants/extended/de.yaml + extensions/rivers.yaml + patches/capitals.yaml + media/ + flags/fi.svg +``` + +## `deck.yaml` + +The base Canonical Deck. It owns shared structure and content. + +## `overlays/` + +Sparse changes to the base deck. Keep overlays small and purpose-shaped: + +- language overlays in `overlays/languages/`; +- shared variant overlays in `overlays/variants/`; +- optional content extensions in `overlays/extensions/`; +- corrections in `overlays/patches/`. + +## `brainbrew.yaml` + +The manifest declares package metadata, named overlays, dependencies, and build targets. + +```yaml +package: + id: example.capitals + version: 0.1.0 +base: deck.yaml +overlays: + overlay.translation.de: + file: overlays/languages/de.yaml + kind: translation +targets: + de-standard: + overlays: + - overlay.translation.de +``` + +## Formatting + +Use canonical formatting as a review gate: + +```bash +brainbrew fmt deck.yaml +brainbrew fmt brainbrew.yaml +find overlays -name '*.yaml' -print0 | xargs -0 -n1 brainbrew fmt +``` + +`brainbrew verify --all-targets` also checks formatting. diff --git a/documentation/docs/concepts/canonical-deck.md b/documentation/docs/concepts/canonical-deck.md new file mode 100644 index 0000000..0311487 --- /dev/null +++ b/documentation/docs/concepts/canonical-deck.md @@ -0,0 +1,72 @@ +--- +title: Canonical Deck +--- + +# Canonical Deck + +A Canonical Deck is Brain Brew' format-independent representation of an Anki-compatible deck. + +It includes deck metadata, note types, card templates, notes, tags, media references, tombstones, stable IDs, and adapter IDs. + +It excludes review history and scheduling state. + +## Shape + +```yaml +deck: + id: deck.capitals + name: Capital Cities + description: A small geography deck. + adapter_ids: {} +note_types: + note-type.capital: + name: Capital Card + field_order: + - field.country + - field.capital + fields: + field.country: + name: Country + field.capital: + name: Capital + card_template_order: + - template.country-to-capital + card_templates: + template.country-to-capital: + name: Country → Capital + question_format: '{{Country}}' + answer_format: '{{Capital}}' + adapter_ids: {} + styling: '' + adapter_ids: {} +notes: + note.france: + note_type_id: note-type.capital + fields: + field.country: France + field.capital: Paris + tags: [] + adapter_ids: {} +media: {} +tombstones: [] +``` + +## Strict source + +Canonical YAML is deliberately strict: + +- unknown fields fail; +- stable IDs key entities; +- note type field/template order is explicit; +- formatting is deterministic; +- comments are not part of the durable model. + +Run the formatter before review: + +```bash +brainbrew fmt deck.yaml +``` + +## Round trips + +CrowdAnki import/export is an adapter around this model. The adapter preserves Anki-compatible deck semantics and external IDs, but the Canonical Deck stays the source of truth. diff --git a/documentation/docs/concepts/identity.md b/documentation/docs/concepts/identity.md new file mode 100644 index 0000000..4589fde --- /dev/null +++ b/documentation/docs/concepts/identity.md @@ -0,0 +1,62 @@ +--- +title: Stable IDs and adapter IDs +--- + +# Stable IDs and adapter IDs + +Brain Brew separates deck identity from external-tool identity. + +## Stable IDs + +Stable IDs are maintainer-owned names for deck entities: + +```yaml +notes: + note.finland: + note_type_id: note-type.ultimate-geography +``` + +They are used by overlays, diffs, manifests, and tests. They should be readable and stable across releases. + +Good stable IDs: + +- `note.finland` +- `field.capital` +- `template.country-map` +- `media.flag.finland` + +## Adapter IDs + +Adapter IDs preserve identity in external tools such as Anki/CrowdAnki: + +```yaml +notes: + note.finland: + adapter_ids: + crowdanki:guid: abc123 +``` + +A translation overlay may map external IDs when a legacy translated target already has different Anki GUIDs: + +```yaml +id: overlay.translation.de +kind: translation +translations: + adapter_ids: + crowdanki:guid: + en-guid: de-guid +``` + +## Why keep both? + +Stable IDs make source review pleasant. Adapter IDs keep round trips compatible with existing exported decks. + +A semantic diff therefore reports stable paths: + +```text +~ notes.note.finland.fields.field.capital + - Helsinki + + Helsingfors +``` + +The path remains meaningful even when external GUIDs differ by language. diff --git a/documentation/docs/concepts/media.md b/documentation/docs/concepts/media.md new file mode 100644 index 0000000..e7623ee --- /dev/null +++ b/documentation/docs/concepts/media.md @@ -0,0 +1,59 @@ +--- +title: Media references +--- + +# Media references + +Media assets are external files. Canonical Deck YAML stores references to those files and their hashes. + +## Declare media + +```yaml +media: + media.flag.finland: + path: flags/fi.svg + sha256: 7b2b... +``` + +Field text, template text, and note-type styling can then use normal Anki-compatible references (``, ` +
{{Country}}
+
+ +
${label.location}
+ +
+ + + +
+ + + + + + + + answer_format: + intent: merge + value: | + +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.location}
+ +
{{Map}}
+ + + + + +media: + media.interactive-map-config-js: + intent: add + path: _ug-interactive_map_config.js + sha256: '' + media.interactive-map-init-js: + intent: add + path: _ug-interactive_map_init.js + sha256: '' + media.jsvectormap-css: + intent: add + path: _ug-jsvectormap.min.css + sha256: '' + media.jsvectormap-js: + intent: add + path: _ug-jsvectormap.js + sha256: '' + media.world-js: + intent: add + path: _ug-world.js + sha256: '' diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/cs.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/cs.yaml new file mode 100644 index 0000000..52dc35d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/cs.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.cs +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: d7743e0d-0979-493a-83fa-67172ea63c07 + expected_base: + value: 47d13c81-b471-4471-8e87-ebad53fa7307 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/da.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/da.yaml new file mode 100644 index 0000000..58eaf8d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/da.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.da +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 85d39aa7-cfd5-41aa-8575-f39d3d5cd55b + expected_base: + value: 15daaec7-4097-4d97-ba94-9db44f8b8f51 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/de.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/de.yaml new file mode 100644 index 0000000..7b18423 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/de.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.de +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 98c67202-2209-48c1-b3de-475fa58372af + expected_base: + value: cb3de16f-7c9c-4944-994a-e17ec2018c28 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/en.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/en.yaml new file mode 100644 index 0000000..2822719 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/en.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.en +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 2e98b530-7583-5551-1fd7-51e6969d58ed + expected_base: + value: 43e2586a-9a65-11e8-a777-a0481cc15658 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/es.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/es.yaml new file mode 100644 index 0000000..2f35ef2 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/es.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.es +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: b7610e2c-c28f-402a-b86a-57a44fdf11f7 + expected_base: + value: 0bacddfd-1e81-4e62-9152-bdff33db0374 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/fr.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/fr.yaml new file mode 100644 index 0000000..30c389b --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/fr.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.fr +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 15113ac3-6e6b-4ad3-9dca-e963ce4d0cf4 + expected_base: + value: fd72d808-58d4-43ea-97db-24196747f24c diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/it.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/it.yaml new file mode 100644 index 0000000..744c831 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/it.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.it +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: cf4488cc-2747-4b48-86a5-fa86ef99ab53 + expected_base: + value: cd65a274-7341-4e72-8de5-5d3d83ea4537 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/nb.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/nb.yaml new file mode 100644 index 0000000..3733642 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/nb.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.nb +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: f5b475fc-3c81-4644-b17f-500d1cf755d9 + expected_base: + value: 8ccdc1f6-b042-4d55-8fcd-9e08b5b71dc7 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/nl.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/nl.yaml new file mode 100644 index 0000000..111f5ca --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/nl.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.nl +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 6bce5589-39ce-496d-86e2-1148d9b19405 + expected_base: + value: 8263244e-6f5e-457a-bb65-a836a6c058a3 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/pl.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/pl.yaml new file mode 100644 index 0000000..1306f8c --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/pl.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.pl +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 7ce20479-556c-4ef2-a1d4-9573a00a70fc + expected_base: + value: 84786477-e28a-490e-958d-7b35946d7b11 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/pt.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/pt.yaml new file mode 100644 index 0000000..ca392c7 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/pt.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.pt +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 6a7fe240-f6fb-4571-9f2a-fd30a9cf7709 + expected_base: + value: 3da2a851-258c-447f-9d16-91a663663675 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/ru.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/ru.yaml new file mode 100644 index 0000000..7947706 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/ru.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.ru +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 1511e9fe-58e6-4d9a-89b2-1423b87c44b8 + expected_base: + value: 2aa62e36-601e-4e4c-a124-5a79b14f8697 diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/sv.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/sv.yaml new file mode 100644 index 0000000..1e7d502 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/sv.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.sv +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: ce42a853-79d7-4b8f-85cc-61963effdb9c + expected_base: + value: 3090ce89-5fd8-4107-86df-3e7a74ec288f diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/zh-tw.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/zh-tw.yaml new file mode 100644 index 0000000..fa27b08 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/zh-tw.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.zh-tw +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 4697c375-ef04-467a-8838-edecadd201d1 + expected_base: + value: d75ad494-71f3-4909-beda-b80ec04c7a0d diff --git a/fixtures/ultimate-geography/overlays/variants/experimental/zh.yaml b/fixtures/ultimate-geography/overlays/variants/experimental/zh.yaml new file mode 100644 index 0000000..58fc3c6 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental/zh.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.experimental.zh +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 10ca3bef-1f66-445c-adf2-fd0f55bf0762 + expected_base: + value: 30c7ff39-56b5-4693-8e16-aa862eb7a619 diff --git a/fixtures/ultimate-geography/overlays/variants/extended.yaml b/fixtures/ultimate-geography/overlays/variants/extended.yaml new file mode 100644 index 0000000..62f75d4 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended.yaml @@ -0,0 +1,80 @@ +id: overlay.variant.extended +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + variables: + variant.name-suffix: + intent: replace + value: ' [Extended]' + expected_base: + value: '' + card_templates: + template.country-flag: + intent: add + insert_after: template.capital-country + template: + name: Country - Flag + question_format: |- + {{#Flag}} +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.flag}
+
+ + + +
+ {{/Flag}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.flag}
+
{{Flag}}
+ {{#Flag similarity}}
${sentence.flag-similar}
{{/Flag similarity}} + adapter_ids: {} + template.country-map: + intent: add + insert_after: template.flag-country + template: + name: Country - Map + question_format: |- + {{#Map}} +
{{Country}}
+ +
+ +
${label.location}
+
+ + + +
+ {{/Map}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.location}
+
{{Map}}
+ adapter_ids: {} diff --git a/fixtures/ultimate-geography/overlays/variants/extended/cs.yaml b/fixtures/ultimate-geography/overlays/variants/extended/cs.yaml new file mode 100644 index 0000000..d75eebb --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/cs.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.cs +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 5ef3528d-e7c3-4d49-81e8-6c1c0a609a77 + expected_base: + value: 47d13c81-b471-4471-8e87-ebad53fa7307 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/da.yaml b/fixtures/ultimate-geography/overlays/variants/extended/da.yaml new file mode 100644 index 0000000..3037b1e --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/da.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.da +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 412504c7-cde6-426d-8110-454bb2b711c7 + expected_base: + value: 15daaec7-4097-4d97-ba94-9db44f8b8f51 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/de.yaml b/fixtures/ultimate-geography/overlays/variants/extended/de.yaml new file mode 100644 index 0000000..f2d4fde --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/de.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.de +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: dc6c1ca9-b55f-429e-99dc-c61c35a20209 + expected_base: + value: cb3de16f-7c9c-4944-994a-e17ec2018c28 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/en.yaml b/fixtures/ultimate-geography/overlays/variants/extended/en.yaml new file mode 100644 index 0000000..77f3d4e --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/en.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.en +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 0a39f994-daaf-11e8-b984-a0481cc15658 + expected_base: + value: 43e2586a-9a65-11e8-a777-a0481cc15658 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/es.yaml b/fixtures/ultimate-geography/overlays/variants/extended/es.yaml new file mode 100644 index 0000000..de2d0f7 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/es.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.es +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 03766b2c-0925-41d0-9a14-9bca36a3b605 + expected_base: + value: 0bacddfd-1e81-4e62-9152-bdff33db0374 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/fr.yaml b/fixtures/ultimate-geography/overlays/variants/extended/fr.yaml new file mode 100644 index 0000000..4a70217 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/fr.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.fr +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: d8f4db0e-310f-45d4-9143-a9c93dd658bb + expected_base: + value: fd72d808-58d4-43ea-97db-24196747f24c diff --git a/fixtures/ultimate-geography/overlays/variants/extended/it.yaml b/fixtures/ultimate-geography/overlays/variants/extended/it.yaml new file mode 100644 index 0000000..a8c5f53 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/it.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.it +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 996b5985-fb94-479f-a468-67284891fa5d + expected_base: + value: cd65a274-7341-4e72-8de5-5d3d83ea4537 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/nb.yaml b/fixtures/ultimate-geography/overlays/variants/extended/nb.yaml new file mode 100644 index 0000000..f35494d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/nb.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.nb +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: a5caadfb-a5b4-41f2-a176-9e029e581368 + expected_base: + value: 8ccdc1f6-b042-4d55-8fcd-9e08b5b71dc7 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/nl.yaml b/fixtures/ultimate-geography/overlays/variants/extended/nl.yaml new file mode 100644 index 0000000..ad2d0df --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/nl.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.nl +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 666fd740-3670-4a45-98e5-dff3a7568ad5 + expected_base: + value: 8263244e-6f5e-457a-bb65-a836a6c058a3 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/pl.yaml b/fixtures/ultimate-geography/overlays/variants/extended/pl.yaml new file mode 100644 index 0000000..5ee7b19 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/pl.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.pl +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: d0fa593e-cf19-4ac9-a7f3-005ed6196957 + expected_base: + value: 84786477-e28a-490e-958d-7b35946d7b11 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/pt.yaml b/fixtures/ultimate-geography/overlays/variants/extended/pt.yaml new file mode 100644 index 0000000..d71d48a --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/pt.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.pt +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 9028894e-3885-495f-a608-1e4e8e2e70df + expected_base: + value: 3da2a851-258c-447f-9d16-91a663663675 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/ru.yaml b/fixtures/ultimate-geography/overlays/variants/extended/ru.yaml new file mode 100644 index 0000000..ec1e4c0 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/ru.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.ru +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 03edf31b-c8ef-4fdd-855a-25846f1e1c13 + expected_base: + value: 2aa62e36-601e-4e4c-a124-5a79b14f8697 diff --git a/fixtures/ultimate-geography/overlays/variants/extended/sv.yaml b/fixtures/ultimate-geography/overlays/variants/extended/sv.yaml new file mode 100644 index 0000000..3cbd312 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/sv.yaml @@ -0,0 +1,60 @@ +id: overlay.variant.extended.sv +kind: extension +deck: + name: + intent: override + value: Ultimate Geography + expected_base: + value: 'Ultimate Geography [SV]' + description: + intent: override + value: | + FULL DESCRIPTION | RELEASE NOTES | CONTRIBUTING + + Ultimate Geography v5.3 features: + + - the world's 205 sovereign states (820 cards) + - 59 territories, world regions, and other entities (103 cards) + - 48 oceans and seas (48 cards, maps only) + - 7 continents (7 cards, maps only) + - for a total of 319 unique notes, 978 cards, 221 flags and 319 maps. + + The deck is available in English, German, Spanish, French, Norwegian, Czech, Russian, Dutch, Swedish, Portuguese, Chinese (simplified and traditional), Polish, Italian and Danish. An extended version is also available in each language. To help with memorisation and provide context while learning, some notes include extra information such as similar flags, governance information, alternative country names, etc. + + You can use Anki's filtered deck feature to focus your study on a subset of the deck, such as sovereign states, a single note template (e.g. map to country), or a specific continent (e.g. Europe). + + This deck is maintained on GitHub. If you spot a mistake, have a suggestion or want to help, please don't hesitate to open an issue. Want to stay informed of new releases? Watch the GitHub repository or subscribe to the releases feed! + expected_base: + value: | + FULLSTÄNDIG BESKRIVNING | UTGIVNINGSANMÄRKNINGAR | BIDRA + + Ultimate Geography v5.3 innehåller: + + - världens 205 självständiga stater (820 kort) + - 59 territorum, världsregioner och andra enheter (103 kort) + - 48 sjöar och hav (48 kort, enbart kartor) + - 7 kontinenter (7 kort, enbart kartor) + - sammanlagt 319 unika noter, 978 kort, 221 flaggor och 319 kartor. + + Kortleken finns tillgänglig på fjorton olika språk: engelska, tyska, spanska, franska, norska, tjeckiska, ryska, nederländska, svenska, portugisiska, kinesiska, polska, italienska och danska. För vardera språk finns dessutom en utökad version. + + För att underlätta inlärningen så har vissa kort en extra informationsrad, med exempelvis information om att flaggan på kortet liknar en annan flagga, politisk styrelse eller ett alternativt namn på ett land. + + Du kan skapa en filtrerad kortlek om du vill fokusera på specifika delar av kortleken. Till exempel kan du på så sätt välja att enbart studera suveräna stater, endast en specifik korttyp (såsom "karta → land") eller en specifik världsdel (till exempel Europa). + + Den här kortleken administreras på GitHub. Om du upptäcker ett fel, har förslag eller vill hjälpa till, tveka inte att öppna ett ärende. Vill du bli informerad om uppdateringar av kortleken? Bevaka källlkodskatalogen på GitHub eller prenumerera på feeden för utgåvor! + adapter_ids: + crowdanki:uuid: + intent: override + value: 43c5ba66-9a65-11e8-90c9-a0481cc15658 + expected_base: + value: 75bfcdb5-0ff3-4038-83cb-3e6ed974f439 +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: a8103c3e-f0a3-4943-98fc-492159b0944e + expected_base: + value: 3090ce89-5fd8-4107-86df-3e7a74ec288f diff --git a/fixtures/ultimate-geography/overlays/variants/extended/zh-tw.yaml b/fixtures/ultimate-geography/overlays/variants/extended/zh-tw.yaml new file mode 100644 index 0000000..a92271d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/zh-tw.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.zh-tw +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: 4f11a863-ba7e-4c78-8426-9cd0f64a7b3a + expected_base: + value: d75ad494-71f3-4909-beda-b80ec04c7a0d diff --git a/fixtures/ultimate-geography/overlays/variants/extended/zh.yaml b/fixtures/ultimate-geography/overlays/variants/extended/zh.yaml new file mode 100644 index 0000000..f77dc53 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/extended/zh.yaml @@ -0,0 +1,11 @@ +id: overlay.variant.extended.zh +kind: extension +note_types: + note-type.ultimate-geography: + intent: merge + adapter_ids: + crowdanki:uuid: + intent: override + value: d1c5ee7f-4a7a-4cff-939f-a06f865d68d2 + expected_base: + value: 30c7ff39-56b5-4693-8e16-aa862eb7a619 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ad7e9d6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1779508470, + "narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "29916453413845e54a65b8a1cf996842300cd299", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5c5ed93 --- /dev/null +++ b/flake.nix @@ -0,0 +1,88 @@ +{ + description = "Brain Brew local-first deck federation CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system (import nixpkgs { inherit system; })); + workspace = builtins.fromTOML (builtins.readFile ./Cargo.toml); + version = workspace.workspace.package.version; + in + { + packages = forAllSystems ( + system: pkgs: + let + brainbrew = pkgs.rustPlatform.buildRustPackage { + pname = "brainbrew"; + inherit version; + + src = pkgs.lib.cleanSource ./.; + cargoLock.lockFile = ./Cargo.lock; + + cargoBuildFlags = [ + "-p" + "brainbrew" + "--bin" + "brainbrew" + ]; + cargoTestFlags = [ + "--workspace" + "--all-targets" + ]; + + meta = { + description = "Local-first deck federation and round-trip CLI for Anki-compatible decks"; + homepage = "https://github.com/jeprecated/brain-brew"; + license = pkgs.lib.licenses.unlicense; + mainProgram = "brainbrew"; + }; + }; + in + { + inherit brainbrew; + default = brainbrew; + } + ); + + apps = forAllSystems ( + system: _pkgs: + let + brainbrew = self.packages.${system}.brainbrew; + in + { + brainbrew = { + type = "app"; + program = "${brainbrew}/bin/brainbrew"; + meta.description = "Run the Brain Brew CLI"; + }; + default = self.apps.${system}.brainbrew; + } + ); + + checks = forAllSystems (system: _pkgs: { + brainbrew = self.packages.${system}.brainbrew; + default = self.checks.${system}.brainbrew; + }); + + devShells = forAllSystems (system: pkgs: { + default = pkgs.mkShell { + packages = [ + pkgs.cargo + pkgs.clippy + pkgs.rustc + pkgs.rustfmt + ]; + }; + }); + }; +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f43d64e --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +newline_style = "Unix" +max_width = 100 diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/build.bash b/scripts/build.bash deleted file mode 100755 index 1213b46..0000000 --- a/scripts/build.bash +++ /dev/null @@ -1,4 +0,0 @@ -# Build -rm -r dist -rm -r build -python3 setup.py sdist bdist_wheel \ No newline at end of file diff --git a/scripts/dist.bash b/scripts/dist.bash deleted file mode 100755 index 9e1a94b..0000000 --- a/scripts/dist.bash +++ /dev/null @@ -1,9 +0,0 @@ -# See credentials in ~/.pypirc - -# To use an API token: -# -# Set your username to __token__ -# Set your password to the token value, including the pypi- prefix - -# Upload -twine upload dist/* --verbose diff --git a/scripts/fetch_ug_release_oracle.py b/scripts/fetch_ug_release_oracle.py new file mode 100755 index 0000000..c3a72b3 --- /dev/null +++ b/scripts/fetch_ug_release_oracle.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Fetch Ultimate Geography release CrowdAnki deck.json files as a parity oracle. + +This is intentionally a small, project-specific helper for the UG migration proof. +It is not a general Brain Brew package downloader. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import urllib.request +import zipfile +from pathlib import Path +from tempfile import TemporaryDirectory + +REPO = "anki-geo/ultimate-geography" +DEFAULT_TAG = "v5.3" +LANGUAGES = [ + "cs", + "da", + "de", + "en", + "es", + "fr", + "it", + "nb", + "nl", + "pl", + "pt", + "ru", + "sv", + "zh", + "zh-tw", +] +VARIANTS = ["standard", "extended"] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", default=DEFAULT_TAG, help="UG release tag to fetch") + parser.add_argument( + "--out", + type=Path, + help="Output directory (default: .cache/brainbrew/ug-release-oracle/)", + ) + parser.add_argument( + "--target", + action="append", + help="Target to fetch, e.g. en-standard. Repeatable. Defaults to all release targets.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Download even when the target deck.json already exists.", + ) + args = parser.parse_args() + + out = args.out or Path(".cache/brainbrew/ug-release-oracle") / args.tag + targets = args.target or all_targets() + validate_targets(targets) + + records = {} + crowdanki_root = out / "crowdanki" + crowdanki_root.mkdir(parents=True, exist_ok=True) + + with TemporaryDirectory(prefix="brainbrew-ug-oracle-") as temp: + temp_dir = Path(temp) + for target in targets: + spec = target_spec(args.tag, target) + deck_json_path = crowdanki_root / spec["deck_folder"] / "deck.json" + if deck_json_path.exists() and not args.force: + deck_bytes = deck_json_path.read_bytes() + records[target] = { + **spec, + "asset_sha256": None, + "deck_json_sha256": sha256_bytes(deck_bytes), + "deck_json": str(deck_json_path.relative_to(out)), + "downloaded": False, + } + print(f"already present {target}: {deck_json_path}") + continue + + zip_path = temp_dir / spec["asset"] + print(f"downloading {target}: {spec['url']}") + urllib.request.urlretrieve(spec["url"], zip_path) + asset_bytes = zip_path.read_bytes() + asset_sha256 = sha256_bytes(asset_bytes) + + with zipfile.ZipFile(zip_path) as archive: + member = f"{spec['deck_folder']}/deck.json" + try: + deck_bytes = archive.read(member) + except KeyError as error: + raise SystemExit(f"{spec['asset']} does not contain {member}") from error + + deck_json_path.parent.mkdir(parents=True, exist_ok=True) + deck_json_path.write_bytes(deck_bytes) + records[target] = { + **spec, + "asset_sha256": asset_sha256, + "deck_json_sha256": sha256_bytes(deck_bytes), + "deck_json": str(deck_json_path.relative_to(out)), + "downloaded": True, + } + print(f"extracted {target}: {deck_json_path}") + + manifest = { + "repo": REPO, + "tag": args.tag, + "targets": records, + } + manifest_path = out / "oracle-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + print(f"wrote {manifest_path}") + return 0 + + +def all_targets() -> list[str]: + return [f"{language}-{variant}" for language in LANGUAGES for variant in VARIANTS] + + +def validate_targets(targets: list[str]) -> None: + valid = set(all_targets()) + invalid = sorted(set(targets) - valid) + if invalid: + raise SystemExit(f"unknown target(s): {', '.join(invalid)}") + + +def target_spec(tag: str, target: str) -> dict[str, str]: + language, variant = target_language_and_variant(target) + release_code = language.upper() + variant_suffix = "_EXTENDED" if variant == "extended" else "" + folder_suffix = " [Extended]" if variant == "extended" else "" + asset = f"Ultimate_Geography_{tag}_{release_code}{variant_suffix}.zip" + deck_folder = f"Ultimate Geography [{release_code}]{folder_suffix}" + return { + "asset": asset, + "url": f"https://github.com/{REPO}/releases/download/{tag}/{asset}", + "deck_folder": deck_folder, + } + + +def target_language_and_variant(target: str) -> tuple[str, str]: + if target.startswith("zh-tw-"): + return "zh-tw", target.removeprefix("zh-tw-") + language, variant = target.split("-", 1) + return language, variant + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/yamale_build.py b/scripts/yamale_build.py deleted file mode 100755 index c5eba2c..0000000 --- a/scripts/yamale_build.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -import sys - -sys.path.append(os.path.abspath('')) - -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder - -build: str = TopLevelBuilder.build_yamale() -filepath = "brain_brew/schemas/recipe.yaml" - -with open(filepath, 'w') as fp: - fp.write(build) - fp.close() diff --git a/setup.py b/setup.py deleted file mode 100644 index 71eed1c..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -import setuptools -from brain_brew.front_matter import latest_version_number - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="Brain-Brew", - version=latest_version_number(), - author="Jordan Munch O'Hare", - author_email="brainbrew@jordan.munchohare.com", - description="Automated Anki flashcard creation and extraction to/from Csv ", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/jeprecated/brain-brew", - packages=setuptools.find_packages(), - include_package_data=True, - entry_points={ - 'console_scripts': [ - 'brain_brew = brain_brew.main:main', - 'brain-brew = brain_brew.main:main', - 'brainbrew = brain_brew.main:main', - ] - }, - classifiers=[ - "Programming Language :: Python :: 3", - "License :: Public Domain", - "Operating System :: OS Independent", - ], - python_requires='>=3.7', - install_requires=[ - 'ruamel.yaml.clib>=0.2.2', - 'ruamel.yaml>=0.16.10', - 'yamale>=3.0.4' - ] -) diff --git a/skills/federated-deck-extensions/SKILL.md b/skills/federated-deck-extensions/SKILL.md new file mode 100644 index 0000000..43cc6f4 --- /dev/null +++ b/skills/federated-deck-extensions/SKILL.md @@ -0,0 +1,221 @@ +--- +name: federated-deck-extensions +description: Use when creating, reviewing, or refactoring Brain Brew Federated Deck workspaces, especially translation overlays, extension overlays, variant targets, or Ultimate Geography-style decks. Enforces variable-first, shared-extension design so agents do not duplicate per-language templates or encode rendered values where source variables should be used. +--- + +# Federated Deck Extensions + +Use this skill whenever you touch a Brain Brew `deck.yaml`, `brainbrew.yaml`, `overlays/languages/*.yaml`, `overlays/variants/**/*.yaml`, or any UG-style Federated Deck workspace. + +The goal is to keep source declarative, maintainable, and parity-safe: + +- base deck owns shared structure; +- translation overlays translate dictionaries and variables; +- shared extension overlays add new structure once; +- language-specific extension overlays contain only the genuinely language/adapter-specific residue. + +## Required context pass + +Before changing design, read or inspect: + +1. `CONTEXT.md` for project language. +2. `documentation/docs/authoring/workspace.md`, `documentation/docs/authoring/manifests-targets.md`, and `documentation/docs/concepts/overlays.md` for manifest, variable, and overlay syntax. +3. The active ADR index in `documentation/docs/reference/decisions/README.md` when making architectural changes. +4. The current workspace files: + - `brainbrew.yaml` + - `deck.yaml` + - `overlays/languages/*.yaml` + - `overlays/variants/**/*.yaml` + +Useful inspection commands: + +```bash +rg -n 'variables:|\$\{|translations:|card_templates:|note_types:|depends_on:' deck.yaml overlays brainbrew.yaml +brainbrew targets --manifest brainbrew.yaml +brainbrew explain --manifest brainbrew.yaml --target +``` + +## Design rules + +### 1. Variables before duplicated template text + +If text appears in card templates, note type names, field labels, repeated descriptions, or extension templates, first ask: **should this be a source variable?** + +Prefer: + +```yaml +name: '${note-type.name}${variant.name-suffix}' +variables: + note-type.name: Ultimate Geography + variant.name-suffix: '' + label.flag: Flag + label.location: Location +``` + +and templates like: + +```html +
${label.flag}
+{{#Flag similarity}}
${sentence.flag-similar}
{{/Flag similarity}} +
${label.location}
+``` + +Avoid per-language copies of the same HTML just to replace `Flag`, `Location`, or a repeated phrase. + +### 2. Translation overlays translate variables, not paths, for shared wording + +For repeated labels/model names, use `translations.variables`: + +```yaml +translations: + variables: + note-type.name: + Ultimate Geography: 'Ultimate Geography [DA]' + label.flag: + Flag: Flag + label.location: + Location: Placering + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Flaget ligner {{Flag similarity}}.' +``` + +Use `translations.changes` for content text and path-scoped exceptions. Do **not** path-scope metadata like `note_types.note-type.ultimate-geography.name` if a variable can express it. + +### 3. Use field fills for non-translation blank content + +If an extension fills fields that already exist but are blank on base notes, use `field_fills` in an extension or patch overlay. Do not put extension content under `translations.additions` just because it is path-indexed. + +Prefer: + +```yaml +id: overlay.extension.hardcore.field-fills.en +kind: extension +field_fills: + note.anguilla: + field.capital: The Valley + field.flag: '' +``` + +Reserve `translations.additions` for blank localized text that is genuinely part of a translation overlay. + +### 4. Shared extension overlay first, language-specific residue later + +For Standard/Extended-style variants, create one shared extension overlay for structural additions: + +```text +overlays/variants/extended.yaml +``` + +It should add card templates/fields once and use variables for localized labels. + +Per-language files should be small: + +```text +overlays/variants/extended/da.yaml +``` + +These files should usually contain only adapter identity preservation, deck metadata exceptions, or genuinely language-specific residue. They should **not** copy card-template HTML. + +### 5. Manifest dependency order matters + +For a localized extended target, wire dependencies so composition is deterministic and adapter IDs see the expected base: + +```yaml +overlays: + overlay.variant.extended: + file: overlays/variants/extended.yaml + kind: extension + + overlay.variant.extended.da: + file: overlays/variants/extended/da.yaml + kind: extension + depends_on: + - overlay.translation.da + - overlay.variant.extended +``` + +That expands to translation first, shared extension second, language-specific extension last. + +### 6. Remember: variables render at export, not during compose + +Overlay `expected_base` checks run against source values before CrowdAnki export renders variables. If a source name is: + +```yaml +name: '${note-type.name}${variant.name-suffix}' +``` + +then a later overlay must not expect the rendered value `Ultimate Geography [DA]` at `note_types...name`. + +Use a variable change instead: + +```yaml +variables: + variant.name-suffix: + intent: replace + value: ' [Extended]' + expected_base: + value: '' +``` + +This avoids the mistake of comparing rendered output against unrendered source. + +## Red flags to stop and refactor + +Stop if you see any of these: + +- `overlays/variants/extended/.yaml` contains full `card_templates:` blocks for every language. +- Template HTML differs only by labels such as `Flag`, `Location`, or `Flag similar...`. +- Translation overlays include `notes:` blocks for ordinary field translations. +- Translation overlays use `translations.additions` for extension-owned field content instead of `field_fills`. +- Translation overlays path-scope note-type names instead of translating a variable. +- `expected_base` refers to the rendered value of a variable-backed source property. +- The same extension template exists in more than one language file. +- An overlay changes adapter IDs and structure in the same large block when those can be separated into shared structure plus per-language identity residue. + +## Safe refactoring workflow + +Use TDD/RGR for behavior changes. For fixture/source refactors, make the guard explicit first. + +1. Add or strengthen a test that catches the mistake: + - translation overlays do not use per-note field replacement blocks; + - translation overlays translate note-type/model names through variables; + - per-language extension overlays do not contain copied card-template HTML; + - manifest targets still compose/export. +2. Refactor source: + - add variables to `deck.yaml`; + - move shared extension templates to a shared overlay; + - replace hardcoded template text with `${...}` references; + - shrink per-language extension overlays; + - update `brainbrew.yaml` dependencies. +3. Format all changed source: + +```bash +brainbrew fmt deck.yaml +brainbrew fmt brainbrew.yaml +find overlays -name '*.yaml' -print0 | xargs -0 -n1 brainbrew fmt +``` + +4. Verify composition/export: + +```bash +brainbrew verify --manifest brainbrew.yaml --all-targets +``` + +5. For UG parity, compare against the configured Brain Brew goldens. A passing verify should end with: + +```text +✓ verified targets + manifest: brainbrew.yaml +``` + +## Review checklist + +Before finishing, answer yes to all: + +- Are repeated template labels represented as variables? +- Are repeated model/note-type names represented as variables plus suffixes where needed? +- Are translation overlays mostly dictionaries and variable translations? +- Is there one shared extension overlay for shared card-template/field additions? +- Are language-specific extension overlays small and free of copied template HTML? +- Does manifest dependency expansion produce the intended order? +- Did `brainbrew verify --manifest brainbrew.yaml --all-targets` pass? diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/build_tasks/__init__.py b/tests/build_tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/build_tasks/test_source_crowd_anki_json.py b/tests/build_tasks/test_source_crowd_anki_json.py deleted file mode 100644 index 5c2a98f..0000000 --- a/tests/build_tasks/test_source_crowd_anki_json.py +++ /dev/null @@ -1,113 +0,0 @@ -# from unittest.mock import patch -# -# import pytest -# -# from brain_brew.constants.build_config_keys import BuildConfigKeys -# from brain_brew.build_tasks.source_crowd_anki import SourceCrowdAnki, CrowdAnkiKeys -# from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -# from brain_brew.representation.json.part_header import DeckPartHeader -# from brain_brew.representation.yaml.note_model_repr import DeckPartNoteModel -# from brain_brew.representation.json.part_notes import DeckPartNotes -# -# -# def setup_ca_config(file, media, useless_note_keys, notes, headers): -# return { -# CrowdAnkiKeys.FILE.value: file, -# CrowdAnkiKeys.MEDIA.value: media, -# CrowdAnkiKeys.USELESS_NOTE_KEYS.value: useless_note_keys, -# BuildConfigKeys.NOTES.value: notes, -# BuildConfigKeys.HEADERS.value: headers -# } -# -# -# class TestConstructor: -# @pytest.mark.parametrize("file, media, useless_note_keys, notes, headers, read_file_now", [ -# ("test", False, {}, "test.json", "header.json", False), -# ("export1", True, {}, "test.json", "header.json", False), -# ("json.json", False, {}, "test.json", "", True), -# ("", False, {"__type__": "Note", "data": None, "flags": 0}, "test.json", "header.json", False) -# ]) -# def test_runs(self, file, media, useless_note_keys, notes, headers, read_file_now, global_config): -# config = setup_ca_config(file, media, useless_note_keys, notes, headers) -# -# def assert_dp_header(passed_file, read_now): -# assert passed_file == headers -# assert read_now == read_file_now -# -# def assert_dp_notes(passed_file, read_now): -# assert passed_file == notes -# assert read_now == read_file_now -# -# def assert_ca_export(passed_file, read_now): -# assert passed_file == file -# assert read_now == read_file_now -# -# with patch.object(DeckPartHeader, "create", side_effect=assert_dp_header) as mock_header, \ -# patch.object(DeckPartNotes, "create", side_effect=assert_dp_notes) as mock_notes, \ -# patch.object(CrowdAnkiExport, "create", side_effect=assert_ca_export) as ca_export: -# -# source = SourceCrowdAnki(config, read_now=read_file_now) -# -# assert isinstance(source, SourceCrowdAnki) -# assert source.should_handle_media == media -# assert source.useless_note_keys == useless_note_keys -# -# assert mock_header.call_count == 1 -# assert mock_notes.call_count == 1 -# assert ca_export.call_count == 1 -# -# -# @pytest.fixture() -# def source_crowd_anki_test1(global_config) -> SourceCrowdAnki: -# with patch.object(DeckPartHeader, "create", return_value=None) as mock_header, \ -# patch.object(DeckPartNotes, "create", return_value=None) as mock_notes, \ -# patch.object(CrowdAnkiExport, "create", return_value=None) as mock_ca_export: -# -# source = SourceCrowdAnki( -# setup_ca_config("", False, {"__type__": "Note", "data": None, "flags": 0}, "", "") -# ) -# -# # source.notes = dp_notes_test1 -# # source.headers = dp_headers_test1 -# # source.crowd_anki_export = ca_export_test1 -# -# return source -# -# -# class TestSourceToDeckParts: -# def test_runs(self, source_crowd_anki_test1: SourceCrowdAnki, ca_export_test1, -# temp_dp_note_model_file, temp_dp_headers_file, temp_dp_notes_file, -# dp_note_model_test1, dp_headers_test1, dp_notes_test1): -# -# # CrowdAnki Export it will use to write to the DeckParts -# source_crowd_anki_test1.crowd_anki_export = ca_export_test1 -# -# # DeckParts to be written to (+ the NoteModel below) -# source_crowd_anki_test1.headers = temp_dp_headers_file -# source_crowd_anki_test1.notes = temp_dp_notes_file -# -# def assert_note_model(name, data_override): -# assert data_override == dp_note_model_test1.get_data() -# return dp_note_model_test1 -# -# with patch.object(DeckPartNoteModel, "create", side_effect=assert_note_model) as mock_nm: -# source_crowd_anki_test1.source_to_parts() -# -# assert source_crowd_anki_test1.headers.get_data() == dp_headers_test1.get_data() -# assert source_crowd_anki_test1.notes.get_data() == dp_notes_test1.get_data() -# -# assert mock_nm.call_count == 1 -# -# -# class TestDeckPartsToSource: -# def test_runs(self, source_crowd_anki_test1: SourceCrowdAnki, temp_ca_export_file, -# ca_export_test1, dp_notes_test1, dp_headers_test1): -# source_crowd_anki_test1.crowd_anki_export = temp_ca_export_file # File to write result to -# -# # DeckParts it will use (+ dp_note_model_test1, but it reads that in as a file) -# source_crowd_anki_test1.headers = dp_headers_test1 -# source_crowd_anki_test1.notes = dp_notes_test1 -# -# source_crowd_anki_test1.parts_to_source() # Where the magic happens -# -# assert temp_ca_export_file.get_data() == ca_export_test1.get_data() diff --git a/tests/build_tasks/test_source_csv.py b/tests/build_tasks/test_source_csv.py deleted file mode 100644 index 2b5e923..0000000 --- a/tests/build_tasks/test_source_csv.py +++ /dev/null @@ -1,140 +0,0 @@ -# from typing import List -# from unittest.mock import patch -# -# import pytest -# -# from brain_brew.build_tasks.source_csv import SourceCsv, SourceCsvKeys -# from brain_brew.constants.deckpart_keys import DeckPartNoteKeys -# from brain_brew.representation.configuration.csv_file_mapping import FileMapping -# from brain_brew.representation.configuration.note_model_mapping import NoteModelMapping -# from brain_brew.representation.generic.csv_file import CsvFile -# from brain_brew.representation.generic.source_file import SourceFile -# from brain_brew.representation.json.part_notes import DeckPartNotes -# from tests.representation.json.test_part_notes import dp_notes_test1 -# from tests.representation.configuration.test_note_model_mapping import setup_nmm_config -# from tests.representation.configuration.test_csv_file_mapping import setup_csv_fm_config -# -# -# def setup_source_csv_config(notes: str, nmm: list, csv_mappings: list): -# return { -# SourceCsvKeys.NOTES.value: notes, -# SourceCsvKeys.NOTE_MODEL_MAPPINGS.value: nmm, -# SourceCsvKeys.CSV_MAPPINGS.value: csv_mappings -# } -# -# -# def get_csv_default(notes: DeckPartNotes, nmm: List[NoteModelMapping], csv_maps: List[FileMapping]) -> SourceCsv: -# csv_source = SourceCsv(setup_source_csv_config("", [], []), read_now=False) -# -# csv_source.notes = notes -# csv_source.note_model_mappings_dict = {nm_map.note_model.name: nm_map for nm_map in nmm} -# csv_source.csv_file_mappings = csv_maps -# -# return csv_source -# -# -# @pytest.fixture() -# def csv_source_test1(dp_notes_test1, nmm_test1, csv_file_mapping1) -> SourceCsv: -# return get_csv_default(dp_notes_test1, [nmm_test1], [csv_file_mapping1]) -# -# -# @pytest.fixture() -# def csv_source_test1_split1(csv_source_default, csv_test1_split1, dp_notes_test1) -> SourceCsv: -# csv_source_default.csv_file = csv_test1_split1 -# csv_source_default.notes = dp_notes_test1 -# return csv_source_default -# -# -# @pytest.fixture() -# def csv_source_test1_split2(csv_source_default2, csv_test1_split2, dp_notes_test2) -> SourceCsv: -# csv_source_default2.csv_file = csv_test1_split2 -# csv_source_default2.notes = dp_notes_test1 -# return csv_source_default2 -# -# -# @pytest.fixture() -# def csv_source_test2(dp_notes_test2, nmm_test1, csv_file_mapping2) -> SourceCsv: -# return get_csv_default(dp_notes_test2, [nmm_test1], [csv_file_mapping2]) -# -# -# # @pytest.fixture() -# # def temp_csv_source(global_config, tmpdir) -> SourceCsv: -# # file = tmpdir.mkdir("notes").join("file.csv") -# # file.write("test,1,2,3") -# -# -# class TestConstructor: -# def test_runs(self): -# source_csv = get_csv_default(None, [], []) -# assert isinstance(source_csv, SourceCsv) -# -# @pytest.mark.parametrize("notes, model, columns, personal_fields, csv_file", [ -# ("notes.json", "Test Model", {"a": "b"}, ["extra"], "file.csv") -# ]) -# def test_calls_correctly(self, notes, model, columns, personal_fields, csv_file, nmm_test1): -# nmm_config = [setup_nmm_config(model, columns, personal_fields)] -# csv_config = [setup_csv_fm_config(csv_file, note_model_name=model)] -# -# def assert_csv(config, read_now): -# assert config in csv_config -# assert read_now is False -# -# def assert_nmm(config, read_now): -# assert config in nmm_config -# assert read_now is False -# -# def assert_dpn(config, read_now): -# assert config == notes -# assert read_now is False -# -# with patch.object(FileMapping, "__init__", side_effect=assert_csv), \ -# patch.object(NoteModelMapping, "__init__", side_effect=assert_nmm), \ -# patch.object(NoteModelMapping, "note_model"), \ -# patch.object(DeckPartNotes, "create", side_effect=assert_dpn): -# -# #nmm_mock.return_value = False -# -# source_csv = SourceCsv(setup_source_csv_config( -# notes, -# nmm_config, -# csv_config -# ), read_now=False) -# -# -# # def test_missing_non_required_columns -# -# -# class TestSourceToDeckParts: -# def test_runs_first(self, csv_source_test1, dp_notes_test1, csv_source_test2, dp_notes_test2): -# self.run_s2dp(csv_source_test1, dp_notes_test1) -# self.run_s2dp(csv_source_test2, dp_notes_test2) -# -# @staticmethod -# def run_s2dp(csv_source: SourceCsv, dp_notes: DeckPartNotes): -# def assert_format(notes_data): -# assert notes_data == dp_notes.get_data()[DeckPartNoteKeys.NOTES.value] -# -# with patch.object(DeckPartNotes, "set_data", side_effect=assert_format) as mock_set_data: -# csv_source.source_to_parts() -# assert mock_set_data.call_count == 1 -# -# -# class TestDeckPartsToSource: -# def test_runs_with_no_change(self, csv_source_test1, csv_test1, csv_source_test2, csv_test2): -# -# self.run_dpts(csv_source_test1, csv_test1) -# self.run_dpts(csv_source_test2, csv_test2) -# -# @staticmethod -# def run_dpts(csv_source: SourceCsv, csv_file: CsvFile): -# def assert_format(source_data): -# assert source_data == csv_file.get_data() -# -# with patch.object(SourceFile, "set_data", side_effect=assert_format) as mock_set_data: -# csv_source.parts_to_source() -# assert csv_source.csv_file_mappings[0].data_set_has_changed is False -# -# csv_source.csv_file_mappings[0].data_set_has_changed = True -# csv_source.csv_file_mappings[0].write_file_on_close() -# assert mock_set_data.call_count == 1 -# diff --git a/tests/representation/__init__.py b/tests/representation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/representation/configuration/__init__.py b/tests/representation/configuration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/representation/configuration/test_csv_file_mapping.py b/tests/representation/configuration/test_csv_file_mapping.py deleted file mode 100644 index 0f2b75b..0000000 --- a/tests/representation/configuration/test_csv_file_mapping.py +++ /dev/null @@ -1,160 +0,0 @@ -# def setup_csv_fm_config(csv: str, sort_by_columns: List[str] = None, reverse_sort: bool = None, -# note_model_name: str = None, derivatives: List[dict] = None): -# cfm: dict = { -# FILE: csv -# } -# if sort_by_columns is not None: -# cfm.setdefault(SORT_BY_COLUMNS, sort_by_columns) -# if reverse_sort is not None: -# cfm.setdefault(REVERSE_SORT, reverse_sort) -# if note_model_name is not None: -# cfm.setdefault(NOTE_MODEL, note_model_name) -# if derivatives is not None: -# cfm.setdefault(DERIVATIVES, derivatives) -# -# return cfm -# - -# class TestConstructor: -# @pytest.mark.parametrize("read_file_now, note_model_name, csv, sort_by_columns, reverse_sort", [ -# (False, "note_model.json", "first.csv", ["guid"], False), -# (True, "model_model.json", "second.csv", ["guid", "note_model_name"], True), -# (False, "note_model-json", "first.csv", ["guid"], False,) -# ]) -# def test_runs_without_derivatives(self, read_file_now, note_model_name, csv, -# sort_by_columns, reverse_sort): -# get_new_file_manager() -# config = setup_csv_fm_config(csv, sort_by_columns, reverse_sort, note_model_name=note_model_name) -# -# def assert_csv(passed_file, read_now): -# assert passed_file == csv -# assert read_now == read_file_now -# -# with patch.object(FileMappingDerivative, "create_derivative", return_value=None) as mock_derivatives, \ -# patch.object(CsvFile, "create", side_effect=assert_csv) as mock_csv: -# -# csv_fm = FileMapping(config, read_now=read_file_now) -# -# assert isinstance(csv_fm, FileMapping) -# assert csv_fm.reverse_sort == reverse_sort -# assert csv_fm.sort_by_columns == sort_by_columns -# assert csv_fm.note_model_name == note_model_name -# -# assert mock_csv.call_count == 1 -# assert mock_derivatives.call_count == 0 -# -# @pytest.mark.parametrize("derivatives", [ -# [setup_csv_fm_config("test_csv.csv")], -# [setup_csv_fm_config("test_csv.csv"), setup_csv_fm_config("second.csv")], -# [setup_csv_fm_config("a.csv"), setup_csv_fm_config("b.csv"), setup_csv_fm_config("c.csv")], -# [setup_csv_fm_config("a.csv", sort_by_columns=["word", "guid"], reverse_sort=True, note_model_name="d")], -# [setup_csv_fm_config("test_csv.csv", derivatives=[setup_csv_fm_config("der_der.csv")])], -# ]) -# def test_runs_with_derivatives(self, derivatives: list): -# get_new_file_manager() -# config = setup_csv_fm_config("test", [], False, note_model_name="nm", derivatives=derivatives.copy()) -# expected_call_count = len(derivatives) -# -# def assert_der(passed_file, read_now): -# der = derivatives.pop(0) -# assert passed_file == der -# assert read_now is False -# -# with patch.object(FileMappingDerivative, "create_derivative", side_effect=assert_der) as mock_derivatives, \ -# patch.object(CsvFile, "create", return_value=None): -# -# csv_fm = FileMapping(config, read_now=False) -# -# assert mock_derivatives.call_count == len(csv_fm.derivatives) == expected_call_count - - -# def csv_fixture_gen(csv_fix): -# with patch.object(CsvFile, "create_or_get", return_value=csv_fix): -# csv = FileMapping(**setup_csv_fm_config("", note_model_name="Test Model")) -# csv.compile_data() -# return csv -# -# -# @pytest.fixture() -# def csv_file_mapping1(csv_test1): -# return csv_fixture_gen(csv_test1) -# -# -# @pytest.fixture() -# def csv_file_mapping2(csv_test2): -# return csv_fixture_gen(csv_test2) -# -# -# @pytest.fixture() -# def csv_file_mapping3(csv_test3): -# return csv_fixture_gen(csv_test3) -# -# -# @pytest.fixture() -# def csv_file_mapping_split1(csv_test1_split1): -# return csv_fixture_gen(csv_test1_split1) -# -# -# @pytest.fixture() -# def csv_file_mapping_split1(csv_test1_split2): -# return csv_fixture_gen(csv_test1_split2) -# -# -# @pytest.fixture() -# def csv_file_mapping2_missing_guids(csv_test2_missing_guids): -# return csv_fixture_gen(csv_test2_missing_guids) -# -# -# class TestSetRelevantData: -# def test_no_change(self, csv_file_mapping1: FileMapping, csv_file_mapping_split1: FileMapping): -# assert csv_file_mapping1.data_set_has_changed is False -# -# previous_data = csv_file_mapping1.compiled_data.copy() -# csv_file_mapping1.set_relevant_data(csv_file_mapping_split1.compiled_data) -# -# assert previous_data == csv_file_mapping1.compiled_data -# assert csv_file_mapping1.data_set_has_changed is False -# -# def test_change_but_no_extra(self, csv_file_mapping1: FileMapping, csv_file_mapping2: FileMapping): -# assert csv_file_mapping1.data_set_has_changed is False -# assert len(csv_file_mapping1.compiled_data) == 15 -# -# previous_data = copy.deepcopy(csv_file_mapping1.compiled_data) -# csv_file_mapping1.set_relevant_data(csv_file_mapping2.compiled_data) -# -# assert previous_data != csv_file_mapping1.compiled_data -# assert csv_file_mapping1.data_set_has_changed is True -# assert len(csv_file_mapping1.compiled_data) == 15 -# -# def test_change_extra_row(self, csv_file_mapping1: FileMapping, csv_file_mapping3: FileMapping): -# assert csv_file_mapping1.data_set_has_changed is False -# assert len(csv_file_mapping1.compiled_data) == 15 -# -# previous_data = copy.deepcopy(csv_file_mapping1.compiled_data.copy()) -# csv_file_mapping1.set_relevant_data(csv_file_mapping3.compiled_data) -# -# assert previous_data != csv_file_mapping1.compiled_data -# assert csv_file_mapping1.data_set_has_changed is True -# assert len(csv_file_mapping1.compiled_data) == 16 -# -# -# class TestCompileData: -# num = 0 -# -# def get_num(self): -# self.num += 1 -# return self.num -# -# def test_when_missing_guids(self, csv_file_mapping2_missing_guids: FileMapping): -# with patch("brain_brew.representation.configuration.csv_file_mapping.generate_anki_guid", wraps=self.get_num) as mock_guid: -# -# csv_file_mapping2_missing_guids.compile_data() -# -# assert csv_file_mapping2_missing_guids.data_set_has_changed is True -# assert mock_guid.call_count == 9 -# assert list(csv_file_mapping2_missing_guids.compiled_data.keys()) == list(range(1, 10)) - -# Tests still to do: -# -# Top level needs a NoteModel, others do not - diff --git a/tests/representation/configuration/test_note_model_mapping.py b/tests/representation/configuration/test_note_model_mapping.py deleted file mode 100644 index da4a2d2..0000000 --- a/tests/representation/configuration/test_note_model_mapping.py +++ /dev/null @@ -1,146 +0,0 @@ -# from unittest.mock import patch -# -# import pytest -# -# from brain_brew.representation.configuration.note_model_mapping import NoteModelMapping, FieldMapping -# from brain_brew.representation.generic.csv_file import CsvFile -# from tests.test_file_manager import get_new_file_manager -# -# -# @pytest.fixture(autouse=True) -# def run_around_tests(): -# get_new_file_manager() -# yield -# -# -# @pytest.fixture() -# def nmm_test1_repr() -> NoteModelMapping.Representation: -# return NoteModelMapping.Representation( -# "Test Model", -# { -# "guid": "guid", -# "tags": "tags", -# -# "english": "word", -# "danish": "otherword" -# }, -# [] -# ) -# -# -# @pytest.fixture() -# def nmm_test2_repr() -> NoteModelMapping.Representation: -# return NoteModelMapping.Representation( -# "Test Model", -# { -# "guid": "guid", -# "tags": "tags", -# -# "english": "word", -# "danish": "otherword" -# }, -# ["extra", "morph_focus"] -# ) -# -# -# @pytest.fixture() -# def nmm_test1(nmm_test1_repr) -> NoteModelMapping: -# return NoteModelMapping.from_repr(nmm_test1_repr) -# -# -# @pytest.fixture() -# def nmm_test2(nmm_test2_repr) -> NoteModelMapping: -# return NoteModelMapping.from_repr(nmm_test2_repr) -# -# -# class TestInit: -# def test_runs(self): -# nmm = NoteModelMapping.from_repr(NoteModelMapping.Representation("test", {}, [])) -# assert isinstance(nmm, NoteModelMapping) -# -# @pytest.mark.parametrize("read_file_now, note_model, personal_fields, columns", [ -# (False, "note_model.json", ["x"], {"guid": "guid", "tags": "tags", "english": "word", "danish": "otherword"}), -# (True, "model_model", [], {"guid": "guid", "tags": "tags"}), -# (False, "note_model-json", ["x", "y", "z"], {"guid": "guid", "tags": "tags", "english": "word", "danish": "otherword"}) -# ]) -# def test_values(self, read_file_now, note_model, personal_fields, columns): -# config = setup_nmm_config(note_model, columns, personal_fields) -# -# def assert_dp_note_model(passed_file, read_now): -# assert passed_file == note_model -# assert read_now == read_file_now -# -# with patch.object(DeckPartNoteModel, "create", side_effect=assert_dp_note_model) as mock_nm: -# -# nmm = NoteModelMapping(config, read_now=read_file_now) -# -# assert isinstance(nmm, NoteModelMapping) -# assert len(nmm.columns) == len(columns) -# assert len(nmm.personal_fields) == len(personal_fields) -# -# assert mock_nm.call_count == 1 -# -# -# class TestVerifyContents: -# pass # TODO -# -# -# class TestCsvRowNoteFieldConversion: -# @staticmethod -# def get_csv_row(): return { -# "guid": "AAAA", -# "tags": "nice card", -# -# "english": "what", -# "danish": "hvad" -# } -# -# @staticmethod -# def get_note_field(): return{ -# "guid": "AAAA", -# "tags": "nice card", -# -# "word": "what", -# "otherword": "hvad", -# "extra": False, -# "morph_focus": False -# } -# -# def test_csv_row_map_to_note_fields(self, nmm_test_with_personal_fields1): -# assert nmm_test_with_personal_fields1.csv_row_map_to_note_fields(self.get_csv_row()) == self.get_note_field() -# -# def test_note_fields_map_to_csv_row(self, nmm_test_with_personal_fields1): -# assert nmm_test_with_personal_fields1.note_fields_map_to_csv_row(self.get_note_field()) == self.get_csv_row() -# -# -# class TestGetRelevantData: -# def test_data_correct(self, nmm_test_with_personal_fields1: NoteModelMapping, csv_test1: CsvFile): -# expected_relevant_columns = ["guid", "english", "danish", "tags"] -# data = csv_test1.get_data() -# -# for row in data: -# relevant_data = nmm_test_with_personal_fields1.get_relevant_data(row) -# assert len(relevant_data) == 4 -# assert list(relevant_data.keys()) == expected_relevant_columns -# -# def test_data_missing_columns(self, nmm_test_with_personal_fields1: NoteModelMapping, csv_test1: CsvFile): -# row_missing = { -# "guid": "test", -# "english": "test" -# } -# with pytest.raises(Exception) as e: -# relevant_data = nmm_test_with_personal_fields1.get_relevant_data(row_missing) -# -# errors = e.value.args[0] -# assert len(errors) == 2 -# assert isinstance(errors[0], KeyError) -# assert isinstance(errors[1], KeyError) -# assert errors[0].args[0] == "Missing column tags" -# assert errors[1].args[0] == "Missing column danish" -# -# -# class TestFieldMapping: -# def test_init(self): -# fm = FieldMapping(FieldMapping.FieldMappingType.COLUMN, "Csv_Row", "note_model_field") -# assert isinstance(fm, FieldMapping) -# assert (fm.field_name, fm.value) == ("csv_row", "note_model_field") diff --git a/tests/representation/generic/__init__.py b/tests/representation/generic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/representation/generic/test_csv_file.py b/tests/representation/generic/test_csv_file.py deleted file mode 100644 index a6bd134..0000000 --- a/tests/representation/generic/test_csv_file.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest - -from brain_brew.representation.generic.csv_file import CsvFile -from tests.test_file_manager import get_new_file_manager -from tests.test_files import TestFiles - -get_new_file_manager() - - -@pytest.fixture() -def csv_test1(): - csv = CsvFile(TestFiles.CsvFiles.TEST1) - csv.read_file() - return csv - - -@pytest.fixture() -def tsv_test1(): - tsv = CsvFile(TestFiles.TsvFiles.TEST1, delimiter='\t') - tsv.read_file() - return tsv - - -@pytest.fixture() -def csv_test1_split1(): - csv = CsvFile(TestFiles.CsvFiles.TEST1_SPLIT1) - csv.read_file() - return csv - - -@pytest.fixture() -def csv_test1_split2(): - csv = CsvFile(TestFiles.CsvFiles.TEST1_SPLIT2) - csv.read_file() - return csv - - -@pytest.fixture() -def csv_test2(): - csv = CsvFile(TestFiles.CsvFiles.TEST2) - csv.read_file() - return csv - - -@pytest.fixture() -def csv_test3(): - csv = CsvFile(TestFiles.CsvFiles.TEST3) - csv.read_file() - return csv - - -@pytest.fixture() -def csv_test2_missing_guids(): - csv = CsvFile(TestFiles.CsvFiles.TEST2_MISSING_GUIDS) - csv.read_file() - return csv - - -@pytest.fixture() -def temp_csv_test1(tmpdir, csv_test1) -> CsvFile: - file = tmpdir.mkdir("json").join("file.csv") - file.write("blank") - - csv = CsvFile.create_or_get(file.strpath) - csv.read_file() - return csv - - -class TestConstructor: - def test_runs(self, csv_test1): - assert isinstance(csv_test1, CsvFile) - assert csv_test1.file_location == TestFiles.CsvFiles.TEST1 - assert "guid" in csv_test1.column_headers - - -def test_to_filename_csv(): - assert "read-this-file.csv" == CsvFile.to_filename_csv("read-this-file") - assert "read-this-file.csv" == CsvFile.to_filename_csv("read-this-file.csv") - assert "read-this-file.tsv" == CsvFile.to_filename_csv("read-this-file.tsv") - - -class TestWriteFile: - def test_runs(self, temp_csv_test1: CsvFile, csv_test1: CsvFile): - temp_csv_test1.set_data(csv_test1.get_data()) - temp_csv_test1.write_file() - temp_csv_test1.read_file() - - assert temp_csv_test1.get_data() == csv_test1.get_data() - - def test_tsv_same_data(self, temp_csv_test1: CsvFile, tsv_test1: CsvFile): - temp_csv_test1.set_data(tsv_test1.get_data()) - temp_csv_test1.write_file() - temp_csv_test1.read_file() - - assert temp_csv_test1.get_data() == tsv_test1.get_data() - - -class TestSortData: - @pytest.mark.parametrize("columns, reverse, result_column, expected_results", [ - (["guid"], False, "guid", [(0, "AAAA"), (1, "BBBB"), (2, "CCCC"), (14, "OOOO")]), - (["guid"], True, "guid", [(14, "AAAA"), (13, "BBBB"), (12, "CCCC"), (0, "OOOO")]), - (["english"], False, "english", [(0, "banana"), (1, "bird"), (2, "cat"), (14, "you")]), - (["english"], True, "english", [(14, "banana"), (13, "bird"), (12, "cat"), (0, "you")]), - (["tags"], False, "tags", [(0, "besttag"), (1, "funny"), (2, "tag2 tag3"), (13, ""), (14, "")]), - (["esperanto", "english"], False, "esperanto", [(0, "banano"), (1, "birdo"), (6, "vi"), (14, "")]), - (["esperanto", "guid"], False, "guid", [(7, "BBBB"), (14, "LLLL")]), - ]) - def test_sort(self, csv_test1: CsvFile, columns, reverse, result_column, expected_results): - csv_test1.sort_data(columns, reverse, case_insensitive_sort=True) - - sorted_data = csv_test1.get_data() - - for result in expected_results: - assert sorted_data[result[0]][result_column] == result[1] - - def test_insensitive(self): - pass diff --git a/tests/representation/generic/test_media_file.py b/tests/representation/generic/test_media_file.py deleted file mode 100644 index 9711021..0000000 --- a/tests/representation/generic/test_media_file.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from brain_brew.representation.generic.media_file import MediaFile - - -@pytest.fixture() -def media_file_test1() -> MediaFile: - return MediaFile("place/loc/file.txt") - - -class TestConstructor: - def test_without_override(self): - loc = "place/loc/file.txt" - - media_file = MediaFile(loc) - - assert isinstance(media_file, MediaFile) - assert media_file.file_path == loc - assert media_file.filename == "file.txt" - - -class TestCopy: - def test_copies_file(self, tmpdir): - source_dir = tmpdir.mkdir("source") - source = source_dir.join("test.txt") - source.write("test content") - assert len(source_dir.listdir()) == 1 - - target_dir = tmpdir.mkdir("target") - target = target_dir.join("test.txt") - assert len(target_dir.listdir()) == 0 - - media_file = MediaFile(str(source)) - media_file.copy_self_to_target(str(target)) - - assert len(target_dir.listdir()) == len(source_dir.listdir()) == 1 - assert target.read() == "test content" diff --git a/tests/representation/json/__init__.py b/tests/representation/json/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/representation/json/test_crowd_anki_export.py b/tests/representation/json/test_crowd_anki_export.py deleted file mode 100644 index 01c0c22..0000000 --- a/tests/representation/json/test_crowd_anki_export.py +++ /dev/null @@ -1,49 +0,0 @@ -import pytest - -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from tests.test_files import TestFiles - - -class TestConstructor: - @pytest.mark.parametrize("export_name", [ - TestFiles.CrowdAnkiExport.TEST1_FOLDER, - TestFiles.CrowdAnkiExport.TEST1_FOLDER_WITHOUT_SLASH - ]) - def test_runs(self, export_name): - file = CrowdAnkiExport(export_name) - - assert isinstance(file, CrowdAnkiExport) - assert file.folder_location == TestFiles.CrowdAnkiExport.TEST1_FOLDER - assert file.json_file_location == TestFiles.CrowdAnkiExport.TEST1_JSON - assert len(file.json_data.data.keys()) == 13 - - -class TestFindJsonFileInFolder: - # def test_no_json_file(self, tmpdir): - # directory = tmpdir.mkdir("test") - # - # with pytest.raises(FileNotFoundError): - # file = CrowdAnkiExport(directory.strpath) - - def test_too_many_json_files(self, tmpdir): - directory = tmpdir.mkdir("test") - file1, file2 = directory.join("file1.json"), directory.join("file2.json") - file1.write("{}") - file2.write("{}") - - with pytest.raises(FileExistsError): - file = CrowdAnkiExport(directory.strpath) - - -@pytest.fixture() -def ca_export_test1() -> CrowdAnkiExport: - return CrowdAnkiExport.create_or_get(TestFiles.CrowdAnkiExport.TEST1_FOLDER) - - -@pytest.fixture() -def temp_ca_export_file(tmpdir) -> CrowdAnkiExport: - folder = tmpdir.mkdir("ca_export") - file = folder.join("file.json") - file.write("{}") - - return CrowdAnkiExport(folder.strpath) diff --git a/tests/representation/yaml/__init__.py b/tests/representation/yaml/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/representation/yaml/test_note_model_repr.py b/tests/representation/yaml/test_note_model_repr.py deleted file mode 100644 index a5ae878..0000000 --- a/tests/representation/yaml/test_note_model_repr.py +++ /dev/null @@ -1,128 +0,0 @@ -import pytest - -from brain_brew.representation.json.json_file import JsonFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template -from brain_brew.representation.yaml.yaml_object import YamlObject -from tests.test_files import TestFiles - - -# CrowdAnki Files -------------------------------------------------------------------------- -from tests.test_helpers import debug_write_part_to_file - - -@pytest.fixture -def ca_nm_data_word(): - return JsonFile.read_file(TestFiles.CrowdAnkiNoteModels.LL_WORD) - - -@pytest.fixture -def ca_nm_word(ca_nm_data_word) -> NoteModel: - return NoteModel.from_crowdanki(ca_nm_data_word) - - -@pytest.fixture -def ca_nm_data_word_required_only(): - return JsonFile.read_file(TestFiles.CrowdAnkiNoteModels.LL_WORD_ONLY_REQUIRED) - - -@pytest.fixture -def ca_nm_word_required_only(ca_nm_data_word_required_only) -> NoteModel: - return NoteModel.from_crowdanki(ca_nm_data_word_required_only) - - -@pytest.fixture -def ca_nm_data_word_no_defaults(): - return JsonFile.read_file(TestFiles.CrowdAnkiNoteModels.LL_WORD_NO_DEFAULTS) - - -@pytest.fixture -def ca_nm_word_no_defaults(ca_nm_data_word_no_defaults) -> NoteModel: - return NoteModel.from_crowdanki(ca_nm_data_word_no_defaults) - - -# Yaml Files -------------------------------------------------------------------------- -@pytest.fixture -def nm_data_word_required_only(): - return YamlObject.read_to_dict(TestFiles.NoteModels.LL_WORD_ONLY_REQUIRED) - - -@pytest.fixture -def nm_data_word_no_defaults(): - return YamlObject.read_to_dict(TestFiles.NoteModels.LL_WORD_NO_DEFAULTS) - - -class TestCrowdAnkiNoteModel: - class TestConstructor: - def test_normal(self, ca_nm_word): - model = ca_nm_word - assert isinstance(model, NoteModel) - - assert model.name == "LL Word" - assert isinstance(model.fields, list) - assert len(model.fields) == 7 - assert all([isinstance(field, Field) for field in model.fields]) - - assert isinstance(model.templates, list) - assert len(model.templates) == 7 - assert all([isinstance(template, Template) for template in model.templates]) - - def test_only_required(self, ca_nm_word_required_only): - model = ca_nm_word_required_only - assert isinstance(model, NoteModel) - - def test_manual_construction(self): - model = NoteModel( - "name", - "23094149+8124+91284+12984", - "css is garbage", - [], - [Field( - "field1" - )], - [Template( - "template1", - "{{Question}}", - "{{Answer}}" - )] - ) - - assert isinstance(model, NoteModel) - - class TestEncodeAsCrowdAnki: - def test_normal(self, ca_nm_word, ca_nm_data_word): - model = ca_nm_word - - encoded = model.encode_as_crowdanki() - # JsonFile.write_file(TestFiles.CrowdAnkiNoteModels.LL_WORD, encoded) - - assert encoded == ca_nm_data_word - - def test_only_required_uses_defaults(self, ca_nm_word_required_only, - ca_nm_data_word, ca_nm_data_word_required_only): - model = ca_nm_word_required_only - - encoded = model.encode_as_crowdanki() - - assert encoded != ca_nm_data_word_required_only - assert encoded == ca_nm_data_word - - class TestEncodeAsDeckPart: - def test_normal(self, ca_nm_word, ca_nm_data_word, ca_nm_data_word_required_only, nm_data_word_required_only): - model = ca_nm_word - - encoded = model.encode() - - assert encoded != ca_nm_data_word - assert encoded != ca_nm_data_word_required_only - assert encoded == nm_data_word_required_only - - def test_only_required_uses_defaults(self, ca_nm_word_no_defaults, ca_nm_data_word_no_defaults, nm_data_word_no_defaults): - model = ca_nm_word_no_defaults - - encoded = model.encode() - - - assert encoded != ca_nm_data_word_no_defaults - assert encoded == nm_data_word_no_defaults diff --git a/tests/representation/yaml/test_note_repr.py b/tests/representation/yaml/test_note_repr.py deleted file mode 100644 index 1a704f5..0000000 --- a/tests/representation/yaml/test_note_repr.py +++ /dev/null @@ -1,392 +0,0 @@ -from textwrap import dedent -from typing import List, Set - -import pytest - -from brain_brew.representation.yaml.notes import Note, NoteGrouping, Notes, \ - NOTES, NOTE_GROUPINGS, FIELDS, GUID, NOTE_MODEL, TAGS, FLAGS - -working_notes = { - "test1": {FIELDS: ['first'], GUID: "12345", NOTE_MODEL: "model_name", TAGS: ['noun', 'other']}, - "test2": {FIELDS: ['english', 'german'], GUID: "sdfhfghsvsdv", NOTE_MODEL: "LL Test", TAGS: ['marked']}, - "no_note_model": {FIELDS: ['first'], GUID: "12345", TAGS: ['noun', 'other']}, - "no_note_model2": {FIELDS: ['second'], GUID: "67890", TAGS: ['noun', 'other']}, - "no_tags1": {FIELDS: ['first'], GUID: "12345", NOTE_MODEL: "model_name"}, - "no_tags2": {FIELDS: ['first'], GUID: "12345", NOTE_MODEL: "model_name", TAGS: []}, - "no_model_or_tags": {FIELDS: ['first'], GUID: "12345"}, - "test1_with_default_flags": {FIELDS: ['first'], GUID: "12345", NOTE_MODEL: "model_name", TAGS: ['noun', 'other'], FLAGS: 0}, - "test1_with_flags": {FIELDS: ['first'], GUID: "12345", NOTE_MODEL: "model_name", TAGS: ['noun', 'other'], FLAGS: 1}, -} - -working_note_groupings = { - "nothing_grouped": {NOTES: [working_notes["test1"], working_notes["test2"]]}, - "note_model_grouped": {NOTES: [working_notes["no_note_model"], working_notes["no_note_model2"]], NOTE_MODEL: "model_name"}, - "note_model_grouped2": {NOTES: [working_notes["no_note_model"], working_notes["no_note_model2"]], NOTE_MODEL: "different_model"}, - "tags_grouped": {NOTES: [working_notes["no_tags1"], working_notes["no_tags2"]], TAGS: ["noun", "other"]}, - "tags_grouped_as_addition": {NOTES: [working_notes["test1"], working_notes["test2"]], TAGS: ["test", "recent"]}, - "model_and_tags_grouped": {NOTES: [working_notes["no_model_or_tags"], working_notes["no_model_or_tags"]], NOTE_MODEL: "model_name", TAGS: ["noun", "other"]} -} - -working_dpns = { - "one_group": {NOTE_GROUPINGS: [working_note_groupings["nothing_grouped"]]}, - "two_groups_two_models": {NOTE_GROUPINGS: [working_note_groupings["nothing_grouped"], working_note_groupings["note_model_grouped"]]}, - "two_groups_three_models": {NOTE_GROUPINGS: [working_note_groupings["nothing_grouped"], working_note_groupings["note_model_grouped2"]]}, -} - - -@pytest.fixture(params=working_notes.values()) -def note_fixtures(request): - return Note.from_dict(request.param) - - -@pytest.fixture(params=working_note_groupings.values()) -def note_grouping_fixtures(request): - return NoteGrouping.from_dict(request.param) - - -class TestConstructor: - class TestNote: - @pytest.mark.parametrize("fields, guid, note_model, tags, flags, media", [ - ([], "", "", [], 0, {}), - (None, None, None, None, None, None), - (["test", "blah", "whatever"], "1234567890x", "model_name", ["noun"], 1, {}), - (["test", "blah", ""], "1234567890x", "model_name", ["noun"], 2, {"animal.jpg"}), - ]) - def test_constructor(self, fields: List[str], guid: str, note_model: str, tags: List[str], flags: int, media: Set[str]): - note = Note(fields=fields, guid=guid, note_model=note_model, tags=tags, flags=flags) - - assert isinstance(note, Note) - assert note.fields == fields - assert note.guid == guid - assert note.note_model == note_model - assert note.tags == tags - assert note.flags == flags - # assert note.media_references == media - - def test_from_dict(self, note_fixtures): - assert isinstance(note_fixtures, Note) - - class TestNoteGrouping: - def test_constructor(self): - note_grouping = NoteGrouping(notes=[Note.from_dict(working_notes["test1"])], note_model=None, tags=None) - - assert isinstance(note_grouping, NoteGrouping) - assert isinstance(note_grouping.notes, List) - - def test_from_dict(self, note_grouping_fixtures): - assert isinstance(note_grouping_fixtures, NoteGrouping) - - class TestDeckPartNote: - def test_constructor(self): - dpn = Notes(note_groupings=[NoteGrouping.from_dict(working_note_groupings["nothing_grouped"])]) - assert isinstance(dpn, Notes) - - def test_from_dict(self): - dpn = Notes.from_dict({NOTE_GROUPINGS: [working_note_groupings["nothing_grouped"]]}) - assert isinstance(dpn, Notes) - - -class TestDumpToYaml: - @staticmethod - def _make_temp_file(tmpdir): - folder = tmpdir.mkdir("yaml_files") - file = folder.join("test.yaml") - file.write("test") - return file - - class TestNote: - @staticmethod - def _assert_dump_to_yaml(tmpdir, ystring, note_name): - file = TestDumpToYaml._make_temp_file(tmpdir) - - note = Note.from_dict(working_notes[note_name]) - note.dump_to_yaml(str(file)) - - assert file.read() == ystring - - def test_all1(self, tmpdir): - ystring = dedent(f'''\ - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "test1") - - def test_all2(self, tmpdir): - ystring = dedent(f'''\ - {FIELDS}: - - english - - german - {GUID}: sdfhfghsvsdv - {NOTE_MODEL}: LL Test - {TAGS}: - - marked - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "test2") - - def test_no_note_model(self, tmpdir): - ystring = dedent(f'''\ - {FIELDS}: - - first - {GUID}: '12345' - {TAGS}: - - noun - - other - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "no_note_model") - - def test_no_tags(self, tmpdir): - for num, note in enumerate(["no_tags1", "no_tags2"]): - ystring = dedent(f'''\ - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - ''') - - self._assert_dump_to_yaml(tmpdir.mkdir(str(num)), ystring, note) - - def test_with_flags(self, tmpdir): - ystring = dedent(f'''\ - {FIELDS}: - - first - {GUID}: '12345' - {FLAGS}: 1 - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "test1_with_flags") - - def test_with_default_flags(self, tmpdir): - ystring = dedent(f'''\ - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "test1_with_default_flags") - - class TestNoteGrouping: - @staticmethod - def _assert_dump_to_yaml(tmpdir, ystring, note_grouping_name): - file = TestDumpToYaml._make_temp_file(tmpdir) - - note = NoteGrouping.from_dict(working_note_groupings[note_grouping_name]) - note.dump_to_yaml(str(file)) - - assert file.read() == ystring - - def test_nothing_grouped(self, tmpdir): - ystring = dedent(f'''\ - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - - {FIELDS}: - - english - - german - {GUID}: sdfhfghsvsdv - {NOTE_MODEL}: LL Test - {TAGS}: - - marked - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "nothing_grouped") - - def test_note_model_grouped(self, tmpdir): - ystring = dedent(f'''\ - {NOTE_MODEL}: model_name - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - {TAGS}: - - noun - - other - - {FIELDS}: - - second - {GUID}: '67890' - {TAGS}: - - noun - - other - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "note_model_grouped") - - def test_note_tags_grouped(self, tmpdir): - ystring = dedent(f'''\ - {TAGS}: - - noun - - other - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "tags_grouped") - - def test_note_model_and_tags_grouped(self, tmpdir): - ystring = dedent(f'''\ - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - - {FIELDS}: - - first - {GUID}: '12345' - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, "model_and_tags_grouped") - - class TestDeckPartNotes: - @staticmethod - def _assert_dump_to_yaml(tmpdir, ystring, groups: list): - file = TestDumpToYaml._make_temp_file(tmpdir) - - note = Notes.from_dict({NOTE_GROUPINGS: [working_note_groupings[name] for name in groups]}) - note.dump_to_yaml(str(file)) - - assert file.read() == ystring - - def test_two_groupings(self, tmpdir): - ystring = dedent(f'''\ - {NOTE_GROUPINGS}: - - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - - {FIELDS}: - - first - {GUID}: '12345' - - {NOTES}: - - {FIELDS}: - - first - {GUID}: '12345' - {NOTE_MODEL}: model_name - {TAGS}: - - noun - - other - - {FIELDS}: - - english - - german - {GUID}: sdfhfghsvsdv - {NOTE_MODEL}: LL Test - {TAGS}: - - marked - ''') - - self._assert_dump_to_yaml(tmpdir, ystring, ["model_and_tags_grouped", "nothing_grouped"]) - - -class TestFunctionality: - class TestGetMediaReferences: - class TestNote: - @pytest.mark.parametrize("fields, expected_count", [ - ([], 0), - (["nothing", "empty", "can't find nothing here"], 0), - (["", "empty", "can't find nothing here"], 1), - (["", "", ""], 1), - (["", "", ""], 3), - (["", "[sound:test.mp3]", "[sound:test.mp3]"], 2), - ]) - def test_all(self, fields, expected_count): - note = Note(fields=fields, note_model=None, guid="", tags=None, flags=0) - media_found = note.get_all_media_references() - assert isinstance(media_found, Set) - assert len(media_found) == expected_count - - class TestGetAllNoteModels: - class TestNoteGrouping: - def test_nothing_grouped(self): - group = NoteGrouping.from_dict(working_note_groupings["nothing_grouped"]) - models = group.get_all_known_note_model_names() - assert models == {'LL Test', 'model_name'} - - def test_grouped(self): - group = NoteGrouping.from_dict(working_note_groupings["note_model_grouped"]) - models = group.get_all_known_note_model_names() - assert models == {'model_name'} - - class TestDeckPartNotes: - def test_two_groups_two_models(self): - dpn = Notes.from_dict(working_dpns["two_groups_two_models"]) - models = dpn.get_all_known_note_model_names() - assert models == {'LL Test', 'model_name'} - - def test_two_groups_three_models(self): - dpn = Notes.from_dict(working_dpns["two_groups_three_models"]) - models = dpn.get_all_known_note_model_names() - assert models == {'LL Test', 'model_name', 'different_model'} - - # class TestGetAllNotes: - # class TestNoteGrouping: - # def test_nothing_grouped(self): - # group = NoteGrouping.from_dict(working_note_groupings["nothing_grouped"]) - # notes = group.get_all_notes_copy([], False) - # assert len(notes) == 2 - # - # def test_model_grouped(self): - # group = NoteGrouping.from_dict(working_note_groupings["note_model_grouped"]) - # assert group.note_model == "model_name" - # assert all([note.note_model is None for note in group.notes]) - # - # notes = group.get_all_notes_copy() - # assert {note.note_model for note in notes} == {"model_name"} - # - # def test_tags_grouped(self): - # group = NoteGrouping.from_dict(working_note_groupings["tags_grouped"]) - # assert group.tags == ["noun", "other"] - # assert all([note.tags is None or note.tags == [] for note in group.notes]) - # - # notes = group.get_all_notes_copy() - # assert all([note.tags == ["noun", "other"] for note in notes]) - # - # def test_tags_grouped_as_addition(self): - # group = NoteGrouping.from_dict(working_note_groupings["tags_grouped_as_addition"]) - # assert group.tags == ["test", "recent"] - # assert all([note.tags is not None for note in group.notes]) - # - # notes = group.get_all_notes_copy() - # assert notes[0].tags == ['noun', 'other', "test", "recent"] - # assert notes[1].tags == ['marked', "test", "recent"] - # - # def test_no_tags(self): - # group = NoteGrouping.from_dict(working_note_groupings["tags_grouped"]) - # group.tags = None - # assert all([note.tags is None or note.tags == [] for note in group.notes]) - # - # notes = group.get_all_notes_copy() - # assert all([note.tags == [] for note in notes]) - # diff --git a/tests/test_argument_reader.py b/tests/test_argument_reader.py deleted file mode 100644 index 089e77e..0000000 --- a/tests/test_argument_reader.py +++ /dev/null @@ -1,51 +0,0 @@ -from argparse import ArgumentParser, ArgumentError -from unittest.mock import patch - -import pytest - -from brain_brew.commands.argument_reader import BBArgumentReader, Commands - - -@pytest.fixture() -def arg_reader_test1(): - return BBArgumentReader(test_mode=True) - - -def test_constructor(arg_reader_test1): - assert isinstance(arg_reader_test1, BBArgumentReader) - assert isinstance(arg_reader_test1, ArgumentParser) - - -class TestArguments: - class CommandRun: - @pytest.mark.parametrize("arguments", [ - ([Commands.RUN_RECIPE.value]), - ([Commands.RUN_RECIPE.value, ""]), - ]) - def test_broken_arguments(self, arg_reader_test1, arguments): - def raise_exit(message): - raise SystemExit - - with pytest.raises(SystemExit): - with patch.object(BBArgumentReader, "error", side_effect=raise_exit): - arg_reader_test1.get_parsed(arguments) - - @pytest.mark.parametrize("arguments, recipe, verify_only", [ - ([Commands.RUN_RECIPE.value, "test_recipe.yaml"], "test_recipe.yaml", False), - ([Commands.RUN_RECIPE.value, "test_recipe.yaml", "--verify"], "test_recipe.yaml", True), - ([Commands.RUN_RECIPE.value, "test_recipe.yaml", "-v"], "test_recipe.yaml", True), - ]) - def test_correct_arguments(self, arg_reader_test1, arguments, recipe, verify_only): - parsed_args = arg_reader_test1.parse_args(arguments) - - assert parsed_args.recipe == recipe - assert parsed_args.verify_only == verify_only - - class CommandInit: - @pytest.mark.parametrize("arguments, location", [ - (["init", "crowdankifolder72"], "crowdankifolder72"), - ]) - def test_correct_arguments(self, arg_reader_test1, arguments, location): - parsed_args = arg_reader_test1.parse_args(arguments) - - assert parsed_args.crowdanki_folder == location diff --git a/tests/test_builder.py b/tests/test_builder.py deleted file mode 100644 index 6eb4445..0000000 --- a/tests/test_builder.py +++ /dev/null @@ -1,12 +0,0 @@ -# class TestConstructor: -# def test_runs(self): -# with patch.object(CsvsGenerate, "__init__", return_value=None) as mock_csv_tr, \ -# patch.object(DeckPartHolder, "from_part_pool", return_value=Mock()), \ -# patch.object(CsvFile, "create_or_get", return_value=Mock()): -# -# data = YamlObject.read_to_dict(TestFiles.BuildConfig.ONE_OF_EACH_TYPE) -# builder = TopLevelRecipeBuilder.from_list(data) -# builder.execute() -# -# assert len(builder.tasks) == 1 -# assert mock_csv_tr.call_count == 1 diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py deleted file mode 100644 index e69d0ad..0000000 --- a/tests/test_file_manager.py +++ /dev/null @@ -1,32 +0,0 @@ -from brain_brew.configuration.file_manager import FileManager - - -def get_new_file_manager(): - FileManager.clear_instance() - return FileManager() - - -# class TestSingletonConstructor: -# def test_runs(self, global_config): -# fm = get_new_file_manager() -# assert isinstance(fm, FileManager) -# -# def test_returns_existing_singleton(self): -# fm = get_new_file_manager() -# fm.known_files_dict = {'test': None} -# fm2 = FileManager.get_instance() -# -# assert fm2.known_files_dict == {'test': None} -# assert fm2 == fm -# -# def test_raises_error(self): -# with pytest.raises(Exception): -# FileManager() -# FileManager() -# -# -# class TestFindMediaFiles: -# def test_finds(self): -# fm = get_new_file_manager() -# -# assert len(fm.known_media_files_dict) == 2 diff --git a/tests/test_files.py b/tests/test_files.py deleted file mode 100644 index a01ef63..0000000 --- a/tests/test_files.py +++ /dev/null @@ -1,72 +0,0 @@ - - -class TestFiles: - class Headers: - LOC = "tests/test_files/deck_parts/headers/" - - FIRST = "default header" - FIRST_COMPLETE = "default-header.json" - - class NoteFiles: - LOC = "tests/test_files/deck_parts/" - - TEST1_NO_GROUPING_OR_SHARED_TAGS = "csvtonotes1_withnogroupingorsharedtags.json" - TEST1_WITH_GROUPING = "csvtonotes1_withgrouping.json" - TEST1_WITH_SHARED_TAGS = "csvtonotes1_withsharedtags.json" - TEST1_WITH_SHARED_TAGS_EMPTY_AND_GROUPING = "csvtonotes1_withsharedtagsandgrouping_butnothingtogroup.json" - TEST2_WITH_SHARED_TAGS_AND_GROUPING = "csvtonotes2_withsharedtagsandgrouping.json" - - class CrowdAnkiNoteModels: - LOC = "tests/test_files/deck_parts/note_models/" - - TEST = "Test Model" - - LL_WORD = LOC + "LL Word" - - LL_WORD_ONLY_REQUIRED = LOC + "LL Word Only Required" - - LL_WORD_NO_DEFAULTS = LOC + "LL Word No Defaults" - - class NoteModels: - LOC = "tests/test_files/deck_parts/yaml/note_models/" - - LL_WORD = LOC + "LL-Word.yaml" - - LL_WORD_ONLY_REQUIRED = LOC + "LL-Word-Only-Required.yaml" - - LL_WORD_NO_DEFAULTS = LOC + "LL-Word-No-Defaults.yaml" - - class CsvFiles: - LOC = "tests/test_files/csv/" - - TEST1 = LOC + "test1.csv" - TEST1_SPLIT1 = LOC + "test1_split1.csv" - TEST1_SPLIT2 = LOC + "test1_split2.csv" - TEST2 = LOC + "test2.csv" - TEST2_MISSING_GUIDS = LOC + "test2_missing_guids.csv" - TEST3 = LOC + "test3.csv" - - class TsvFiles: - LOC = "tests/test_files/tsv/" - - TEST1 = LOC + "test1.tsv" - - class CrowdAnkiExport: - LOC = "tests/test_files/crowd_anki/" - - TEST1_FOLDER = LOC + "crowdanki_example_1/" - TEST1_FOLDER_WITHOUT_SLASH = LOC + "crowdanki_example_1" - TEST1_JSON = TEST1_FOLDER + "deck.json" - - class BuildConfig: - LOC = "tests/test_files/build_files/" - - ONE_OF_EACH_TYPE = LOC + "builder1.yaml" - - class MediaFiles: - LOC = "tests/test_files/media_files/" - - class YamlNotes: - LOC = "tests/test_files/yaml/notes/" - - TEST1 = LOC + "note1.yaml" diff --git a/tests/test_files/build_files/builder1.yaml b/tests/test_files/build_files/builder1.yaml deleted file mode 100644 index c45cda5..0000000 --- a/tests/test_files/build_files/builder1.yaml +++ /dev/null @@ -1,42 +0,0 @@ - -- generate_csv_collection: - notes: test_from_CA - - note_model_mappings: - - note_models: - - LL Word - - LL Verb - - LL Noun - columns_to_fields: - guid: guid - tags: tags - - english: Word - danish: X Word - danish audio: X Pronunciation (Recording and/or IPA) - esperanto: Y Word - esperanto audio: Y Pronunciation (Recording and/or IPA) - - present: Form Present - past: Form Past - present perfect: Form Perfect Present - - plural: Plural - indefinite plural: Indefinite Plural - definite plural: Definite Plural - personal_fields: - - picture - - extra - - morphman_focusmorph - - file_mappings: - - file: source/vocab/main.csv - note_model: LL Word - sort_by_columns: [english] - reverse_sort: false - - derivatives: - - file: source/vocab/derivatives/danish/danish_verbs.csv - note_model: LL Verb - - file: source/vocab/derivatives/danish/danish_nouns.csv - note_model: LL Noun \ No newline at end of file diff --git a/tests/test_files/crowd_anki/crowdanki_example_1/deck.json b/tests/test_files/crowd_anki/crowdanki_example_1/deck.json deleted file mode 100644 index ac970af..0000000 --- a/tests/test_files/crowd_anki/crowdanki_example_1/deck.json +++ /dev/null @@ -1,398 +0,0 @@ -{ - "__type__": "Deck", - "children": [], - "crowdanki_uuid": "16bef726-1426-11ea-a85d-d8cb8ac9abf0", - "deck_config_uuid": "3cc64d85-e410-11e9-960e-d8cb8ac9abf0", - "deck_configurations": [ - { - "__type__": "DeckConfig", - "autoplay": true, - "crowdanki_uuid": "3cc64d85-e410-11e9-960e-d8cb8ac9abf0", - "currentValue": 120, - "dyn": false, - "lapse": { - "delays": [ - 10, - 1440 - ], - "leechAction": 1, - "leechFails": 8, - "minInt": 1, - "mult": 0.25 - }, - "maxLife": 120, - "maxTaken": 60, - "name": "LL Default", - "new": { - "bury": false, - "delays": [ - 1, - 15 - ], - "initialFactor": 2500, - "ints": [ - 5, - 10, - 7 - ], - "order": 0, - "perDay": 3, - "separate": true - }, - "recover": 5, - "replayq": true, - "rev": { - "bury": true, - "ease4": 1.5, - "fuzz": 0.05, - "hardFactor": 1.2, - "ivlFct": 1.5, - "maxIvl": 36500, - "minSpace": 1, - "perDay": 70 - }, - "timer": 0 - } - ], - "desc": "", - "dyn": 0, - "extendNew": 10, - "extendRev": 50, - "name": "LL::1. Vocab", - "note_models": [ - { - "__type__": "NoteModel", - "crowdanki_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color: #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background: linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n}\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}", - "flds": [ - { - "name": "Word" - }, - { - "name": "OtherWord" - } - ], - "latexPost": "\\end{document}", - "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", - "name": "Test Model", - "req": [ - [ - 0, - "all", - [ - 1 - ] - ], - [ - 1, - "all", - [ - 2 - ] - ], - [ - 2, - "all", - [ - 1, - 3 - ] - ], - [ - 3, - "all", - [ - 2, - 3 - ] - ], - [ - 4, - "all", - [ - 1, - 3 - ] - ], - [ - 5, - "all", - [ - 2, - 3 - ] - ], - [ - 6, - "all", - [ - 1, - 2, - 3 - ] - ] - ], - "sortf": 0, - "tags": [ - "Meta::InProgress" - ], - "tmpls": [ - { - "afmt": "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Comprehension", - "ord": 0, - "qfmt": "{{#X Word}}\n\t{{text:X Word}}\n{{/X Word}}" - }, - { - "afmt": "{{#Y Word}}\n\t {{Y Word}}\n{{/Y Word}}\n\n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Comprehension", - "ord": 1, - "qfmt": "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{X Word}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Production", - "ord": 2, - "qfmt": "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n {{Y Word}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Production", - "ord": 3, - "qfmt": "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Spelling", - "ord": 4, - "qfmt": "{{#X Word}}\n\t
Spell this word:
\n\n\t{{type:X Word}}\n\n\t
{{Picture}}\n{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Spelling", - "ord": 5, - "qfmt": "{{#Y Word}}\n\t
Spell this word:
\n\n\t{{type:Y Word}}\n\n\t
{{Picture}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n
{{text:X Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X and Y Production", - "ord": 6, - "qfmt": "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}\n" - } - ], - "type": 0, - "vers": [] - } - ], - "notes": [ - { - "fields": [ - "you", - "du" - ], - "guid": "AAAA", - "tags": [ - "funny" - ], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "healthy", - "rask" - ], - "guid": "BBBB", - "tags": [ - "tag2", - "tag3" - ], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "tired", - "træt" - ], - "guid": "CCCC", - "tags": [ - "besttag" - ], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "banana", - "en banan" - ], - "guid": "DDDD", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "cat", - "en kat" - ], - "guid": "EEEE", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "dog", - "en hund" - ], - "guid": "FFFF", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "fish", - "en fisk" - ], - "guid": "GGGG", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "bird", - "en fugl" - ], - "guid": "HHHH", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "cow", - "en ko" - ], - "guid": "IIII", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "pig", - "et svin" - ], - "guid": "JJJJ", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "mouse", - "en mus" - ], - "guid": "KKKK", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "horse", - "en hest" - ], - "guid": "LLLL", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "to learn", - "at lære" - ], - "guid": "MMMM", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "to eat", - "at spise" - ], - "guid": "NNNN", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - }, - { - "fields": [ - "to drink", - "at drikke" - ], - "guid": "OOOO", - "tags": [], - "__type__": "Note", - "data": "", - "flags": 0, - "note_model_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - } - ], - "media_files": [] -} \ No newline at end of file diff --git a/tests/test_files/csv/test1.csv b/tests/test_files/csv/test1.csv deleted file mode 100644 index 7308d3b..0000000 --- a/tests/test_files/csv/test1.csv +++ /dev/null @@ -1,16 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -AAAA,you,du,vi,[sound:pronunciation_da_du.mp3],,,funny -BBBB,healthy,rask,,,,test,tag2 tag3 -CCCC,tired,træt,,,,,besttag -DDDD,banana,en banan,banano,[sound:pronunciation_da_banan.mp3],,, -EEEE,cat,en kat,,,,, -FFFF,dog,en hund,hundo,,,, -GGGG,fish,en fisk,,,,, -HHHH,bird,en fugl,birdo,,,, -IIII,cow,en ko,,,,, -JJJJ,pig,et svin,,,,, -KKKK,mouse,en mus,,,,, -LLLL,horse,en hest,,,,, -MMMM,to learn,at lære,lerni,[sound:pronunciation_da_lære.mp3],,, -NNNN,to eat,at spise,manĝi,,,, -OOOO,to drink,at drikke,drinki,,,, \ No newline at end of file diff --git a/tests/test_files/csv/test1_split1.csv b/tests/test_files/csv/test1_split1.csv deleted file mode 100644 index 8b4b711..0000000 --- a/tests/test_files/csv/test1_split1.csv +++ /dev/null @@ -1,7 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -AAAA,you,du,vi,[sound:pronunciation_da_du.mp3],,,funny -BBBB,healthy,rask,,,,test,tag2 tag3 -CCCC,tired,træt,,,,,besttag -DDDD,banana,en banan,banano,[sound:pronunciation_da_banan.mp3],,, -EEEE,cat,en kat,,,,, -FFFF,dog,en hund,hundo,,,, \ No newline at end of file diff --git a/tests/test_files/csv/test1_split2.csv b/tests/test_files/csv/test1_split2.csv deleted file mode 100644 index 176857d..0000000 --- a/tests/test_files/csv/test1_split2.csv +++ /dev/null @@ -1,10 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -GGGG,fish,en fisk,,,,, -HHHH,bird,en fugl,birdo,,,, -IIII,cow,en ko,,,,, -JJJJ,pig,et svin,,,,, -KKKK,mouse,en mus,,,,, -LLLL,horse,en hest,,,,, -MMMM,to learn,at lære,lerni,[sound:pronunciation_da_lære.mp3],,, -NNNN,to eat,at spise,manĝi,,,, -OOOO,to drink,at drikke,drinki,,,, \ No newline at end of file diff --git a/tests/test_files/csv/test2.csv b/tests/test_files/csv/test2.csv deleted file mode 100644 index ffbd0ed..0000000 --- a/tests/test_files/csv/test2.csv +++ /dev/null @@ -1,10 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -DDDD,banana,en banan,banano,[sound:pronunciation_da_banan.mp3],,,LL::Noun -EEEE,cat,en kat,,,,,Animal LL::Noun -FFFF,dog,en hund,hundo,,,,Animal LL::Noun -GGGG,fish,en fisk,,,,,Animal LL::Noun -HHHH,bird,en fugl,birdo,,,,Animal LL::Noun -IIII,cow,en ko,,,,,Animal LL::Noun -JJJJ,pig,et svin,,,,,Animal LL::Noun -KKKK,mouse,en mus,,,,,Animal LL::Noun -LLLL,horse,en hest,,,,,Animal LL::Noun \ No newline at end of file diff --git a/tests/test_files/csv/test2_missing_guids.csv b/tests/test_files/csv/test2_missing_guids.csv deleted file mode 100644 index 894bb66..0000000 --- a/tests/test_files/csv/test2_missing_guids.csv +++ /dev/null @@ -1,10 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -,banana,en banan,banano,[sound:pronunciation_da_banan.mp3],,,LL::Noun -,cat,en kat,,,,,Animal LL::Noun -,dog,en hund,hundo,,,,Animal LL::Noun -,fish,en fisk,,,,,Animal LL::Noun -,bird,en fugl,birdo,,,,Animal LL::Noun -,cow,en ko,,,,,Animal LL::Noun -,pig,et svin,,,,,Animal LL::Noun -,mouse,en mus,,,,,Animal LL::Noun -,horse,en hest,,,,,Animal LL::Noun \ No newline at end of file diff --git a/tests/test_files/csv/test3.csv b/tests/test_files/csv/test3.csv deleted file mode 100644 index ace7b59..0000000 --- a/tests/test_files/csv/test3.csv +++ /dev/null @@ -1,2 +0,0 @@ -guid,English,Danish,Esperanto,Danish Audio,Esperanto Audio,Japanese,Tags -1111,New,Ny,nova,,,atarashi, \ No newline at end of file diff --git a/tests/test_files/deck_parts/note_models/LL Word No Defaults.json b/tests/test_files/deck_parts/note_models/LL Word No Defaults.json deleted file mode 100644 index bc20584..0000000 --- a/tests/test_files/deck_parts/note_models/LL Word No Defaults.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "__type__": "NoteModelTEST", - "crowdanki_uuid": "057a8d66-bc4e-11e9-9822-d8cb8ac9abf0", - "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color: #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background: linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n}\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}", - "flds": [ - { - "font": "Liberation SansTEST", - "media": ["TEST"], - "name": "Word", - "ord": 0, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "X Word", - "ord": 1, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "Y Word", - "ord": 2, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "Picture", - "ord": 3, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "Extra", - "ord": 4, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "X Pronunciation (Recording and/or IPA)", - "ord": 5, - "rtl": true, - "size": 10, - "sticky": true - }, - { - "font": "Arial", - "media": ["TEST"], - "name": "Y Pronunciation (Recording and/or IPA)", - "ord": 6, - "rtl": true, - "size": 10, - "sticky": true - } - ], - "latexPost": "\\end{document}TEST", - "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\nTEST", - "name": "LL Word", - "req": [], - "sortf": 1, - "tags": ["TEST"], - "tmpls": [ - { - "afmt": "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "X Comprehension", - "ord": 0, - "qfmt": "{{#X Word}}\n\t{{text:X Word}}\n{{/X Word}}" - }, - { - "afmt": "{{#Y Word}}\n\t{{Y Word}}\n{{/Y Word}}\n\n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "Y Comprehension", - "ord": 1, - "qfmt": "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{X Word}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "X Production", - "ord": 2, - "qfmt": "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{Y Word}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "Y Production", - "ord": 3, - "qfmt": "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "X Spelling", - "ord": 4, - "qfmt": "{{#X Word}}\n\t
Spell this word:
\n\n\t
{{type:X Word}}
\n\n\t
{{Picture}}\n{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 2, - "name": "Y Spelling", - "ord": 5, - "qfmt": "{{#Y Word}}\n\t
Spell this word:
\n\n\t
{{type:Y Word}}
\n\n\t
{{Picture}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n
{{text:X Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "TEST", - "bqfmt": "TEST", - "did": 1, - "name": "X and Y Production", - "ord": 6, - "qfmt": "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}" - } - ], - "type": 1, - "vers": ["TEST"] -} \ No newline at end of file diff --git a/tests/test_files/deck_parts/note_models/LL Word Only Required.json b/tests/test_files/deck_parts/note_models/LL Word Only Required.json deleted file mode 100644 index e9becab..0000000 --- a/tests/test_files/deck_parts/note_models/LL Word Only Required.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "crowdanki_uuid": "057a8d66-bc4e-11e9-9822-d8cb8ac9abf0", - "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color: #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background: linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n}\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}", - "flds": [ - { - "name": "Word", - "ord": 0, - "size": 12 - }, - { - "font": "Arial", - "name": "X Word", - "ord": 1 - }, - { - "font": "Arial", - "name": "Y Word", - "ord": 2 - }, - { - "font": "Arial", - "name": "Picture", - "ord": 3, - "size": 6 - }, - { - "font": "Arial", - "name": "Extra", - "ord": 4 - }, - { - "font": "Arial", - "name": "X Pronunciation (Recording and/or IPA)", - "ord": 5 - }, - { - "font": "Arial", - "name": "Y Pronunciation (Recording and/or IPA)", - "ord": 6 - } - ], - "name": "LL Word", - "tmpls": [ - { - "afmt": "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "X Comprehension", - "ord": 0, - "qfmt": "{{#X Word}}\n\t{{text:X Word}}\n{{/X Word}}" - }, - { - "afmt": "{{#Y Word}}\n\t{{Y Word}}\n{{/Y Word}}\n\n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "Y Comprehension", - "ord": 1, - "qfmt": "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{X Word}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "X Production", - "ord": 2, - "qfmt": "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{Y Word}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "Y Production", - "ord": 3, - "qfmt": "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "X Spelling", - "ord": 4, - "qfmt": "{{#X Word}}\n\t
Spell this word:
\n\n\t
{{type:X Word}}
\n\n\t
{{Picture}}\n{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "Y Spelling", - "ord": 5, - "qfmt": "{{#Y Word}}\n\t
Spell this word:
\n\n\t
{{type:Y Word}}
\n\n\t
{{Picture}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n
{{text:X Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "name": "X and Y Production", - "ord": 6, - "qfmt": "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}" - } - ] -} \ No newline at end of file diff --git a/tests/test_files/deck_parts/note_models/LL Word.json b/tests/test_files/deck_parts/note_models/LL Word.json deleted file mode 100644 index cc48cfa..0000000 --- a/tests/test_files/deck_parts/note_models/LL Word.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "__type__": "NoteModel", - "crowdanki_uuid": "057a8d66-bc4e-11e9-9822-d8cb8ac9abf0", - "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color: #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background: linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n}\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}", - "flds": [ - { - "font": "Liberation Sans", - "media": [], - "name": "Word", - "ord": 0, - "rtl": false, - "size": 12, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "X Word", - "ord": 1, - "rtl": false, - "size": 20, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "Y Word", - "ord": 2, - "rtl": false, - "size": 20, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "Picture", - "ord": 3, - "rtl": false, - "size": 6, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "Extra", - "ord": 4, - "rtl": false, - "size": 20, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "X Pronunciation (Recording and/or IPA)", - "ord": 5, - "rtl": false, - "size": 20, - "sticky": false - }, - { - "font": "Arial", - "media": [], - "name": "Y Pronunciation (Recording and/or IPA)", - "ord": 6, - "rtl": false, - "size": 20, - "sticky": false - } - ], - "latexPost": "\\end{document}", - "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", - "latexsvg": false, - "name": "LL Word", - "req": [], - "sortf": 0, - "tags": [], - "tmpls": [ - { - "afmt": "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "X Comprehension", - "ord": 0, - "qfmt": "{{#X Word}}\n\t{{text:X Word}}\n{{/X Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{#Y Word}}\n\t{{Y Word}}\n{{/Y Word}}\n\n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "Y Comprehension", - "ord": 1, - "qfmt": "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{X Word}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "X Production", - "ord": 2, - "qfmt": "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{Y Word}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "Y Production", - "ord": 3, - "qfmt": "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "X Spelling", - "ord": 4, - "qfmt": "{{#X Word}}\n\t
Spell this word:
\n\n\t
{{type:X Word}}
\n\n\t
{{Picture}}\n{{/X Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "Y Spelling", - "ord": 5, - "qfmt": "{{#Y Word}}\n\t
Spell this word:
\n\n\t
{{type:Y Word}}
\n\n\t
{{Picture}}\n{{/Y Word}}", - "scratchPad": 0 - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n
{{text:X Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bfont": "", - "bqfmt": "", - "bsize": 0, - "did": null, - "name": "X and Y Production", - "ord": 6, - "qfmt": "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}", - "scratchPad": 0 - } - ], - "type": 0, - "vers": [] -} \ No newline at end of file diff --git a/tests/test_files/deck_parts/note_models/Test-Model.json b/tests/test_files/deck_parts/note_models/Test-Model.json deleted file mode 100644 index 1341727..0000000 --- a/tests/test_files/deck_parts/note_models/Test-Model.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "__type__": "NoteModel", - "crowdanki_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color: #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background: linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n}\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}", - "flds": [ - { - "name": "Word" - }, - { - "name": "OtherWord" - } - ], - "latexPost": "\\end{document}", - "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", - "name": "Test Model", - "req": [ - [ - 0, - "all", - [ - 1 - ] - ], - [ - 1, - "all", - [ - 2 - ] - ], - [ - 2, - "all", - [ - 1, - 3 - ] - ], - [ - 3, - "all", - [ - 2, - 3 - ] - ], - [ - 4, - "all", - [ - 1, - 3 - ] - ], - [ - 5, - "all", - [ - 2, - 3 - ] - ], - [ - 6, - "all", - [ - 1, - 2, - 3 - ] - ] - ], - "sortf": 0, - "tags": [ - "Meta::InProgress" - ], - "tmpls": [ - { - "afmt": "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Comprehension", - "ord": 0, - "qfmt": "{{#X Word}}\n\t{{text:X Word}}\n{{/X Word}}" - }, - { - "afmt": "{{#Y Word}}\n\t {{Y Word}}\n{{/Y Word}}\n\n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Comprehension", - "ord": 1, - "qfmt": "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n{{X Word}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Production", - "ord": 2, - "qfmt": "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n {{Y Word}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Production", - "ord": 3, - "qfmt": "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X Spelling", - "ord": 4, - "qfmt": "{{#X Word}}\n\t
Spell this word:
\n\n\t{{type:X Word}}\n\n\t
{{Picture}}\n{{/X Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "Y Spelling", - "ord": 5, - "qfmt": "{{#Y Word}}\n\t
Spell this word:
\n\n\t{{type:Y Word}}\n\n\t
{{Picture}}\n{{/Y Word}}" - }, - { - "afmt": "{{FrontSide}}\n\n
\n\n
{{text:X Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}", - "bafmt": "", - "bqfmt": "", - "did": null, - "name": "X and Y Production", - "ord": 6, - "qfmt": "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}\n" - } - ], - "type": 0, - "vers": [] -} \ No newline at end of file diff --git a/tests/test_files/deck_parts/yaml/note_models/LL-Word-No-Defaults.yaml b/tests/test_files/deck_parts/yaml/note_models/LL-Word-No-Defaults.yaml deleted file mode 100644 index f4dcb16..0000000 --- a/tests/test_files/deck_parts/yaml/note_models/LL-Word-No-Defaults.yaml +++ /dev/null @@ -1,144 +0,0 @@ -name: LL Word -id: 057a8d66-bc4e-11e9-9822-d8cb8ac9abf0 -css: ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color:\ - \ black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color:\ - \ #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background:\ - \ linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n\ - }\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}" -sort_field_num: 1 -is_cloze: true -latex_pre: "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\ - \\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\nTEST" -latex_post: \end{document}TEST -fields: -- name: Word - font: Liberation SansTEST - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: X Word - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: Y Word - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: Picture - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: Extra - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: X Pronunciation (Recording and/or IPA) - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -- name: Y Pronunciation (Recording and/or IPA) - font: Arial - media: - - TEST - is_right_to_left: true - font_size: 10 - is_sticky: true -templates: -- name: X Comprehension - question_format: "{{#X Word}}\n\t{{text:X Word}}\n{{/X\ - \ Word}}" - answer_format: "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\ - \n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\ - \t
{{X Pronunciation (Recording and/or IPA)}}\n\ - {{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -- name: Y Comprehension - question_format: "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y\ - \ Word}}" - answer_format: "{{#Y Word}}\n\t{{Y Word}}\n{{/Y Word}}\n\ - \n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\ - \t
{{Y Pronunciation (Recording and/or IPA)}}\n\ - {{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -- name: X Production - question_format: "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n{{X Word}}\n\ - \n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -- name: Y Production - question_format: "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n{{Y Word}}\n\ - \n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -- name: X Spelling - question_format: "{{#X Word}}\n\t
Spell this word:
\n\n\t
{{type:X Word}}
\n\n\t
{{Picture}}\n{{/X Word}}" - answer_format: "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t\ -
{{X Pronunciation (Recording and/or IPA)}}\n\ - {{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -- name: Y Spelling - question_format: "{{#Y Word}}\n\t
Spell this word:
\n\n\t
{{type:Y Word}}
\n\n\t
{{Picture}}\n{{/Y Word}}" - answer_format: "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t\ -
{{Y Pronunciation (Recording and/or IPA)}}\n\ - {{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 2 -- name: X and Y Production - question_format: "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n
{{text:X\ - \ Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation\ - \ (Recording and/or IPA)}}\n\t
{{X Pronunciation\ - \ (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\ - \n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" - browser_question_format: TEST - browser_answer_format: TEST - deck_override_id: 1 -tags: -- TEST -version: -- TEST -__type__: NoteModelTEST -required_fields_per_template: [] \ No newline at end of file diff --git a/tests/test_files/deck_parts/yaml/note_models/LL-Word-Only-Required.yaml b/tests/test_files/deck_parts/yaml/note_models/LL-Word-Only-Required.yaml deleted file mode 100644 index 4128c83..0000000 --- a/tests/test_files/deck_parts/yaml/note_models/LL-Word-Only-Required.yaml +++ /dev/null @@ -1,79 +0,0 @@ -name: LL Word -id: 057a8d66-bc4e-11e9-9822-d8cb8ac9abf0 -css: ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color:\ - \ black;\n background-color: white;\n}\n\n.card1,.card3, .card5 { background-color:\ - \ #B60F2D; }\n.card2,.card4, .card6 { background-color: #2E9017; }\n.card7 { background:\ - \ linear-gradient(90deg, #B60F2D 49.9%, #2E9017 50.1%); }\n\n.word {\n font-size:1.5em;\n\ - }\n\n.pronunciation{\n color:blue;\n}\n\n.extrainfo{\n color:lightgrey;\n}" -fields: -- name: Word - font_size: 12 -- name: X Word - font: Arial -- name: Y Word - font: Arial -- name: Picture - font: Arial - font_size: 6 -- name: Extra - font: Arial -- name: X Pronunciation (Recording and/or IPA) - font: Arial -- name: Y Pronunciation (Recording and/or IPA) - font: Arial -templates: -- name: X Comprehension - question_format: "{{#X Word}}\n\t{{text:X Word}}\n{{/X\ - \ Word}}" - answer_format: "{{#X Word}}\n\t{{X Word}}\n{{/X Word}}\n\ - \n
\n\n{{Picture}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\ - \t
{{X Pronunciation (Recording and/or IPA)}}\n\ - {{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" -- name: Y Comprehension - question_format: "{{#Y Word}}\n\t{{text:Y Word}}\n{{/Y\ - \ Word}}" - answer_format: "{{#Y Word}}\n\t{{Y Word}}\n{{/Y Word}}\n\ - \n
\n\n{{Picture}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\ - \t
{{Y Pronunciation (Recording and/or IPA)}}\n\ - {{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" -- name: X Production - question_format: "{{#X Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/X Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n{{X Word}}\n\ - \n{{#X Pronunciation (Recording and/or IPA)}}\n\t
{{X Pronunciation (Recording and/or IPA)}}\n{{/X Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" -- name: Y Production - question_format: "{{#Y Word}}{{#Picture}}\n\t{{Picture}}\n{{/Picture}}{{/Y Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n{{Y Word}}\n\ - \n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" -- name: X Spelling - question_format: "{{#X Word}}\n\t
Spell this word:
\n\n\t
{{type:X Word}}
\n\n\t
{{Picture}}\n{{/X Word}}" - answer_format: "{{FrontSide}}\n\n{{#X Pronunciation (Recording and/or IPA)}}\n\t\ -
{{X Pronunciation (Recording and/or IPA)}}\n\ - {{/X Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" -- name: Y Spelling - question_format: "{{#Y Word}}\n\t
Spell this word:
\n\n\t
{{type:Y Word}}
\n\n\t
{{Picture}}\n{{/Y Word}}" - answer_format: "{{FrontSide}}\n\n{{#Y Pronunciation (Recording and/or IPA)}}\n\t\ -
{{Y Pronunciation (Recording and/or IPA)}}\n\ - {{/Y Pronunciation (Recording and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n{{/Extra}}" -- name: X and Y Production - question_format: "{{#X Word}}\n{{#Y Word}}\n\t{{Picture}}\n{{/Y Word}}\n{{/X Word}}" - answer_format: "{{FrontSide}}\n\n
\n\n
{{text:X\ - \ Word}}
\n
{{text:Y Word}}
\n\n{{#X Pronunciation\ - \ (Recording and/or IPA)}}\n\t
{{X Pronunciation\ - \ (Recording and/or IPA)}}\n{{/X Pronunciation (Recording and/or IPA)}}\n\ - \n{{#Y Pronunciation (Recording and/or IPA)}}\n\t
{{Y Pronunciation (Recording and/or IPA)}}\n{{/Y Pronunciation (Recording\ - \ and/or IPA)}}\n\n
\n{{#Extra}}\n\t
{{Extra}}\n\ - {{/Extra}}" -required_fields_per_template: [] \ No newline at end of file diff --git a/tests/test_files/deck_parts/yaml/notes/note1.yaml b/tests/test_files/deck_parts/yaml/notes/note1.yaml deleted file mode 100644 index a79da3d..0000000 --- a/tests/test_files/deck_parts/yaml/notes/note1.yaml +++ /dev/null @@ -1,31 +0,0 @@ -note_groupings: - - note_model: LL Noun - tags: - - noun - - english - notes: - - guid: 7ysf7ysd8f8 - fields: - - test - - blah - - another one - - guid: sfkdsfhsd - fields: - - first - - second - - third - - note_model: LL Verb - tags: - - verb - - english - notes: - - guid: dhdfhsdf - fields: - - verby - - boo - - another one - - guid: dfgdfhgjs - fields: - - first - - second - - third diff --git a/tests/test_files/media_files/buried/even_more/signals2.png b/tests/test_files/media_files/buried/even_more/signals2.png deleted file mode 100644 index 4c55c9d..0000000 Binary files a/tests/test_files/media_files/buried/even_more/signals2.png and /dev/null differ diff --git a/tests/test_files/media_files/signals.png b/tests/test_files/media_files/signals.png deleted file mode 100644 index 4c55c9d..0000000 Binary files a/tests/test_files/media_files/signals.png and /dev/null differ diff --git a/tests/test_files/tsv/test1.tsv b/tests/test_files/tsv/test1.tsv deleted file mode 100644 index b4ca91b..0000000 --- a/tests/test_files/tsv/test1.tsv +++ /dev/null @@ -1,16 +0,0 @@ -guid English Danish Esperanto Danish Audio Esperanto Audio Japanese Tags -AAAA you du vi [sound:pronunciation_da_du.mp3] funny -BBBB healthy rask test tag2 tag3 -CCCC tired træt besttag -DDDD banana en banan banano [sound:pronunciation_da_banan.mp3] -EEEE cat en kat -FFFF dog en hund hundo -GGGG fish en fisk -HHHH bird en fugl birdo -IIII cow en ko -JJJJ pig et svin -KKKK mouse en mus -LLLL horse en hest -MMMM to learn at lære lerni [sound:pronunciation_da_lære.mp3] -NNNN to eat at spise manĝi -OOOO to drink at drikke drinki diff --git a/tests/test_helpers.py b/tests/test_helpers.py deleted file mode 100644 index 863e869..0000000 --- a/tests/test_helpers.py +++ /dev/null @@ -1,7 +0,0 @@ -from brain_brew.configuration.part_holder import PartHolder - - -def debug_write_part_to_file(part, filepath: str): - dp = PartHolder("Blah", filepath, part) - dp.save_to_file = filepath - dp.write_to_file() diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index a64ce90..0000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest - -from brain_brew.representation.yaml.note_model_template import html_separator_regex -from brain_brew.utils import find_media_in_field, str_to_lowercase_no_separators, split_tags, split_by_regex - - -class TestFindMedia: - @pytest.mark.parametrize("field_value, expected_results", [ - (r'', ["image.png"]), - (r'', ["image.png"]), - (r'< img src="image.png">', ["image.png"]), - (r'< img src="image.png">', ["image.png"]), - (r'', ["image.png"]), - (r'', ["image.png"]), - (r'', ["image.png"]), - (r'words in the field end other stuff', ["image.png"]), - (r'', ["ug-map-saint_barthelemy.png"]), - (r'', - ["ug-map-saint_barthelemy.png", "image.png"]), - (r'[sound:test.mp3]', ["test.mp3"]), - (r'[sound:test.mp3][sound:othersound.mp3]', ["test.mp3", "othersound.mp3"]), - (r'[sound:test.mp3] [sound:othersound.mp3]', ["test.mp3", "othersound.mp3"]), - (r'words in the field [sound:test.mp3] other stuff too [sound:othersound.mp3] end', ["test.mp3", "othersound.mp3"]), - (r'[sound:unfinished-bracket.mp3', []) - ]) - def test_find_media_in_field(self, field_value, expected_results): - assert find_media_in_field(field_value) == expected_results - - -class TestHelperFunctions: - @pytest.mark.parametrize("str_to_tidy", [ - 'Generate Csv Blah Blah', - 'Generate__Csv_Blah-Blah', - 'Generate Csv Blah Blah', - 'generateCsvBlahBlah' - ]) - def test_remove_spacers_from_str(self, str_to_tidy): - assert str_to_lowercase_no_separators(str_to_tidy) == "generatecsvblahblah" - - -class TestSplitTags: - @pytest.mark.parametrize("str_to_split, expected_result", [ - ("tags1, tags2", ["tags1", "tags2"]), - ("tags1 tags2", ["tags1", "tags2"]), - ("tags1; tags2", ["tags1", "tags2"]), - ("tags1 tags2", ["tags1", "tags2"]), - ("tags1, tags2, tags3, tags4, tags5, tags6, tags7, tags8, tags9", - ["tags1", "tags2", "tags3", "tags4", "tags5", "tags6", "tags7", "tags8", "tags9"]), - ("tags1, tags2; tags3 tags4 tags5, tags6; tags7 tags8, tags9", - ["tags1", "tags2", "tags3", "tags4", "tags5", "tags6", "tags7", "tags8", "tags9"]), - ("tags1,tags2", ["tags1", "tags2"]), - ("tags1;tags2", ["tags1", "tags2"]), - ("tags1, tags2", ["tags1", "tags2"]), - ("tags1; tags2", ["tags1", "tags2"]), - ]) - def test_runs(self, str_to_split, expected_result): - assert split_tags(str_to_split) == expected_result - - -class TestSplitByRegex: - @pytest.mark.parametrize("str_to_split, split_by, expected_result", [ - ("testbabyhighfive", "baby", ["test", "highfive"]), - ("testbabyhighfive", "(baby)", ["test", "baby", "highfive"]), - ("testbabyhighfive", html_separator_regex, ["testbabyhighfive"]), - ("test\n---\nhighfive", html_separator_regex, ["test", "highfive"]), - ("test\n---\n\nhighfive", html_separator_regex, ["test", "highfive"]), - ("test\n-\nhighfive", html_separator_regex, ["test", "highfive"]), - ("test\n\n\n\n-\nhighfive", html_separator_regex, ["test", "highfive"]), - ("test\n\n\n\n---\n\n\n\nhighfive", html_separator_regex, ["test", "highfive"]), - ("test\n\n\n\n---\n\n\n\nhighfive\n\n--\n\nbackflip", html_separator_regex, ["test", "highfive", "backflip"]), - ]) - def test_runs(self, str_to_split, split_by, expected_result): - assert split_by_regex(str_to_split, split_by) == expected_result - - -# class TestJoinTags: -# @pytest.mark.parametrize("join_with, expected_result", [ -# (", ", "test, test1, test2") -# ]) -# def test_joins(self, global_config, join_with, expected_result): -# list_to_join = ["test", "test1", "test2"] -# global_config.flags.join_values_with = join_with -# -# assert join_tags(list_to_join) == expected_result