From 73104e16df24334ce3ba97145d043ab51c23b39c Mon Sep 17 00:00:00 2001 From: Jordan Munch O'Hare Date: Mon, 25 May 2026 09:50:23 +0200 Subject: [PATCH 1/8] chore: remove legacy Python implementation --- .github/workflows/integrity-check.yml | 32 - CONTRIBUTING.md | 46 -- MANIFEST.in | 1 - Pipfile | 24 - Pipfile.lock | 645 ------------------ TODO.md | 9 - brain_brew/__init__.py | 0 brain_brew/build_tasks/__init__.py | 0 brain_brew/build_tasks/crowd_anki/__init__.py | 0 .../crowd_anki/crowd_anki_generate.py | 81 --- .../crowd_anki/headers_from_crowdanki.py | 69 -- .../crowd_anki/headers_to_crowd_anki.py | 42 -- .../crowd_anki/media_group_from_crowd_anki.py | 39 -- .../crowd_anki/media_group_to_crowd_anki.py | 40 -- .../note_model_single_from_crowd_anki.py | 62 -- .../note_models_all_from_crowd_anki.py | 52 -- .../crowd_anki/note_models_to_crowd_anki.py | 91 --- .../crowd_anki/notes_from_crowd_anki.py | 81 --- .../crowd_anki/notes_to_crowd_anki.py | 96 --- .../crowd_anki/shared_base_notes.py | 20 - brain_brew/build_tasks/csvs/__init__.py | 0 brain_brew/build_tasks/csvs/csvs_generate.py | 101 --- .../csvs/generate_guids_in_csvs.py | 88 --- .../build_tasks/csvs/notes_from_csvs.py | 92 --- .../build_tasks/csvs/shared_base_csvs.py | 63 -- brain_brew/build_tasks/deck_parts/__init__.py | 0 .../build_tasks/deck_parts/from_yaml_part.py | 61 -- .../deck_parts/headers_from_yaml_part.py | 67 -- .../deck_parts/media_group_from_folder.py | 58 -- .../deck_parts/note_model_from_html_parts.py | 78 --- .../deck_parts/note_model_from_yaml_part.py | 45 -- .../deck_parts/save_media_group_to_folder.py | 55 -- .../deck_parts/save_note_models_to_folder.py | 57 -- brain_brew/build_tasks/overrides/__init__.py | 0 .../build_tasks/overrides/headers_override.py | 55 -- .../build_tasks/overrides/notes_override.py | 40 -- brain_brew/commands/__init__.py | 0 brain_brew/commands/argument_reader.py | 117 ---- brain_brew/commands/init_repo/__init__.py | 0 brain_brew/commands/init_repo/init_repo.py | 208 ------ brain_brew/commands/run_recipe/__init__.py | 0 brain_brew/commands/run_recipe/build_task.py | 45 -- .../commands/run_recipe/parts_builder.py | 64 -- .../commands/run_recipe/recipe_builder.py | 82 --- brain_brew/commands/run_recipe/run_recipe.py | 18 - .../commands/run_recipe/top_level_builder.py | 87 --- brain_brew/configuration/__init__.py | 0 brain_brew/configuration/anki_field.py | 16 - brain_brew/configuration/file_manager.py | 58 -- brain_brew/configuration/part_holder.py | 45 -- .../configuration/representation_base.py | 28 - brain_brew/configuration/yaml_verifier.py | 40 -- brain_brew/front_matter.py | 2 - brain_brew/interfaces/__init__.py | 0 brain_brew/interfaces/command.py | 7 - brain_brew/interfaces/media_container.py | 8 - brain_brew/interfaces/yamale_verifyable.py | 22 - brain_brew/main.py | 23 - brain_brew/representation/__init__.py | 0 brain_brew/representation/generic/__init__.py | 0 brain_brew/representation/generic/csv_file.py | 116 ---- .../representation/generic/html_file.py | 38 -- .../representation/generic/media_file.py | 31 - .../representation/generic/source_file.py | 41 -- brain_brew/representation/json/__init__.py | 0 .../representation/json/crowd_anki_export.py | 63 -- brain_brew/representation/json/json_file.py | 25 - .../json/wrappers_for_crowd_anki.py | 117 ---- brain_brew/representation/yaml/__init__.py | 0 brain_brew/representation/yaml/headers.py | 44 -- brain_brew/representation/yaml/media_group.py | 57 -- brain_brew/representation/yaml/note_model.py | 270 -------- .../representation/yaml/note_model_field.py | 86 --- .../yaml/note_model_template.py | 196 ------ brain_brew/representation/yaml/notes.py | 184 ----- brain_brew/representation/yaml/yaml_object.py | 55 -- brain_brew/schemas/__init__.py | 0 brain_brew/schemas/recipe.yaml | 173 ----- brain_brew/transformers/__init__.py | 0 .../create_media_group_from_location.py | 24 - brain_brew/transformers/file_mapping.py | 194 ------ brain_brew/transformers/note_model_mapping.py | 178 ----- .../save_media_group_to_location.py | 36 - .../save_note_model_to_location.py | 46 -- brain_brew/utils.py | 129 ---- scripts/__init__.py | 0 scripts/build.bash | 4 - scripts/dist.bash | 9 - scripts/yamale_build.py | 13 - setup.py | 36 - tests/__init__.py | 0 tests/build_tasks/__init__.py | 0 .../test_source_crowd_anki_json.py | 113 --- tests/build_tasks/test_source_csv.py | 140 ---- tests/representation/__init__.py | 0 .../representation/configuration/__init__.py | 0 .../configuration/test_csv_file_mapping.py | 160 ----- .../configuration/test_note_model_mapping.py | 146 ---- tests/representation/generic/__init__.py | 0 tests/representation/generic/test_csv_file.py | 117 ---- .../representation/generic/test_media_file.py | 37 - tests/representation/json/__init__.py | 0 .../json/test_crowd_anki_export.py | 49 -- tests/representation/yaml/__init__.py | 0 .../yaml/test_note_model_repr.py | 128 ---- tests/representation/yaml/test_note_repr.py | 392 ----------- tests/test_argument_reader.py | 51 -- tests/test_builder.py | 12 - tests/test_file_manager.py | 32 - tests/test_files.py | 72 -- tests/test_files/build_files/builder1.yaml | 42 -- .../crowd_anki/crowdanki_example_1/deck.json | 398 ----------- tests/test_files/csv/test1.csv | 16 - tests/test_files/csv/test1_split1.csv | 7 - tests/test_files/csv/test1_split2.csv | 10 - tests/test_files/csv/test2.csv | 10 - tests/test_files/csv/test2_missing_guids.csv | 10 - tests/test_files/csv/test3.csv | 2 - .../note_models/LL Word No Defaults.json | 143 ---- .../note_models/LL Word Only Required.json | 87 --- .../deck_parts/note_models/LL Word.json | 165 ----- .../deck_parts/note_models/Test-Model.json | 144 ---- .../yaml/note_models/LL-Word-No-Defaults.yaml | 144 ---- .../note_models/LL-Word-Only-Required.yaml | 79 --- .../deck_parts/yaml/notes/note1.yaml | 31 - .../media_files/buried/even_more/signals2.png | Bin 15118 -> 0 bytes tests/test_files/media_files/signals.png | Bin 15118 -> 0 bytes tests/test_files/tsv/test1.tsv | 16 - tests/test_helpers.py | 7 - tests/test_utils.py | 84 --- 130 files changed, 8169 deletions(-) delete mode 100644 .github/workflows/integrity-check.yml delete mode 100644 CONTRIBUTING.md delete mode 100644 MANIFEST.in delete mode 100644 Pipfile delete mode 100644 Pipfile.lock delete mode 100644 TODO.md delete mode 100644 brain_brew/__init__.py delete mode 100644 brain_brew/build_tasks/__init__.py delete mode 100644 brain_brew/build_tasks/crowd_anki/__init__.py delete mode 100644 brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py delete mode 100644 brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py delete mode 100644 brain_brew/build_tasks/crowd_anki/shared_base_notes.py delete mode 100644 brain_brew/build_tasks/csvs/__init__.py delete mode 100644 brain_brew/build_tasks/csvs/csvs_generate.py delete mode 100644 brain_brew/build_tasks/csvs/generate_guids_in_csvs.py delete mode 100644 brain_brew/build_tasks/csvs/notes_from_csvs.py delete mode 100644 brain_brew/build_tasks/csvs/shared_base_csvs.py delete mode 100644 brain_brew/build_tasks/deck_parts/__init__.py delete mode 100644 brain_brew/build_tasks/deck_parts/from_yaml_part.py delete mode 100644 brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py delete mode 100644 brain_brew/build_tasks/deck_parts/media_group_from_folder.py delete mode 100644 brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py delete mode 100644 brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py delete mode 100644 brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py delete mode 100644 brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py delete mode 100644 brain_brew/build_tasks/overrides/__init__.py delete mode 100644 brain_brew/build_tasks/overrides/headers_override.py delete mode 100644 brain_brew/build_tasks/overrides/notes_override.py delete mode 100644 brain_brew/commands/__init__.py delete mode 100644 brain_brew/commands/argument_reader.py delete mode 100644 brain_brew/commands/init_repo/__init__.py delete mode 100644 brain_brew/commands/init_repo/init_repo.py delete mode 100644 brain_brew/commands/run_recipe/__init__.py delete mode 100644 brain_brew/commands/run_recipe/build_task.py delete mode 100644 brain_brew/commands/run_recipe/parts_builder.py delete mode 100644 brain_brew/commands/run_recipe/recipe_builder.py delete mode 100644 brain_brew/commands/run_recipe/run_recipe.py delete mode 100644 brain_brew/commands/run_recipe/top_level_builder.py delete mode 100644 brain_brew/configuration/__init__.py delete mode 100644 brain_brew/configuration/anki_field.py delete mode 100644 brain_brew/configuration/file_manager.py delete mode 100644 brain_brew/configuration/part_holder.py delete mode 100644 brain_brew/configuration/representation_base.py delete mode 100644 brain_brew/configuration/yaml_verifier.py delete mode 100644 brain_brew/front_matter.py delete mode 100644 brain_brew/interfaces/__init__.py delete mode 100644 brain_brew/interfaces/command.py delete mode 100644 brain_brew/interfaces/media_container.py delete mode 100644 brain_brew/interfaces/yamale_verifyable.py delete mode 100644 brain_brew/main.py delete mode 100644 brain_brew/representation/__init__.py delete mode 100644 brain_brew/representation/generic/__init__.py delete mode 100644 brain_brew/representation/generic/csv_file.py delete mode 100644 brain_brew/representation/generic/html_file.py delete mode 100644 brain_brew/representation/generic/media_file.py delete mode 100644 brain_brew/representation/generic/source_file.py delete mode 100644 brain_brew/representation/json/__init__.py delete mode 100644 brain_brew/representation/json/crowd_anki_export.py delete mode 100644 brain_brew/representation/json/json_file.py delete mode 100644 brain_brew/representation/json/wrappers_for_crowd_anki.py delete mode 100644 brain_brew/representation/yaml/__init__.py delete mode 100644 brain_brew/representation/yaml/headers.py delete mode 100644 brain_brew/representation/yaml/media_group.py delete mode 100644 brain_brew/representation/yaml/note_model.py delete mode 100644 brain_brew/representation/yaml/note_model_field.py delete mode 100644 brain_brew/representation/yaml/note_model_template.py delete mode 100644 brain_brew/representation/yaml/notes.py delete mode 100644 brain_brew/representation/yaml/yaml_object.py delete mode 100644 brain_brew/schemas/__init__.py delete mode 100644 brain_brew/schemas/recipe.yaml delete mode 100644 brain_brew/transformers/__init__.py delete mode 100644 brain_brew/transformers/create_media_group_from_location.py delete mode 100644 brain_brew/transformers/file_mapping.py delete mode 100644 brain_brew/transformers/note_model_mapping.py delete mode 100644 brain_brew/transformers/save_media_group_to_location.py delete mode 100644 brain_brew/transformers/save_note_model_to_location.py delete mode 100644 brain_brew/utils.py delete mode 100644 scripts/__init__.py delete mode 100755 scripts/build.bash delete mode 100755 scripts/dist.bash delete mode 100755 scripts/yamale_build.py delete mode 100644 setup.py delete mode 100644 tests/__init__.py delete mode 100644 tests/build_tasks/__init__.py delete mode 100644 tests/build_tasks/test_source_crowd_anki_json.py delete mode 100644 tests/build_tasks/test_source_csv.py delete mode 100644 tests/representation/__init__.py delete mode 100644 tests/representation/configuration/__init__.py delete mode 100644 tests/representation/configuration/test_csv_file_mapping.py delete mode 100644 tests/representation/configuration/test_note_model_mapping.py delete mode 100644 tests/representation/generic/__init__.py delete mode 100644 tests/representation/generic/test_csv_file.py delete mode 100644 tests/representation/generic/test_media_file.py delete mode 100644 tests/representation/json/__init__.py delete mode 100644 tests/representation/json/test_crowd_anki_export.py delete mode 100644 tests/representation/yaml/__init__.py delete mode 100644 tests/representation/yaml/test_note_model_repr.py delete mode 100644 tests/representation/yaml/test_note_repr.py delete mode 100644 tests/test_argument_reader.py delete mode 100644 tests/test_builder.py delete mode 100644 tests/test_file_manager.py delete mode 100644 tests/test_files.py delete mode 100644 tests/test_files/build_files/builder1.yaml delete mode 100644 tests/test_files/crowd_anki/crowdanki_example_1/deck.json delete mode 100644 tests/test_files/csv/test1.csv delete mode 100644 tests/test_files/csv/test1_split1.csv delete mode 100644 tests/test_files/csv/test1_split2.csv delete mode 100644 tests/test_files/csv/test2.csv delete mode 100644 tests/test_files/csv/test2_missing_guids.csv delete mode 100644 tests/test_files/csv/test3.csv delete mode 100644 tests/test_files/deck_parts/note_models/LL Word No Defaults.json delete mode 100644 tests/test_files/deck_parts/note_models/LL Word Only Required.json delete mode 100644 tests/test_files/deck_parts/note_models/LL Word.json delete mode 100644 tests/test_files/deck_parts/note_models/Test-Model.json delete mode 100644 tests/test_files/deck_parts/yaml/note_models/LL-Word-No-Defaults.yaml delete mode 100644 tests/test_files/deck_parts/yaml/note_models/LL-Word-Only-Required.yaml delete mode 100644 tests/test_files/deck_parts/yaml/notes/note1.yaml delete mode 100644 tests/test_files/media_files/buried/even_more/signals2.png delete mode 100644 tests/test_files/media_files/signals.png delete mode 100644 tests/test_files/tsv/test1.tsv delete mode 100644 tests/test_helpers.py delete mode 100644 tests/test_utils.py 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/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/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/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/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/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/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 4c55c9dbfd78f517efeb63c402b511a9bcaf0e11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15118 zcmcJ$c{r7C`!9Si(~@DKfeb68l8lA0ge94$Q0A#9Lz$O(T9MLVsDxz9Jd-hGXrNNa z5M_?SGG(6G=c?z}d%t_{-yi!u-uI8^@O-=PYq+oL96smg{H%KybyOMYIOq@rVN_SU zppPJw@P8@rv=s2C;&Utt{-6o!+REr3DC$EZ+*85=0`O1!T-8iG5ah@k^8ZlI9iH;= zk5qPw+KLEL8b!Z-g&IL{N$M9AFZukK9o*-0c_nFY;TY9R)OR)7+S`GzE|@4co(PMi zKXpmF?hNi*rlTia@L{arN@-yiA*--e&91!fY9XbMcFuiT`vVN0Fd?@!!_V(O#_}eX zibCD^$sW^Hy_n;#WF`XIHLYyu^;G>J){$D>hFwoJ#lLV7C z>L;SeQyjQC;&koWwao17e9`TohW3dvAKg03;+C;1(>Id}%RDRFzJ0HWHEVBcYy0p) zjC5Uwk_ti0?@}`(h%1&A4-WzjHKizH>)Q1F|r(;Vb zR=Z{)aBKBTf~2M&8_HXDh~IusqE+h0&_grHYp6@{%*)f44CWuw`<&oC`H|#AA#&#G zyYP=s_=DHx%nnU}lW^Ve(}qf=9D^QGvd2bGxQ zx?lTyPaVu!N?!TWARsK9BxePp?Cbg zRw&i|yFJC~;zgb$dZO|qg2GJ?)~>x?8xl-);5Dc=aftR>(yGTk<)J&b2I=Ft{lp#r0e0j)! zzR;vh1;=822G%WD?z4${-`uQ`-IzH*h~0dVmq+?;m+~}{a^wumeD)AGw}MSG!_-0h zvlW-*yiF%JCQ^!Xa-wyp4&TKvBTtq|J=67Db5C?oHi>3$>NiRGm$Ff2uyWsdclX7e zwPDdDit92}IZ5JnI_md+iKe2`J5UtX;eCC5El2#daMT4J&d;`H%GwPtUsi8H4Lraw z^NbDe?F^@lvk7G(>+etZabbB7?U+Z89zA}1G>IbeYxTvp_V)HqpB}eS zOg_XgTi*{456@2a)4)+Dp86%U^1e8DXS${aHznPuVBd|RbGEk!6NLzKVS)89kC3#q zF^+=j&(8zV*FI64EPl7Qd?w|+|KevK7t@!jj?)n}0d8?0y!KM6T6>bk?Pzzv1MODo z{a+6?GqrdvebY^mAI!Tf_@D~oja#$!SYqL?U8&e5?XAvM`b1_|tsy@|N)ISOt=o zljF0yF_9VCxB*7FIcn3NcDkaV{qyH>rQf$a;GY!vR3j5593B(s8VM2HXBu2b->gjc zYTDfr|B9kmX6EGF0yBp_3Q0~C*rfzk=H+SN=y~p7mCVakEO*$utdXjN-FPr_wM;_FSNkNojo&JFub?B zb{B&M%j$=R(qF;V`)j>jrVbLZxokAb%F4kTtyUSK2rD>;(P1&`Mhox3<4F`Hk}Hcm zF!S8NtFW-JP7`Y*0ue!PR0VHURaZ;HXz`plx~ET{w#%(05||s!3B=Sg7CsfNJc##wbJ$Lzh&p^%C5YnnGz~tkHF!14d-dBpYo>!Jnms)7V})s2~~q*WX0h-I>48L z*WYq$WFuU~PW_e7o;`DE*ja^Fk0w#Crs|=?#qk|q98^vb7E!5L%6>Nc{&2Z>O{j6$Cy(d^y{bp=IEgKIFG6B??>K)4cn_bZqR2B#K7%DEUo$OG`^<=OO~L zaR|hZU)c&f0{PsHhTXim7_F&;_@xe1h)uc6@a{eSWVp-`ipP&1!)*p{^$!~w8G)5N z0~2E9#1T%ysNda+d6>&E>IsaZ90=N)HL~!0N}xmHy!)N1j`Q=28kgX4w4W8xv?ZTy zqU4%MXjf*>#=N(NIBHjR0@2!P_D99m_x2#z9KH-f>-Fo`cYA|(*tnC@IN=PwZ?4U^ z!x#xq+I&j5L)R-XM)kj2T59GT#M6O+fx5bLQ#@kih;|M#8b6!?C&|?iT|uB5>4y`r z%+h||d{>Aff&H9M39j5KU_p6`$Nn^2e-k4kqv8C$kBb`Hx}r<>Zq~H2dEdLaw&z=G zK70BX1FsM>7ncwg+~WD`xzcY|JHz#ZN1x9aXjARX>9E3 ztl+A^x8>v3UzPdi+#a3mxCwqLGCuZ1^t zC5_`oxx+mVGjH)d{+58U!rk%%^GdUFZ{PN?k*Kr%zB_b(=|qK;goK2I(X)X8TVq#3 z>^d{lLunWvV?us+vx)g`Se|f``hX2mPd+$m^rPp` z&CJZ6CnTK0QhjOiBobLoYY1dx`PI#QF&&SiHouQyrV=u*E=f&=|3Z9If;Ok_9pFEq zop20GrLBafr8V~QI-AdpOyu0^@R8Zqr~%iPD>dX}GtrjRDS-5~Mq*{VT}KXxa}hDj z%tYU%-=&)UDUiyG z!;AIRcuud!K!nieJAU-&)$Saq_Ub+g7S^_+bCMP$SUD`Dj+;7t$ z{WpM_P62aB+<&x<%-zBWRETp^<1OAvBrOwioco^v;mB`WmbAsSyAy8OzfO-N7vkZpxSLLBea?U%adACa#5HF zfzMS`GGskdj0)DHa2sj7RW%%B2Io^{5SpWO7qf5QzD`B+Pz~MOeVr8f*`*EGE`BMeY31Dk-Ezks z4P3Yvj5!YYRuqduD4N#aWPU}xQ7$wteNj*Vm=2*jA0aCFZoS$k6cPlT z=Oa16EoSw{V;lCZFeQ(UlA^l5b#-+ynwQ7B)6jBAdy+0$S|;C;GB!3||G*a&&$80n zu)7j}+Q!tKzBNvCZ_aA2zp8zD-Il&caRw=LcXtmEy0q^M z7c7p1G7b4oBwZhHH#JRQm-645tx|vkyFRe!R5fU5V&bs=#WHwjMfcpoZT4I9uO^O& z2Zecze|Y40<%-J4R9CjkP~G=N&brY%oRHCMRW3;<3DBq3?ceKaEpTr8G0ydHc)qG$ zvLHE|Q+NAtg{S8Yb#=lgl=RT(+q-B+4hi4I@z~**(I;n13S0Nf?2W%(@YLlV)9fG+ z|2SQ_a-|>$vA5~3bb`WW`di_10<$pSKS;f<$zv^1s;a7**$BetG*JD>>RwBgE0!nG zt|^4ND_b{C2ZhkoE`Ad1IirRPTY_k4?*MZ^S_UIRnxTdmvk(h7faMAJ_2ChpjEoFe zV588^*4l7G@ZQ2$OJ=AvJCRRB8S(a4lzYYI=H~kcY~;!F;dFB9>INQ0QT76!pS<$*g>okg zg1=)*sjf;qhQrTQcucgBA?-ngC*2@h$A5Jux1b;f^YbK3j~d#h7;&dWV`3ta(*&Zq zT+JD)y%sy@4X4baDdU*qHC)7sL% z!81i$g2D1s)f_KKF)jS|x)jeZGzU*gb|NhEjLe76x z4PD9&8@d0hHBJ-uJux*{TScIXe}IW@JL^1PY+wM<7fIw75-NbSXBD)ONnm~sa9DMH zeJm=I4zXFf<}NN5j#zt z-(eM%uyKd6=7_{y6p_`jD~nM(m5lhlxNV{lh9-3$i?688i$Ov14cpYn9a?(&u>`yo z>|LOt5;tT==?i!C~cwI6aHRy^t;Wb z+*QrwGODUJmzxyC(dJ03zjI}4o3BZ-e3f4odjl&A%U?>S$=dh$Zg$*;(9op#*lUNg z#!Htk3%9rHKG9(bFXxd8zM9V$Yf~n#!&U++K+1nUWE0-TQcYc*Ze*Z6|G&96le+%*;G|$dK+Fi;0$ZyrH1r z>mCNy)X{bS5Wm7+U8Pln+7!~xcHsy zK6YhxaSeaYLxuCA8pAyEyH(T*d<@z2Ur?53RXRW*N|gGqaYKG0?(Y7%J9vP<@`(;F zkOnt@tuG85;IGz_MdF>?lMlRl1tnA?0;lgf)At-ayHR*3V5x6?p_4ZQt}*a}ieVzI zLI>QV`hAUO_Nh|?JFS0AQz^%NENX*Jfk6)Z>e8*%i7&Chg;9ZVL={;`se^HNe=pC? zCs36i#PZl_YAjYcdP13xRJZ0D6mIVxi?S;wFt>h1g=iC|9+T79z)6LM2k<%|ZuEoj zoQDqtpMvc{A4!N)pR zgmHLgHa4UEtcGM$AIQ%-QIK&A_D*pD{UI0Uy-IeAs--Pietsj3>=I@ykJ^pN?p!>M z5~;RORLs6ZGsR2`J5VKcJHXzNMs~9*uSK6vnFhvCklG$UUJ6v9Szk@h#xYW4-4Ft;lGu58{Rs0 z!Z{A6t1g-CKTurf9z~#g0*8P5&h6Vrrx*|-z2GU05sxM=c-7|EF<0>1n@(G+w}W;t zYT05Cg^i=S6cB3#wa!BG5_fgWI3%NCV;q8CkslK=#;iEHHE&U?;`_b-$RT0oXi^d8esLF8ADt(a}!= zkW+~Cc@vYFk570D&3ZM_r_r#yfP7Fkd=cJYcHpBr4;riwadQLqw(+#r+{5X9qpo}u z8R|HRjn+I>Pd}%=bS`U{uX0?bwXfg2VR!b2=@ga{k+WV~4I?Vs_ww+J)CSS$M z@-l&vO*ktN6>=+*d)UCt3=nX2^)E%m2FFz?pmK9fSi6~Hl9G}hJ^DJ}vZvf3svoMK zxyDsp^5)GCIsZuSIj$2}s<^BF8y~gpz)!BZ*k8@7tD8m0r&A$fy}Ch*pGKPe8S4~_ zdvC1w)1&^(`?OZQBRw(uSNPRaX`?^PUA57KSd1iOmrH!>qmH)t2Q+3PfhCrXG)2@^ z8?OPtFuT!}b>dVWkT*!G?x*l#`+{S0#W|y7BHE`3kvm%OzBB6e3}m=)(8! z0tCRzn8QUI9q`g>bX5yq zFyf4b^urn+IV4P;p?a`3J(F+b#U%2b4Onzni3)`DZ8n-frvwvH|EQYS5k*`}7Tpb! zUtNJry?O}yS`}`e$JKw;evS1omM30VqFm(Y&k_iG_z?_uWKRB=Qj61lOtkjGE*oN> zT2~k7{!)c)Ih&HItiq*17Y3(huFj^@*cuRsH}foH!SF_WwpJ^iL|UJOx`q>W(Lr>k z=ZWY*y-Xh&>C)3XM0P0Q%X%!Hn*`T)Z!+9@!U^wz9mu+3U;#zI z>Q9i{5vHBCs0`o@SI<*&AmxPda8~-iOBo*~AAiRqzAK$9u1Dbce%7q_ci$sOoA+|g zf8Ke17F-ptq#9NED3S=+;AI>+P(UAMtf$-FjYcgU6AfU!+_wF`ZPe6SxUg><-Hg9( za)EILNvhKyuE$bI>CZ#PBu{Hw%1?`%9O!n_p<`3lqiiaOUQF?6$l^5RvnDd5eMC8>T2@!A3G7jml!< zPGQr?_Yb8uEs&m(&oACaPr$8(Vp?dBk)wwfE6<}ps6=urh=99UvtsX@0~1obYeR{+ zvM~4pxo^wIuao%KY#(5hk}=GmQ{R@NkZRgHb%k)YkDl=BlEGQL@?BnCH0||{(LQK0 zQ7H`>g2&Rr682jE9zg9D12L6o@}Cu<)+9Ul|9Qogl@+qlUH^LJLl-K9ogjS{LD-t2 zt01kNeFLj=bp3n4!NKwOo8J6;^um&h3XQHBX9eDhnn?^AVA!?Ums@S$1}i;REOg~o zVlLgZfe6?6M_4S@3PGGciYQ9>(LWT&pWVNI|A_2k6B85Cp?jN0Wo2dg_}>2PgFYI% z#PhpL+@N>m-0#BnM47IieKcK@43Nt?9Y(ic4q2NI{rB&<+>$X;Q=^UhNP$>T2P6!u zz_gz?6c{2VU2nprgz&luLNvJzrz(;4HX34$_}|i+0?M!d95NRtl6v~z>GMi-$E=)0 z8ZrMlDxl8)>!bRg#-#y~^Y_@UeCUVx%1OS8h$*r0DWr)3s>=WB(dg*tfAxrkh2{VB zNRotvot2O{9F*?xG5OHPBZ6}Ww-yo)^{uh8vbH738u>9HNLIXz$8REvGQ*%eJiEOx z*`c{uIpL(7++d?Jz00ldYFDpbwH}Ut^oS)OcJt^+;jrslM)B*PGfm_n4TN_4Re&K60aH`>W=M8}_uN2A828 zX;}Sn1F95ZQBeiApSORQYinz(tE)RZ&v65qy~`aWEh$-{8g(RMKB3B?D+?;nUUQEx zK;x|qc(e0Q%h?LEu+8PEE|Qf_ z60SlsPUKWsP1D|X`<{@9h~aQ%<<@8Mb_t!+74%VZt9{uiL3vkmUi(aC#;HUdDfBX! zosnA`SQ@>{owuXE_VKw(z3*6rR32L}smMUK9;ibS$vjl30V9HE7y8XXPrH5-;w(H^Jl1nbF}mK@``(ga~h2H=Ap?0#a_M*R_0XZR~DVgId&>aBlBhM?NfHyI!WESii6>svAjxm za<(>rroZT2@WaGYH;*y5R>#R`(8$s2Zdh1uVZOiCj=v`#RJE6%n?YT^g4R@2Yyzt6 zvgwMAzJIyLgwBeXL2$9xDipE?Od0U;rgHAxQR;G+x-GJgaFUJ~Fx{C*3AWr>?vX19 z`bG*ju!@O|%~Mr_K32Dg%lDVca-X}?P32y6zqOPzgfM4pW|luEwL)xI8}jebRh5&T zhi2J|tN!!W_I9JaKq$KU8Cu~WhLu;|0*t0wp_L&_ zMsKR;s=finDhoc=)er?=9eL4{p7b@}*?h3T&nJIN11n96ysTh<2)|<#Sh%aQ^LjIQ zzGk{;4~S(wrb`tK4cos&dxUfP^{TR*4F>O878@PsK7I9_!cLb$ZXc&rmgYwz(V5NuV=vB%Ii6 z{^<7Y+xGe~l*o1{$Bbj0W_F`5WHX(cH)kI4sRBKUFr?Ge5~xCcU74Ye!=aCyb>TC^)2Wg2v>rA1OoQWpyKw*gQO@Oj!-a(f;E6gF0kyuk^NM2% z)`4Efb?D!wlgl2>)1RGy#nH~Sxeg@cUvT@PpCTMme^&o#N~GwWSQS*hXM1uD_yJZC zV>_MVAxnQhdh{qnIOw||6xq$y9KSmS$lb1)e2A6-M-L;?aU5fvEDwQ>XYH7MCxBEl z*bKD6ha1I-Sdc%CB75{O?3do&XB;6u7i*qB;bP1-t6OnI=thip9qos2AOsHpWZONbm@}0xj86i2+Rmx zJm+g6O*Mh;09@iVA4U`s`F65W0S-4i@&)7!G8ZWkS_v>8htCos{$sR1zm}c(n7pT3T4x2?9e)6!+fXd=NBwYLn&sMT|>Q)6#g) zovVW!p{J*p61?kcVFA2BlV`6*wzJcx7y&35FJHb4%>;TzMl5*kr>BA-JJ^BveVUgy z4SK8|zdz>|7wc+kKOJ%zdC}eR&3z^P*+t2;hc$%-1qD!!t|(jH+nc&3|6uJuOnsM5 zP{Z(qxX&;o*i3JFq~nsK{oeM3g4F8S*OtYlE}~S;4P(N?`|~wB{+5w41|gs?`X?}& zLS%@3U+dh)RLEud2|XnaQV zghMaXG~xybR`%oYkzZ@Q=Yd})FaAJ3)D#!;5v;&(liLUxab<8sr%{d{{zsM~)dzRb zl2GW2N=wK0vLe=Iju$xBN9n{ku{`{G#lrXR-zR&y2!5|iL6Jb@$pBiL9AMa*!@Z!h zuCt$oN7O0dH=$4=&joIDVA_tWK&bQy8Y?Jv62xxbAt$}P#iK>it zQ?#*AcnQFD!uONu&h-S2R`Y^k^1JPgwhi@`+@EKwll+7VZ$XurWsZXZ8>H20!FsB(D=R7weS zR8X~sGP0ks1)UbefyT^k=GaJU8Q@bwV`2{eCBqo5sHRhnTf4cr1xTMm!Y_^)Fr&^N|%a5^lYX!vZU_g!h#{jzmoK zn1yH0<>}sHGA$tTf~J^2#S0hC4IwLCC4&4-&S@$|9Ce)n9r|Pvu?6d0U{>{)FAU*$ zg=RVuqr(f`(wo=b+}@WY>m3UNHVxhyMwm|>6hp8)+S>rA0vA8>Lzp7|?k;&6$pbCm znd?8w9pHlT!3<_j9jH!d6}$r_n1ui8%_$xvRao+RPsvjqUU>7S{pRu%`AxfrCQ0z7 zD&+d+2+pdj@9D^I@=99w#KW7~>6nZ5N=l)lQVFp$CXZ%5-MF2ybCa%VI$dGuvpBT> zJJ(Q%y&7X*v#9Mvose#dnOxUHs|ON#ettE#9>iLUv*KU=?W-@E)P0S~9m>$Fls=*Y z0F$iyIQRtuo!AuZjOPQ^ zfop!RBSclZzX6W>{SJtozVF)`OYKSPkCK*N5=?`DFxQ6eg^i6(eqJ7ER){A~ojOHF zM>kWl8=d{Pl^0|uw6_)RDKG<9H52s&k93@GjTHt0 zvkcDkTR01!VUam7ZwRfHlM@ImZe$wtL9bT&O+~MzAZ#P?6uIB@w{t)i|CzmVKi+_$ zLB3qJu+ZL`pa8EI&^It3Z(zxFQ`H~H>A1&_U;3>$iZC!D+D3YMrGI18-#*8iiMN#a zvVcEL=uQ@E91g!Z4b3pXsGnL}!Oe)2F(C0gcu+071ir&H7HW(wxc)}=q2Rqh=+Xe+ zVg!98Re;M6AZWmV!b7aAK@0EMIt6Y5PswEmc1RvD7b{``^fl;###zBvpr!mvJOVXx z^;?mJnVueR(wiaJ^}V1!q1=CaK+riZB~TdyLGl0!MaS*+g@8(KB%t^4aZS?nG#tkf zI1VU$(#h}xO!@UCL&GA|3gEnjl+hnNi)!a;q=o|wz~lQq)XvunVw<3E-t>yBp3Bzd zh6riR1_C5muNhFtko#EaoX*v^(y<7KuzB@GT!;rG#q#z2*q!X@G~rN<8g8 z{^8A=H{>2B8igDnTh-s7mI$}B>@i)~tWbwE8V1Ox{A+jyWCFfWSZ`v2o+0^K1z3em z-~F05gBVaQarGT-5FZ0E7W(LLe}YLAXF=#OL9YH%{O4rIr&|IoG7Z%ZFptSoLP9gK zX3o$PMO2`(gA3XQ)X_%|P1ydCrckgNC?J5fLOI0nJ0!OZ)V?h` zXD1Ul4f?Iqr%&Hy;*R~nf{1;JE@P-u{0==IaQPizxfu*t@g0p2Of&e5`2kau{4X38 zl@}kUlygiy

TmLvZd_(oj*m^SCwJXrrIOe$RS(O?|G{NeP?WR8LlDjpH8g4-C`# zDDq`XU$_0jO4#tc)SpvqWZfIU6w#NeNQXPP?AD*ROc6Db#l8JLKkc%E-IC{79?6{= zYEZPToml_d-}2n|!+&f#S>8^gL)bTW!-7C`)pK+uD@pGQ7DYq)|FZg?&FXOVW|Pss zK2&uK5C`A{M7&tNtAjb3HcsElYLAp(JOqdfFl#D%_lW+OCN3-z8c_r3pqmpF)dfSB zc*OD}DW9P)6~ z;nU{fkRx6_+Z6>dqL3^}UYi>PzZtpVcry1Zw3smfufMEzYKrc(1aGUV(dOGB zi{QsS7a!}4a=@nC%dUSRciip7BAE~4@U-u4QKEr9hybad0W4r-5ULizH+9e%%nW2W zf<~N_JZcdYvvTG2LQ%d@w`;Xx}X3a(g`=pY^9kmI7ERVyM$loeARsP%_it z@Qv55+1a`DS8BtDWb0F2hyb#=1LlgiAFA^O3Pu-nrVy-M+jf$m@7+WcZS?v9$Lb01$f$93J1;9X}=>h$N=dP~>wPM*+yQy><;)m79MB zRS2zFmGdAuVkc3M1<>}ql8d0nA}hF#Nm3c_;DOf*2auMVn+tLB1spB~tElB?yE<7W z&<-*?Zf^w0!&VBiFad@N(aLDrH`_tMo}MdYwH2JIvZe2@rx4iGv+;QFw*QsHOKkUu zm94@w3YhMO+;!l4-!);_E-}`7R68a)*?Hu}d$LZ@T7EM}Tj{O(bk*}Xwm((ZCCrpA2r6Je_(H+NJ*L(PKc{rNMzvFrDNbbmd)y-`*m;+3Jb zaw5Wgi=Luk*d;Kt83a4^2gR>WM@1dk(de&43lC8v*Jb_6-zG>h0k5av@-gBt&WU2U z=J|$#%RU9nN)Tyf6~00OdrZ*w*UG@Jr>HzypWX(=p5q z)HMBip)ad_62aq}FWwc;=k8hK1`*9eNzx*Z3cc|#jjKT<>NCMubDlS3tX{n6shba(hH-afJ-=PEs(;MJmg6C zUu6KynVQ>;Y9*mp$+Fjr7hN?kUyf1?Wrf7!%1%vvU~NVnvOyO}h{`X|Oe*KLv%DsFh=arDF`E}6$AUb9iqItO7NBH;KTHpU$G3bHcZ zk0f*?gqJo0^Te9q;Jy6fpvw?4RuabR`BbI%<{-QvCXOS&$3GY3-g}CnsE#9xYd?_C z7_bgZrQeD!kXRQj1~nmc^xg~iImXWqay%i}dC9#GAMO^{`^ACI+M-)A8zTogSXZjq??HEw`>38%GdW8=E4zqc+#a96AN8`Txm?opPH7Gh4H6Q4Ysp zD5N_poqFeoCMbBZoabzX}@3pGu-2LqS(&$xjFN%Qq~3 zYEVa`xU83c_hYyw`I9Kbpn$0k;h2<>5xMpboEsBX7a^i?C%00cVbjAVxHl%~+(scwhou-^;6eoE? zYf-(uRbbb`u&hK@@+nxh(y2Chc~U{s4rL0}Av*GiHmK_RpiUim)7^E)l;(X)90Qj0 zq>VzNKTCV)K3&`WxAtHdaTj%Y9of4}$0~W1h91GwkAVBvW;vk~UIL==#$UPFPZASv zK^t2x**migVf(c<{L*{g8Uj8C9CKK=!Gp~w@y79w7b^*@Bj+|Y6b2fCfo5q`hb;Cb zJ^8x3JI%Z1pwj{C0aHTw4*nFa+`JmHwpIyBvhbc-l)a+dukrEE5AT^A#Zt{>9tk2T zf-}%UdBNjaXzHo0?R3#f$m$6D!AtyDv z8H8lZEuKU~Fekgb`M~+Wt>4R&rHVMrO}pMAmj0?ekW9PEa9EIkM?lQ%equhh1m!~6 zo3~LKEw@T+T7DE6D_%Ro41qEF>v}ocks$kl>bQ(DeB)p5V1@8ye*%7##E_Ou!8yz^ zRwij4qFaRA1W~^=eMCxOT7CF~ewmwnoSkmgB@vwS1qCw#Fa)%3h}Mhb6P!lpw|~ad ztUncL1W6d`tmhnCOH8TFo)o#)e^f%;xbmS?pj1H(ZLt2z>bSxF z1M~*^L$R1^2LHprhah3Q(a^fGtWc#q@fh;h4reKT|8C|{+wqZ(v^LJO+hl$R3NFNG zYwl>OO-l%`6063gx<3QydA*-mrGU#3um7_6=?Tz{y!WF{F!?gIKjaD|LspBllGYO Xd4fuKjbkpr77%r1oeM?htwa7BkS8Q( diff --git a/tests/test_files/media_files/signals.png b/tests/test_files/media_files/signals.png deleted file mode 100644 index 4c55c9dbfd78f517efeb63c402b511a9bcaf0e11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15118 zcmcJ$c{r7C`!9Si(~@DKfeb68l8lA0ge94$Q0A#9Lz$O(T9MLVsDxz9Jd-hGXrNNa z5M_?SGG(6G=c?z}d%t_{-yi!u-uI8^@O-=PYq+oL96smg{H%KybyOMYIOq@rVN_SU zppPJw@P8@rv=s2C;&Utt{-6o!+REr3DC$EZ+*85=0`O1!T-8iG5ah@k^8ZlI9iH;= zk5qPw+KLEL8b!Z-g&IL{N$M9AFZukK9o*-0c_nFY;TY9R)OR)7+S`GzE|@4co(PMi zKXpmF?hNi*rlTia@L{arN@-yiA*--e&91!fY9XbMcFuiT`vVN0Fd?@!!_V(O#_}eX zibCD^$sW^Hy_n;#WF`XIHLYyu^;G>J){$D>hFwoJ#lLV7C z>L;SeQyjQC;&koWwao17e9`TohW3dvAKg03;+C;1(>Id}%RDRFzJ0HWHEVBcYy0p) zjC5Uwk_ti0?@}`(h%1&A4-WzjHKizH>)Q1F|r(;Vb zR=Z{)aBKBTf~2M&8_HXDh~IusqE+h0&_grHYp6@{%*)f44CWuw`<&oC`H|#AA#&#G zyYP=s_=DHx%nnU}lW^Ve(}qf=9D^QGvd2bGxQ zx?lTyPaVu!N?!TWARsK9BxePp?Cbg zRw&i|yFJC~;zgb$dZO|qg2GJ?)~>x?8xl-);5Dc=aftR>(yGTk<)J&b2I=Ft{lp#r0e0j)! zzR;vh1;=822G%WD?z4${-`uQ`-IzH*h~0dVmq+?;m+~}{a^wumeD)AGw}MSG!_-0h zvlW-*yiF%JCQ^!Xa-wyp4&TKvBTtq|J=67Db5C?oHi>3$>NiRGm$Ff2uyWsdclX7e zwPDdDit92}IZ5JnI_md+iKe2`J5UtX;eCC5El2#daMT4J&d;`H%GwPtUsi8H4Lraw z^NbDe?F^@lvk7G(>+etZabbB7?U+Z89zA}1G>IbeYxTvp_V)HqpB}eS zOg_XgTi*{456@2a)4)+Dp86%U^1e8DXS${aHznPuVBd|RbGEk!6NLzKVS)89kC3#q zF^+=j&(8zV*FI64EPl7Qd?w|+|KevK7t@!jj?)n}0d8?0y!KM6T6>bk?Pzzv1MODo z{a+6?GqrdvebY^mAI!Tf_@D~oja#$!SYqL?U8&e5?XAvM`b1_|tsy@|N)ISOt=o zljF0yF_9VCxB*7FIcn3NcDkaV{qyH>rQf$a;GY!vR3j5593B(s8VM2HXBu2b->gjc zYTDfr|B9kmX6EGF0yBp_3Q0~C*rfzk=H+SN=y~p7mCVakEO*$utdXjN-FPr_wM;_FSNkNojo&JFub?B zb{B&M%j$=R(qF;V`)j>jrVbLZxokAb%F4kTtyUSK2rD>;(P1&`Mhox3<4F`Hk}Hcm zF!S8NtFW-JP7`Y*0ue!PR0VHURaZ;HXz`plx~ET{w#%(05||s!3B=Sg7CsfNJc##wbJ$Lzh&p^%C5YnnGz~tkHF!14d-dBpYo>!Jnms)7V})s2~~q*WX0h-I>48L z*WYq$WFuU~PW_e7o;`DE*ja^Fk0w#Crs|=?#qk|q98^vb7E!5L%6>Nc{&2Z>O{j6$Cy(d^y{bp=IEgKIFG6B??>K)4cn_bZqR2B#K7%DEUo$OG`^<=OO~L zaR|hZU)c&f0{PsHhTXim7_F&;_@xe1h)uc6@a{eSWVp-`ipP&1!)*p{^$!~w8G)5N z0~2E9#1T%ysNda+d6>&E>IsaZ90=N)HL~!0N}xmHy!)N1j`Q=28kgX4w4W8xv?ZTy zqU4%MXjf*>#=N(NIBHjR0@2!P_D99m_x2#z9KH-f>-Fo`cYA|(*tnC@IN=PwZ?4U^ z!x#xq+I&j5L)R-XM)kj2T59GT#M6O+fx5bLQ#@kih;|M#8b6!?C&|?iT|uB5>4y`r z%+h||d{>Aff&H9M39j5KU_p6`$Nn^2e-k4kqv8C$kBb`Hx}r<>Zq~H2dEdLaw&z=G zK70BX1FsM>7ncwg+~WD`xzcY|JHz#ZN1x9aXjARX>9E3 ztl+A^x8>v3UzPdi+#a3mxCwqLGCuZ1^t zC5_`oxx+mVGjH)d{+58U!rk%%^GdUFZ{PN?k*Kr%zB_b(=|qK;goK2I(X)X8TVq#3 z>^d{lLunWvV?us+vx)g`Se|f``hX2mPd+$m^rPp` z&CJZ6CnTK0QhjOiBobLoYY1dx`PI#QF&&SiHouQyrV=u*E=f&=|3Z9If;Ok_9pFEq zop20GrLBafr8V~QI-AdpOyu0^@R8Zqr~%iPD>dX}GtrjRDS-5~Mq*{VT}KXxa}hDj z%tYU%-=&)UDUiyG z!;AIRcuud!K!nieJAU-&)$Saq_Ub+g7S^_+bCMP$SUD`Dj+;7t$ z{WpM_P62aB+<&x<%-zBWRETp^<1OAvBrOwioco^v;mB`WmbAsSyAy8OzfO-N7vkZpxSLLBea?U%adACa#5HF zfzMS`GGskdj0)DHa2sj7RW%%B2Io^{5SpWO7qf5QzD`B+Pz~MOeVr8f*`*EGE`BMeY31Dk-Ezks z4P3Yvj5!YYRuqduD4N#aWPU}xQ7$wteNj*Vm=2*jA0aCFZoS$k6cPlT z=Oa16EoSw{V;lCZFeQ(UlA^l5b#-+ynwQ7B)6jBAdy+0$S|;C;GB!3||G*a&&$80n zu)7j}+Q!tKzBNvCZ_aA2zp8zD-Il&caRw=LcXtmEy0q^M z7c7p1G7b4oBwZhHH#JRQm-645tx|vkyFRe!R5fU5V&bs=#WHwjMfcpoZT4I9uO^O& z2Zecze|Y40<%-J4R9CjkP~G=N&brY%oRHCMRW3;<3DBq3?ceKaEpTr8G0ydHc)qG$ zvLHE|Q+NAtg{S8Yb#=lgl=RT(+q-B+4hi4I@z~**(I;n13S0Nf?2W%(@YLlV)9fG+ z|2SQ_a-|>$vA5~3bb`WW`di_10<$pSKS;f<$zv^1s;a7**$BetG*JD>>RwBgE0!nG zt|^4ND_b{C2ZhkoE`Ad1IirRPTY_k4?*MZ^S_UIRnxTdmvk(h7faMAJ_2ChpjEoFe zV588^*4l7G@ZQ2$OJ=AvJCRRB8S(a4lzYYI=H~kcY~;!F;dFB9>INQ0QT76!pS<$*g>okg zg1=)*sjf;qhQrTQcucgBA?-ngC*2@h$A5Jux1b;f^YbK3j~d#h7;&dWV`3ta(*&Zq zT+JD)y%sy@4X4baDdU*qHC)7sL% z!81i$g2D1s)f_KKF)jS|x)jeZGzU*gb|NhEjLe76x z4PD9&8@d0hHBJ-uJux*{TScIXe}IW@JL^1PY+wM<7fIw75-NbSXBD)ONnm~sa9DMH zeJm=I4zXFf<}NN5j#zt z-(eM%uyKd6=7_{y6p_`jD~nM(m5lhlxNV{lh9-3$i?688i$Ov14cpYn9a?(&u>`yo z>|LOt5;tT==?i!C~cwI6aHRy^t;Wb z+*QrwGODUJmzxyC(dJ03zjI}4o3BZ-e3f4odjl&A%U?>S$=dh$Zg$*;(9op#*lUNg z#!Htk3%9rHKG9(bFXxd8zM9V$Yf~n#!&U++K+1nUWE0-TQcYc*Ze*Z6|G&96le+%*;G|$dK+Fi;0$ZyrH1r z>mCNy)X{bS5Wm7+U8Pln+7!~xcHsy zK6YhxaSeaYLxuCA8pAyEyH(T*d<@z2Ur?53RXRW*N|gGqaYKG0?(Y7%J9vP<@`(;F zkOnt@tuG85;IGz_MdF>?lMlRl1tnA?0;lgf)At-ayHR*3V5x6?p_4ZQt}*a}ieVzI zLI>QV`hAUO_Nh|?JFS0AQz^%NENX*Jfk6)Z>e8*%i7&Chg;9ZVL={;`se^HNe=pC? zCs36i#PZl_YAjYcdP13xRJZ0D6mIVxi?S;wFt>h1g=iC|9+T79z)6LM2k<%|ZuEoj zoQDqtpMvc{A4!N)pR zgmHLgHa4UEtcGM$AIQ%-QIK&A_D*pD{UI0Uy-IeAs--Pietsj3>=I@ykJ^pN?p!>M z5~;RORLs6ZGsR2`J5VKcJHXzNMs~9*uSK6vnFhvCklG$UUJ6v9Szk@h#xYW4-4Ft;lGu58{Rs0 z!Z{A6t1g-CKTurf9z~#g0*8P5&h6Vrrx*|-z2GU05sxM=c-7|EF<0>1n@(G+w}W;t zYT05Cg^i=S6cB3#wa!BG5_fgWI3%NCV;q8CkslK=#;iEHHE&U?;`_b-$RT0oXi^d8esLF8ADt(a}!= zkW+~Cc@vYFk570D&3ZM_r_r#yfP7Fkd=cJYcHpBr4;riwadQLqw(+#r+{5X9qpo}u z8R|HRjn+I>Pd}%=bS`U{uX0?bwXfg2VR!b2=@ga{k+WV~4I?Vs_ww+J)CSS$M z@-l&vO*ktN6>=+*d)UCt3=nX2^)E%m2FFz?pmK9fSi6~Hl9G}hJ^DJ}vZvf3svoMK zxyDsp^5)GCIsZuSIj$2}s<^BF8y~gpz)!BZ*k8@7tD8m0r&A$fy}Ch*pGKPe8S4~_ zdvC1w)1&^(`?OZQBRw(uSNPRaX`?^PUA57KSd1iOmrH!>qmH)t2Q+3PfhCrXG)2@^ z8?OPtFuT!}b>dVWkT*!G?x*l#`+{S0#W|y7BHE`3kvm%OzBB6e3}m=)(8! z0tCRzn8QUI9q`g>bX5yq zFyf4b^urn+IV4P;p?a`3J(F+b#U%2b4Onzni3)`DZ8n-frvwvH|EQYS5k*`}7Tpb! zUtNJry?O}yS`}`e$JKw;evS1omM30VqFm(Y&k_iG_z?_uWKRB=Qj61lOtkjGE*oN> zT2~k7{!)c)Ih&HItiq*17Y3(huFj^@*cuRsH}foH!SF_WwpJ^iL|UJOx`q>W(Lr>k z=ZWY*y-Xh&>C)3XM0P0Q%X%!Hn*`T)Z!+9@!U^wz9mu+3U;#zI z>Q9i{5vHBCs0`o@SI<*&AmxPda8~-iOBo*~AAiRqzAK$9u1Dbce%7q_ci$sOoA+|g zf8Ke17F-ptq#9NED3S=+;AI>+P(UAMtf$-FjYcgU6AfU!+_wF`ZPe6SxUg><-Hg9( za)EILNvhKyuE$bI>CZ#PBu{Hw%1?`%9O!n_p<`3lqiiaOUQF?6$l^5RvnDd5eMC8>T2@!A3G7jml!< zPGQr?_Yb8uEs&m(&oACaPr$8(Vp?dBk)wwfE6<}ps6=urh=99UvtsX@0~1obYeR{+ zvM~4pxo^wIuao%KY#(5hk}=GmQ{R@NkZRgHb%k)YkDl=BlEGQL@?BnCH0||{(LQK0 zQ7H`>g2&Rr682jE9zg9D12L6o@}Cu<)+9Ul|9Qogl@+qlUH^LJLl-K9ogjS{LD-t2 zt01kNeFLj=bp3n4!NKwOo8J6;^um&h3XQHBX9eDhnn?^AVA!?Ums@S$1}i;REOg~o zVlLgZfe6?6M_4S@3PGGciYQ9>(LWT&pWVNI|A_2k6B85Cp?jN0Wo2dg_}>2PgFYI% z#PhpL+@N>m-0#BnM47IieKcK@43Nt?9Y(ic4q2NI{rB&<+>$X;Q=^UhNP$>T2P6!u zz_gz?6c{2VU2nprgz&luLNvJzrz(;4HX34$_}|i+0?M!d95NRtl6v~z>GMi-$E=)0 z8ZrMlDxl8)>!bRg#-#y~^Y_@UeCUVx%1OS8h$*r0DWr)3s>=WB(dg*tfAxrkh2{VB zNRotvot2O{9F*?xG5OHPBZ6}Ww-yo)^{uh8vbH738u>9HNLIXz$8REvGQ*%eJiEOx z*`c{uIpL(7++d?Jz00ldYFDpbwH}Ut^oS)OcJt^+;jrslM)B*PGfm_n4TN_4Re&K60aH`>W=M8}_uN2A828 zX;}Sn1F95ZQBeiApSORQYinz(tE)RZ&v65qy~`aWEh$-{8g(RMKB3B?D+?;nUUQEx zK;x|qc(e0Q%h?LEu+8PEE|Qf_ z60SlsPUKWsP1D|X`<{@9h~aQ%<<@8Mb_t!+74%VZt9{uiL3vkmUi(aC#;HUdDfBX! zosnA`SQ@>{owuXE_VKw(z3*6rR32L}smMUK9;ibS$vjl30V9HE7y8XXPrH5-;w(H^Jl1nbF}mK@``(ga~h2H=Ap?0#a_M*R_0XZR~DVgId&>aBlBhM?NfHyI!WESii6>svAjxm za<(>rroZT2@WaGYH;*y5R>#R`(8$s2Zdh1uVZOiCj=v`#RJE6%n?YT^g4R@2Yyzt6 zvgwMAzJIyLgwBeXL2$9xDipE?Od0U;rgHAxQR;G+x-GJgaFUJ~Fx{C*3AWr>?vX19 z`bG*ju!@O|%~Mr_K32Dg%lDVca-X}?P32y6zqOPzgfM4pW|luEwL)xI8}jebRh5&T zhi2J|tN!!W_I9JaKq$KU8Cu~WhLu;|0*t0wp_L&_ zMsKR;s=finDhoc=)er?=9eL4{p7b@}*?h3T&nJIN11n96ysTh<2)|<#Sh%aQ^LjIQ zzGk{;4~S(wrb`tK4cos&dxUfP^{TR*4F>O878@PsK7I9_!cLb$ZXc&rmgYwz(V5NuV=vB%Ii6 z{^<7Y+xGe~l*o1{$Bbj0W_F`5WHX(cH)kI4sRBKUFr?Ge5~xCcU74Ye!=aCyb>TC^)2Wg2v>rA1OoQWpyKw*gQO@Oj!-a(f;E6gF0kyuk^NM2% z)`4Efb?D!wlgl2>)1RGy#nH~Sxeg@cUvT@PpCTMme^&o#N~GwWSQS*hXM1uD_yJZC zV>_MVAxnQhdh{qnIOw||6xq$y9KSmS$lb1)e2A6-M-L;?aU5fvEDwQ>XYH7MCxBEl z*bKD6ha1I-Sdc%CB75{O?3do&XB;6u7i*qB;bP1-t6OnI=thip9qos2AOsHpWZONbm@}0xj86i2+Rmx zJm+g6O*Mh;09@iVA4U`s`F65W0S-4i@&)7!G8ZWkS_v>8htCos{$sR1zm}c(n7pT3T4x2?9e)6!+fXd=NBwYLn&sMT|>Q)6#g) zovVW!p{J*p61?kcVFA2BlV`6*wzJcx7y&35FJHb4%>;TzMl5*kr>BA-JJ^BveVUgy z4SK8|zdz>|7wc+kKOJ%zdC}eR&3z^P*+t2;hc$%-1qD!!t|(jH+nc&3|6uJuOnsM5 zP{Z(qxX&;o*i3JFq~nsK{oeM3g4F8S*OtYlE}~S;4P(N?`|~wB{+5w41|gs?`X?}& zLS%@3U+dh)RLEud2|XnaQV zghMaXG~xybR`%oYkzZ@Q=Yd})FaAJ3)D#!;5v;&(liLUxab<8sr%{d{{zsM~)dzRb zl2GW2N=wK0vLe=Iju$xBN9n{ku{`{G#lrXR-zR&y2!5|iL6Jb@$pBiL9AMa*!@Z!h zuCt$oN7O0dH=$4=&joIDVA_tWK&bQy8Y?Jv62xxbAt$}P#iK>it zQ?#*AcnQFD!uONu&h-S2R`Y^k^1JPgwhi@`+@EKwll+7VZ$XurWsZXZ8>H20!FsB(D=R7weS zR8X~sGP0ks1)UbefyT^k=GaJU8Q@bwV`2{eCBqo5sHRhnTf4cr1xTMm!Y_^)Fr&^N|%a5^lYX!vZU_g!h#{jzmoK zn1yH0<>}sHGA$tTf~J^2#S0hC4IwLCC4&4-&S@$|9Ce)n9r|Pvu?6d0U{>{)FAU*$ zg=RVuqr(f`(wo=b+}@WY>m3UNHVxhyMwm|>6hp8)+S>rA0vA8>Lzp7|?k;&6$pbCm znd?8w9pHlT!3<_j9jH!d6}$r_n1ui8%_$xvRao+RPsvjqUU>7S{pRu%`AxfrCQ0z7 zD&+d+2+pdj@9D^I@=99w#KW7~>6nZ5N=l)lQVFp$CXZ%5-MF2ybCa%VI$dGuvpBT> zJJ(Q%y&7X*v#9Mvose#dnOxUHs|ON#ettE#9>iLUv*KU=?W-@E)P0S~9m>$Fls=*Y z0F$iyIQRtuo!AuZjOPQ^ zfop!RBSclZzX6W>{SJtozVF)`OYKSPkCK*N5=?`DFxQ6eg^i6(eqJ7ER){A~ojOHF zM>kWl8=d{Pl^0|uw6_)RDKG<9H52s&k93@GjTHt0 zvkcDkTR01!VUam7ZwRfHlM@ImZe$wtL9bT&O+~MzAZ#P?6uIB@w{t)i|CzmVKi+_$ zLB3qJu+ZL`pa8EI&^It3Z(zxFQ`H~H>A1&_U;3>$iZC!D+D3YMrGI18-#*8iiMN#a zvVcEL=uQ@E91g!Z4b3pXsGnL}!Oe)2F(C0gcu+071ir&H7HW(wxc)}=q2Rqh=+Xe+ zVg!98Re;M6AZWmV!b7aAK@0EMIt6Y5PswEmc1RvD7b{``^fl;###zBvpr!mvJOVXx z^;?mJnVueR(wiaJ^}V1!q1=CaK+riZB~TdyLGl0!MaS*+g@8(KB%t^4aZS?nG#tkf zI1VU$(#h}xO!@UCL&GA|3gEnjl+hnNi)!a;q=o|wz~lQq)XvunVw<3E-t>yBp3Bzd zh6riR1_C5muNhFtko#EaoX*v^(y<7KuzB@GT!;rG#q#z2*q!X@G~rN<8g8 z{^8A=H{>2B8igDnTh-s7mI$}B>@i)~tWbwE8V1Ox{A+jyWCFfWSZ`v2o+0^K1z3em z-~F05gBVaQarGT-5FZ0E7W(LLe}YLAXF=#OL9YH%{O4rIr&|IoG7Z%ZFptSoLP9gK zX3o$PMO2`(gA3XQ)X_%|P1ydCrckgNC?J5fLOI0nJ0!OZ)V?h` zXD1Ul4f?Iqr%&Hy;*R~nf{1;JE@P-u{0==IaQPizxfu*t@g0p2Of&e5`2kau{4X38 zl@}kUlygiy

TmLvZd_(oj*m^SCwJXrrIOe$RS(O?|G{NeP?WR8LlDjpH8g4-C`# zDDq`XU$_0jO4#tc)SpvqWZfIU6w#NeNQXPP?AD*ROc6Db#l8JLKkc%E-IC{79?6{= zYEZPToml_d-}2n|!+&f#S>8^gL)bTW!-7C`)pK+uD@pGQ7DYq)|FZg?&FXOVW|Pss zK2&uK5C`A{M7&tNtAjb3HcsElYLAp(JOqdfFl#D%_lW+OCN3-z8c_r3pqmpF)dfSB zc*OD}DW9P)6~ z;nU{fkRx6_+Z6>dqL3^}UYi>PzZtpVcry1Zw3smfufMEzYKrc(1aGUV(dOGB zi{QsS7a!}4a=@nC%dUSRciip7BAE~4@U-u4QKEr9hybad0W4r-5ULizH+9e%%nW2W zf<~N_JZcdYvvTG2LQ%d@w`;Xx}X3a(g`=pY^9kmI7ERVyM$loeARsP%_it z@Qv55+1a`DS8BtDWb0F2hyb#=1LlgiAFA^O3Pu-nrVy-M+jf$m@7+WcZS?v9$Lb01$f$93J1;9X}=>h$N=dP~>wPM*+yQy><;)m79MB zRS2zFmGdAuVkc3M1<>}ql8d0nA}hF#Nm3c_;DOf*2auMVn+tLB1spB~tElB?yE<7W z&<-*?Zf^w0!&VBiFad@N(aLDrH`_tMo}MdYwH2JIvZe2@rx4iGv+;QFw*QsHOKkUu zm94@w3YhMO+;!l4-!);_E-}`7R68a)*?Hu}d$LZ@T7EM}Tj{O(bk*}Xwm((ZCCrpA2r6Je_(H+NJ*L(PKc{rNMzvFrDNbbmd)y-`*m;+3Jb zaw5Wgi=Luk*d;Kt83a4^2gR>WM@1dk(de&43lC8v*Jb_6-zG>h0k5av@-gBt&WU2U z=J|$#%RU9nN)Tyf6~00OdrZ*w*UG@Jr>HzypWX(=p5q z)HMBip)ad_62aq}FWwc;=k8hK1`*9eNzx*Z3cc|#jjKT<>NCMubDlS3tX{n6shba(hH-afJ-=PEs(;MJmg6C zUu6KynVQ>;Y9*mp$+Fjr7hN?kUyf1?Wrf7!%1%vvU~NVnvOyO}h{`X|Oe*KLv%DsFh=arDF`E}6$AUb9iqItO7NBH;KTHpU$G3bHcZ zk0f*?gqJo0^Te9q;Jy6fpvw?4RuabR`BbI%<{-QvCXOS&$3GY3-g}CnsE#9xYd?_C z7_bgZrQeD!kXRQj1~nmc^xg~iImXWqay%i}dC9#GAMO^{`^ACI+M-)A8zTogSXZjq??HEw`>38%GdW8=E4zqc+#a96AN8`Txm?opPH7Gh4H6Q4Ysp zD5N_poqFeoCMbBZoabzX}@3pGu-2LqS(&$xjFN%Qq~3 zYEVa`xU83c_hYyw`I9Kbpn$0k;h2<>5xMpboEsBX7a^i?C%00cVbjAVxHl%~+(scwhou-^;6eoE? zYf-(uRbbb`u&hK@@+nxh(y2Chc~U{s4rL0}Av*GiHmK_RpiUim)7^E)l;(X)90Qj0 zq>VzNKTCV)K3&`WxAtHdaTj%Y9of4}$0~W1h91GwkAVBvW;vk~UIL==#$UPFPZASv zK^t2x**migVf(c<{L*{g8Uj8C9CKK=!Gp~w@y79w7b^*@Bj+|Y6b2fCfo5q`hb;Cb zJ^8x3JI%Z1pwj{C0aHTw4*nFa+`JmHwpIyBvhbc-l)a+dukrEE5AT^A#Zt{>9tk2T zf-}%UdBNjaXzHo0?R3#f$m$6D!AtyDv z8H8lZEuKU~Fekgb`M~+Wt>4R&rHVMrO}pMAmj0?ekW9PEa9EIkM?lQ%equhh1m!~6 zo3~LKEw@T+T7DE6D_%Ro41qEF>v}ocks$kl>bQ(DeB)p5V1@8ye*%7##E_Ou!8yz^ zRwij4qFaRA1W~^=eMCxOT7CF~ewmwnoSkmgB@vwS1qCw#Fa)%3h}Mhb6P!lpw|~ad ztUncL1W6d`tmhnCOH8TFo)o#)e^f%;xbmS?pj1H(ZLt2z>bSxF z1M~*^L$R1^2LHprhah3Q(a^fGtWc#q@fh;h4reKT|8C|{+wqZ(v^LJO+hl$R3NFNG zYwl>OO-l%`6063gx<3QydA*-mrGU#3um7_6=?Tz{y!WF{F!?gIKjaD|LspBllGYO Xd4fuKjbkpr77%r1oeM?htwa7BkS8Q( 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 From 19048d4c876de05b8a0e362faaf36fbea79abc02 Mon Sep 17 00:00:00 2001 From: Jordan Munch O'Hare Date: Mon, 25 May 2026 09:50:37 +0200 Subject: [PATCH 2/8] feat: add Rust Brain Brew core and CLI --- .editorconfig | 15 + .gitignore | 149 +- Cargo.lock | 1265 ++++++++ Cargo.toml | 25 + crates/brain-brew-cli/Cargo.toml | 28 + crates/brain-brew-cli/src/args.rs | 335 ++ crates/brain-brew-cli/src/commands/compose.rs | 73 + crates/brain-brew-cli/src/commands/diff.rs | 33 + crates/brain-brew-cli/src/commands/explain.rs | 122 + crates/brain-brew-cli/src/commands/export.rs | 109 + crates/brain-brew-cli/src/commands/fmt.rs | 23 + crates/brain-brew-cli/src/commands/import.rs | 38 + crates/brain-brew-cli/src/commands/lock.rs | 885 ++++++ crates/brain-brew-cli/src/commands/mod.rs | 10 + crates/brain-brew-cli/src/commands/targets.rs | 78 + .../brain-brew-cli/src/commands/validate.rs | 128 + crates/brain-brew-cli/src/commands/verify.rs | 120 + crates/brain-brew-cli/src/help.rs | 73 + crates/brain-brew-cli/src/io.rs | 482 +++ crates/brain-brew-cli/src/main.rs | 63 + crates/brain-brew-cli/src/media_assets.rs | 58 + crates/brain-brew-cli/src/output.rs | 174 ++ crates/brain-brew-cli/src/overlay_draft.rs | 299 ++ crates/brain-brew-cli/src/package_resolver.rs | 93 + crates/brain-brew-core/Cargo.toml | 12 + crates/brain-brew-core/src/lib.rs | 2720 +++++++++++++++++ crates/brain-brew-formats/Cargo.toml | 19 + .../brain-brew-formats/src/canonical_yaml.rs | 1827 +++++++++++ crates/brain-brew-formats/src/crowdanki.rs | 1110 +++++++ crates/brain-brew-formats/src/lib.rs | 31 + crates/brain-brew-formats/src/lockfile.rs | 238 ++ crates/brain-brew-formats/src/manifest.rs | 405 +++ crates/brain-brew-formats/src/media.rs | 235 ++ devbox.json | 46 + devbox.lock | 295 ++ flake.lock | 27 + flake.nix | 88 + rustfmt.toml | 3 + 38 files changed, 11607 insertions(+), 127 deletions(-) create mode 100644 .editorconfig create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/brain-brew-cli/Cargo.toml create mode 100644 crates/brain-brew-cli/src/args.rs create mode 100644 crates/brain-brew-cli/src/commands/compose.rs create mode 100644 crates/brain-brew-cli/src/commands/diff.rs create mode 100644 crates/brain-brew-cli/src/commands/explain.rs create mode 100644 crates/brain-brew-cli/src/commands/export.rs create mode 100644 crates/brain-brew-cli/src/commands/fmt.rs create mode 100644 crates/brain-brew-cli/src/commands/import.rs create mode 100644 crates/brain-brew-cli/src/commands/lock.rs create mode 100644 crates/brain-brew-cli/src/commands/mod.rs create mode 100644 crates/brain-brew-cli/src/commands/targets.rs create mode 100644 crates/brain-brew-cli/src/commands/validate.rs create mode 100644 crates/brain-brew-cli/src/commands/verify.rs create mode 100644 crates/brain-brew-cli/src/help.rs create mode 100644 crates/brain-brew-cli/src/io.rs create mode 100644 crates/brain-brew-cli/src/main.rs create mode 100644 crates/brain-brew-cli/src/media_assets.rs create mode 100644 crates/brain-brew-cli/src/output.rs create mode 100644 crates/brain-brew-cli/src/overlay_draft.rs create mode 100644 crates/brain-brew-cli/src/package_resolver.rs create mode 100644 crates/brain-brew-core/Cargo.toml create mode 100644 crates/brain-brew-core/src/lib.rs create mode 100644 crates/brain-brew-formats/Cargo.toml create mode 100644 crates/brain-brew-formats/src/canonical_yaml.rs create mode 100644 crates/brain-brew-formats/src/crowdanki.rs create mode 100644 crates/brain-brew-formats/src/lib.rs create mode 100644 crates/brain-brew-formats/src/lockfile.rs create mode 100644 crates/brain-brew-formats/src/manifest.rs create mode 100644 crates/brain-brew-formats/src/media.rs create mode 100644 devbox.json create mode 100644 devbox.lock create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rustfmt.toml 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/.gitignore b/.gitignore index 1506eb0..28dd34c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,29 @@ -# 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/ - -# Spyder project settings -.spyderproject -.spyproject +.env.* +!.env.example -# Rope project settings -.ropeproject +# Nix build outputs +/result +/result-* -# mkdocs documentation -/site +# Documentation site generated files +/documentation/.docusaurus/ +/documentation/build/ +/documentation/node_modules/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - - - -.idea/ -.directory \ No newline at end of file +# Local generated/downloaded caches +/.cache/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..665e0cc --- /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-cli" +version = "0.1.0" +dependencies = [ + "base64", + "brain-brew-core", + "brain-brew-formats", + "flate2", + "nix-nar", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", +] + +[[package]] +name = "brain-brew-core" +version = "0.1.0" + +[[package]] +name = "brain-brew-formats" +version = "0.1.0" +dependencies = [ + "brain-brew-core", + "serde", + "serde_json", + "serde_yaml", + "sha2", +] + +[[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..63bad74 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[workspace] +members = [ + "crates/brain-brew-core", + "crates/brain-brew-formats", + "crates/brain-brew-cli", +] +resolver = "3" + +[workspace.package] +version = "0.1.0" +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" diff --git a/crates/brain-brew-cli/Cargo.toml b/crates/brain-brew-cli/Cargo.toml new file mode 100644 index 0000000..bccfd79 --- /dev/null +++ b/crates/brain-brew-cli/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "brain-brew-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false + +[[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-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-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/devbox.json b/devbox.json new file mode 100644 index 0000000..31869a7 --- /dev/null +++ b/devbox.json @@ -0,0 +1,46 @@ +{ + "$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" + ], + "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" + ], + "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..81378ea --- /dev/null +++ b/devbox.lock @@ -0,0 +1,295 @@ +{ + "lockfile_version": "1", + "packages": { + "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/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..c0d929c --- /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" + "brain-brew-cli" + "--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 From 56b5e3feb0fe597fedb52d37687ed426b7b6990d Mon Sep 17 00:00:00 2001 From: Jordan Munch O'Hare Date: Mon, 25 May 2026 09:50:56 +0200 Subject: [PATCH 3/8] test: add federation fixtures and regression coverage --- crates/brain-brew-cli/tests/cli.rs | 1691 ++++ .../brain-brew-cli/tests/ug_style_fixture.rs | 158 + .../tests/canonical_deck_validation.rs | 192 + .../brain-brew-core/tests/overlay_compose.rs | 963 +++ crates/brain-brew-core/tests/semantic_diff.rs | 147 + .../tests/canonical_yaml.rs | 319 + crates/brain-brew-formats/tests/crowdanki.rs | 433 + .../brain-brew-formats/tests/lockfile_yaml.rs | 65 + .../brain-brew-formats/tests/manifest_yaml.rs | 289 + .../tests/media_references.rs | 222 + .../brain-brew-formats/tests/overlay_yaml.rs | 736 ++ .../tests/ultimate_geography_fixture.rs | 1453 ++++ fixtures/ug-style/brainbrew.yaml | 29 + fixtures/ug-style/deck.yaml | 659 ++ fixtures/ug-style/extension-population.yaml | 57 + .../ug-style/patch-netherlands-capital.yaml | 11 + fixtures/ug-style/personal-finland-hint.yaml | 9 + fixtures/ug-style/tombstone-australia.yaml | 6 + fixtures/ug-style/translation-es.yaml | 228 + fixtures/ug-style/translation-sv.yaml | 228 + fixtures/ultimate-geography/brainbrew.yaml | 581 ++ fixtures/ultimate-geography/deck.yaml | 7036 +++++++++++++++++ .../overlays/extensions/hardcore.yaml | 665 ++ .../extensions/hardcore/field-fills.yaml | 84 + .../extensions/hardcore/field-fills/de.yaml | 40 + .../extensions/hardcore/field-fills/es.yaml | 88 + .../extensions/hardcore/field-fills/fr.yaml | 64 + .../extensions/hardcore/field-fills/nb.yaml | 48 + .../extensions/hardcore/translations/cs.yaml | 22 + .../extensions/hardcore/translations/de.yaml | 79 + .../extensions/hardcore/translations/es.yaml | 81 + .../extensions/hardcore/translations/fr.yaml | 83 + .../extensions/hardcore/translations/it.yaml | 22 + .../extensions/hardcore/translations/nb.yaml | 79 + .../extensions/hardcore/translations/nl.yaml | 22 + .../extensions/hardcore/translations/pl.yaml | 22 + .../extensions/hardcore/translations/pt.yaml | 22 + .../extensions/hardcore/translations/ru.yaml | 22 + .../extensions/hardcore/translations/sv.yaml | 22 + .../extensions/hardcore/translations/zh.yaml | 22 + .../overlays/languages/cs.yaml | 811 ++ .../overlays/languages/da.yaml | 703 ++ .../overlays/languages/de.yaml | 747 ++ .../overlays/languages/es.yaml | 832 ++ .../overlays/languages/fr.yaml | 768 ++ .../overlays/languages/it.yaml | 711 ++ .../overlays/languages/nb.yaml | 705 ++ .../overlays/languages/nl.yaml | 721 ++ .../overlays/languages/pl.yaml | 798 ++ .../overlays/languages/pt.yaml | 872 ++ .../overlays/languages/ru.yaml | 1005 +++ .../overlays/languages/sv.yaml | 772 ++ .../overlays/languages/zh-tw.yaml | 1032 +++ .../overlays/languages/zh.yaml | 1032 +++ .../overlays/variants/experimental.yaml | 745 ++ .../overlays/variants/experimental/cs.yaml | 11 + .../overlays/variants/experimental/da.yaml | 11 + .../overlays/variants/experimental/de.yaml | 11 + .../overlays/variants/experimental/en.yaml | 11 + .../overlays/variants/experimental/es.yaml | 11 + .../overlays/variants/experimental/fr.yaml | 11 + .../overlays/variants/experimental/it.yaml | 11 + .../overlays/variants/experimental/nb.yaml | 11 + .../overlays/variants/experimental/nl.yaml | 11 + .../overlays/variants/experimental/pl.yaml | 11 + .../overlays/variants/experimental/pt.yaml | 11 + .../overlays/variants/experimental/ru.yaml | 11 + .../overlays/variants/experimental/sv.yaml | 11 + .../overlays/variants/experimental/zh-tw.yaml | 11 + .../overlays/variants/experimental/zh.yaml | 11 + .../overlays/variants/extended.yaml | 80 + .../overlays/variants/extended/cs.yaml | 11 + .../overlays/variants/extended/da.yaml | 11 + .../overlays/variants/extended/de.yaml | 11 + .../overlays/variants/extended/en.yaml | 11 + .../overlays/variants/extended/es.yaml | 11 + .../overlays/variants/extended/fr.yaml | 11 + .../overlays/variants/extended/it.yaml | 11 + .../overlays/variants/extended/nb.yaml | 11 + .../overlays/variants/extended/nl.yaml | 11 + .../overlays/variants/extended/pl.yaml | 11 + .../overlays/variants/extended/pt.yaml | 11 + .../overlays/variants/extended/ru.yaml | 11 + .../overlays/variants/extended/sv.yaml | 60 + .../overlays/variants/extended/zh-tw.yaml | 11 + .../overlays/variants/extended/zh.yaml | 11 + scripts/fetch_ug_release_oracle.py | 158 + 87 files changed, 29870 insertions(+) create mode 100644 crates/brain-brew-cli/tests/cli.rs create mode 100644 crates/brain-brew-cli/tests/ug_style_fixture.rs create mode 100644 crates/brain-brew-core/tests/canonical_deck_validation.rs create mode 100644 crates/brain-brew-core/tests/overlay_compose.rs create mode 100644 crates/brain-brew-core/tests/semantic_diff.rs create mode 100644 crates/brain-brew-formats/tests/canonical_yaml.rs create mode 100644 crates/brain-brew-formats/tests/crowdanki.rs create mode 100644 crates/brain-brew-formats/tests/lockfile_yaml.rs create mode 100644 crates/brain-brew-formats/tests/manifest_yaml.rs create mode 100644 crates/brain-brew-formats/tests/media_references.rs create mode 100644 crates/brain-brew-formats/tests/overlay_yaml.rs create mode 100644 crates/brain-brew-formats/tests/ultimate_geography_fixture.rs create mode 100644 fixtures/ug-style/brainbrew.yaml create mode 100644 fixtures/ug-style/deck.yaml create mode 100644 fixtures/ug-style/extension-population.yaml create mode 100644 fixtures/ug-style/patch-netherlands-capital.yaml create mode 100644 fixtures/ug-style/personal-finland-hint.yaml create mode 100644 fixtures/ug-style/tombstone-australia.yaml create mode 100644 fixtures/ug-style/translation-es.yaml create mode 100644 fixtures/ug-style/translation-sv.yaml create mode 100644 fixtures/ultimate-geography/brainbrew.yaml create mode 100644 fixtures/ultimate-geography/deck.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/de.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/es.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/fr.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/nb.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/cs.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/de.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/es.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/fr.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/it.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/nb.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/nl.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/pl.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/pt.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/ru.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/sv.yaml create mode 100644 fixtures/ultimate-geography/overlays/extensions/hardcore/translations/zh.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/cs.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/da.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/de.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/es.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/fr.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/it.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/nb.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/nl.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/pl.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/pt.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/ru.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/sv.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/zh-tw.yaml create mode 100644 fixtures/ultimate-geography/overlays/languages/zh.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/cs.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/da.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/de.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/en.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/es.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/fr.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/it.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/nb.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/nl.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/pl.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/pt.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/ru.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/sv.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/zh-tw.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/experimental/zh.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/cs.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/da.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/de.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/en.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/es.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/fr.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/it.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/nb.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/nl.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/pl.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/pt.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/ru.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/sv.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/zh-tw.yaml create mode 100644 fixtures/ultimate-geography/overlays/variants/extended/zh.yaml create mode 100755 scripts/fetch_ug_release_oracle.py 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/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/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/fixtures/ug-style/brainbrew.yaml b/fixtures/ug-style/brainbrew.yaml new file mode 100644 index 0000000..0390ec5 --- /dev/null +++ b/fixtures/ug-style/brainbrew.yaml @@ -0,0 +1,29 @@ +base: deck.yaml +overlays: + extension.population: + file: extension-population.yaml + kind: extension + patch.netherlands-capital: + file: patch-netherlands-capital.yaml + kind: patch + personal.finland-hint: + file: personal-finland-hint.yaml + kind: personal + tombstone.australia: + file: tombstone-australia.yaml + kind: patch + translation.es: + file: translation-es.yaml + kind: translation + translation.sv: + file: translation-sv.yaml + kind: translation +targets: + full-demo: + overlays: + - translation.es + - translation.sv + - extension.population + - patch.netherlands-capital + - personal.finland-hint + - tombstone.australia diff --git a/fixtures/ug-style/deck.yaml b/fixtures/ug-style/deck.yaml new file mode 100644 index 0000000..f208d73 --- /dev/null +++ b/fixtures/ug-style/deck.yaml @@ -0,0 +1,659 @@ +deck: + id: deck.ultimate-geography-fixture + name: Ultimate Geography Fixture + description: Milestone 1 Ultimate Geography-style fixture. + adapter_ids: + crowdanki:uuid: 11111111-1111-1111-1111-111111111111 +note_types: + note-type.country: + name: Country + field_order: + - field.country + - field.capital + - field.country-es + - field.capital-es + - field.country-sv + - field.capital-sv + - field.capital-hint + - field.flag + - field.map + fields: + field.capital: + name: Capital + field.capital-es: + name: Capital ES + field.capital-hint: + name: Capital hint + field.capital-sv: + name: Capital SV + field.country: + name: Country + field.country-es: + name: Country ES + field.country-sv: + name: Country SV + field.flag: + name: Flag + field.map: + name: Map + card_template_order: + - template.country-capital + - template.capital-country + - template.country-flag + - template.flag-country + - template.country-map-extended + - template.map-country-extended + card_templates: + template.capital-country: + name: Capital - Country + question_format: '{{Capital}}' + answer_format: '{{FrontSide}}
{{Country}}' + adapter_ids: {} + template.country-capital: + name: Country - Capital + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Capital}}' + adapter_ids: {} + template.country-flag: + name: Country - Flag + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Flag}}' + adapter_ids: {} + template.country-map-extended: + name: 'Country - Map [Extended]' + question_format: '{{Country}}' + answer_format: '{{FrontSide}}
{{Map}}' + adapter_ids: {} + template.flag-country: + name: Flag - Country + question_format: '{{Flag}}' + answer_format: '{{FrontSide}}
{{Country}}' + adapter_ids: {} + template.map-country-extended: + name: 'Map - Country [Extended]' + question_format: '{{Map}}' + answer_format: '{{FrontSide}}
{{Country}}' + adapter_ids: {} + styling: | + .card { font-family: sans-serif; } + .extended { color: #2255aa; } + adapter_ids: + crowdanki:uuid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa +notes: + note.australia: + note_type_id: note-type.country + fields: + field.capital: Canberra + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Australia + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Oceania + - Pacific + adapter_ids: + crowdanki:guid: ug-australia-guid + note.austria: + note_type_id: note-type.country + fields: + field.capital: Vienna + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Austria + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Central + - Europe + adapter_ids: + crowdanki:guid: ug-austria-guid + note.belgium: + note_type_id: note-type.country + fields: + field.capital: Brussels + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Belgium + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Western + adapter_ids: + crowdanki:guid: ug-belgium-guid + note.canada: + note_type_id: note-type.country + fields: + field.capital: Ottawa + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Canada + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Americas + - North America + adapter_ids: + crowdanki:guid: ug-canada-guid + note.czechia: + note_type_id: note-type.country + fields: + field.capital: Prague + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Czechia + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Central + - Europe + adapter_ids: + crowdanki:guid: ug-czechia-guid + note.denmark: + note_type_id: note-type.country + fields: + field.capital: Copenhagen + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Denmark + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-denmark-guid + note.estonia: + note_type_id: note-type.country + fields: + field.capital: Tallinn + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Estonia + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Baltic + - Europe + adapter_ids: + crowdanki:guid: ug-estonia-guid + note.finland: + note_type_id: note-type.country + fields: + field.capital: Helsinki + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Finland + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-finland-guid + note.france: + note_type_id: note-type.country + fields: + field.capital: Paris + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: France + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Western + adapter_ids: + crowdanki:guid: ug-france-guid + note.germany: + note_type_id: note-type.country + fields: + field.capital: Berlin + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Germany + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Central + - Europe + adapter_ids: + crowdanki:guid: ug-germany-guid + note.iceland: + note_type_id: note-type.country + fields: + field.capital: Reykjavik + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Iceland + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-iceland-guid + note.ireland: + note_type_id: note-type.country + fields: + field.capital: Dublin + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Ireland + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Western + adapter_ids: + crowdanki:guid: ug-ireland-guid + note.italy: + note_type_id: note-type.country + fields: + field.capital: Rome + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Italy + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Southern + adapter_ids: + crowdanki:guid: ug-italy-guid + note.japan: + note_type_id: note-type.country + fields: + field.capital: Tokyo + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Japan + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Asia + - East Asia + adapter_ids: + crowdanki:guid: ug-japan-guid + note.latvia: + note_type_id: note-type.country + fields: + field.capital: Riga + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Latvia + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Baltic + - Europe + adapter_ids: + crowdanki:guid: ug-latvia-guid + note.lithuania: + note_type_id: note-type.country + fields: + field.capital: Vilnius + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Lithuania + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Baltic + - Europe + adapter_ids: + crowdanki:guid: ug-lithuania-guid + note.mexico: + note_type_id: note-type.country + fields: + field.capital: Mexico City + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Mexico + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Americas + - North America + adapter_ids: + crowdanki:guid: ug-mexico-guid + note.netherlands: + note_type_id: note-type.country + fields: + field.capital: Amsterdam + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Netherlands + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Western + adapter_ids: + crowdanki:guid: ug-netherlands-guid + note.norway: + note_type_id: note-type.country + fields: + field.capital: Oslo + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Norway + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-norway-guid + note.poland: + note_type_id: note-type.country + fields: + field.capital: Warsaw + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Poland + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Central + - Europe + adapter_ids: + crowdanki:guid: ug-poland-guid + note.portugal: + note_type_id: note-type.country + fields: + field.capital: Lisbon + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Portugal + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Southern + adapter_ids: + crowdanki:guid: ug-portugal-guid + note.spain: + note_type_id: note-type.country + fields: + field.capital: Madrid + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Spain + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Southern + adapter_ids: + crowdanki:guid: ug-spain-guid + note.sweden: + note_type_id: note-type.country + fields: + field.capital: Stockholm + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Sweden + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Nordic + adapter_ids: + crowdanki:guid: ug-sweden-guid + note.switzerland: + note_type_id: note-type.country + fields: + field.capital: Bern + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: Switzerland + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Central + - Europe + adapter_ids: + crowdanki:guid: ug-switzerland-guid + note.united-kingdom: + note_type_id: note-type.country + fields: + field.capital: London + field.capital-es: '' + field.capital-hint: '' + field.capital-sv: '' + field.country: United Kingdom + field.country-es: '' + field.country-sv: '' + field.flag: '' + field.map: '' + tags: + - Europe + - Western + adapter_ids: + crowdanki:guid: ug-united-kingdom-guid +media: + media.flags-at-png: + path: flags/at.png + sha256: '' + media.flags-au-png: + path: flags/au.png + sha256: '' + media.flags-be-png: + path: flags/be.png + sha256: '' + media.flags-ca-png: + path: flags/ca.png + sha256: '' + media.flags-ch-png: + path: flags/ch.png + sha256: '' + media.flags-cz-png: + path: flags/cz.png + sha256: '' + media.flags-de-png: + path: flags/de.png + sha256: '' + media.flags-dk-png: + path: flags/dk.png + sha256: '' + media.flags-ee-png: + path: flags/ee.png + sha256: '' + media.flags-es-png: + path: flags/es.png + sha256: '' + media.flags-fi-png: + path: flags/fi.png + sha256: '' + media.flags-fr-png: + path: flags/fr.png + sha256: '' + media.flags-gb-png: + path: flags/gb.png + sha256: '' + media.flags-ie-png: + path: flags/ie.png + sha256: '' + media.flags-is-png: + path: flags/is.png + sha256: '' + media.flags-it-png: + path: flags/it.png + sha256: '' + media.flags-jp-png: + path: flags/jp.png + sha256: '' + media.flags-lt-png: + path: flags/lt.png + sha256: '' + media.flags-lv-png: + path: flags/lv.png + sha256: '' + media.flags-mx-png: + path: flags/mx.png + sha256: '' + media.flags-nl-png: + path: flags/nl.png + sha256: '' + media.flags-no-png: + path: flags/no.png + sha256: '' + media.flags-pl-png: + path: flags/pl.png + sha256: '' + media.flags-pt-png: + path: flags/pt.png + sha256: '' + media.flags-se-png: + path: flags/se.png + sha256: '' + media.maps-at-svg: + path: maps/at.svg + sha256: '' + media.maps-au-svg: + path: maps/au.svg + sha256: '' + media.maps-be-svg: + path: maps/be.svg + sha256: '' + media.maps-ca-svg: + path: maps/ca.svg + sha256: '' + media.maps-ch-svg: + path: maps/ch.svg + sha256: '' + media.maps-cz-svg: + path: maps/cz.svg + sha256: '' + media.maps-de-svg: + path: maps/de.svg + sha256: '' + media.maps-dk-svg: + path: maps/dk.svg + sha256: '' + media.maps-ee-svg: + path: maps/ee.svg + sha256: '' + media.maps-es-svg: + path: maps/es.svg + sha256: '' + media.maps-fi-svg: + path: maps/fi.svg + sha256: '' + media.maps-fr-svg: + path: maps/fr.svg + sha256: '' + media.maps-gb-svg: + path: maps/gb.svg + sha256: '' + media.maps-ie-svg: + path: maps/ie.svg + sha256: '' + media.maps-is-svg: + path: maps/is.svg + sha256: '' + media.maps-it-svg: + path: maps/it.svg + sha256: '' + media.maps-jp-svg: + path: maps/jp.svg + sha256: '' + media.maps-lt-svg: + path: maps/lt.svg + sha256: '' + media.maps-lv-svg: + path: maps/lv.svg + sha256: '' + media.maps-mx-svg: + path: maps/mx.svg + sha256: '' + media.maps-nl-svg: + path: maps/nl.svg + sha256: '' + media.maps-no-svg: + path: maps/no.svg + sha256: '' + media.maps-pl-svg: + path: maps/pl.svg + sha256: '' + media.maps-pt-svg: + path: maps/pt.svg + sha256: '' + media.maps-se-svg: + path: maps/se.svg + sha256: '' +tombstones: [] diff --git a/fixtures/ug-style/extension-population.yaml b/fixtures/ug-style/extension-population.yaml new file mode 100644 index 0000000..c4ce202 --- /dev/null +++ b/fixtures/ug-style/extension-population.yaml @@ -0,0 +1,57 @@ +id: overlay.extension.population +kind: extension +field_additions: + note-type.country: + fields: + field.population: Population + values: + note.australia: + field.population: 25.0 million + note.austria: + field.population: 16.0 million + note.belgium: + field.population: 19.0 million + note.canada: + field.population: 22.0 million + note.czechia: + field.population: 15.0 million + note.denmark: + field.population: 4.0 million + note.estonia: + field.population: 6.0 million + note.finland: + field.population: 1.0 million + note.france: + field.population: 10.0 million + note.germany: + field.population: 9.0 million + note.iceland: + field.population: 5.0 million + note.ireland: + field.population: 20.0 million + note.italy: + field.population: 13.0 million + note.japan: + field.population: 24.0 million + note.latvia: + field.population: 7.0 million + note.lithuania: + field.population: 8.0 million + note.mexico: + field.population: 23.0 million + note.netherlands: + field.population: 18.0 million + note.norway: + field.population: 3.0 million + note.poland: + field.population: 14.0 million + note.portugal: + field.population: 12.0 million + note.spain: + field.population: 11.0 million + note.sweden: + field.population: 2.0 million + note.switzerland: + field.population: 17.0 million + note.united-kingdom: + field.population: 21.0 million diff --git a/fixtures/ug-style/patch-netherlands-capital.yaml b/fixtures/ug-style/patch-netherlands-capital.yaml new file mode 100644 index 0000000..7e0003e --- /dev/null +++ b/fixtures/ug-style/patch-netherlands-capital.yaml @@ -0,0 +1,11 @@ +id: overlay.patch.netherlands-capital +kind: patch +notes: + note.netherlands: + intent: merge + fields: + field.capital: + intent: replace + value: 'Amsterdam (constitutional capital)' + expected_base: + value: Amsterdam diff --git a/fixtures/ug-style/personal-finland-hint.yaml b/fixtures/ug-style/personal-finland-hint.yaml new file mode 100644 index 0000000..667481b --- /dev/null +++ b/fixtures/ug-style/personal-finland-hint.yaml @@ -0,0 +1,9 @@ +id: overlay.personal.finland-hint +kind: personal +notes: + note.finland: + intent: merge + fields: + field.capital-hint: + intent: merge + value: Starts with H diff --git a/fixtures/ug-style/tombstone-australia.yaml b/fixtures/ug-style/tombstone-australia.yaml new file mode 100644 index 0000000..612e5a7 --- /dev/null +++ b/fixtures/ug-style/tombstone-australia.yaml @@ -0,0 +1,6 @@ +id: overlay.patch.remove-australia +kind: patch +notes: + note.australia: + intent: remove + expected_base: entity_present diff --git a/fixtures/ug-style/translation-es.yaml b/fixtures/ug-style/translation-es.yaml new file mode 100644 index 0000000..4ed853e --- /dev/null +++ b/fixtures/ug-style/translation-es.yaml @@ -0,0 +1,228 @@ +id: overlay.translation.es +kind: translation +notes: + note.australia: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Canberra (ES)' + field.country-es: + intent: merge + value: 'Australia (ES)' + note.austria: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Vienna (ES)' + field.country-es: + intent: merge + value: 'Austria (ES)' + note.belgium: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Brussels (ES)' + field.country-es: + intent: merge + value: 'Belgium (ES)' + note.canada: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Ottawa (ES)' + field.country-es: + intent: merge + value: 'Canada (ES)' + note.czechia: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Prague (ES)' + field.country-es: + intent: merge + value: 'Czechia (ES)' + note.denmark: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Copenhagen (ES)' + field.country-es: + intent: merge + value: 'Denmark (ES)' + note.estonia: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Tallinn (ES)' + field.country-es: + intent: merge + value: 'Estonia (ES)' + note.finland: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Helsinki (ES)' + field.country-es: + intent: merge + value: 'Finland (ES)' + note.france: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Paris (ES)' + field.country-es: + intent: merge + value: 'France (ES)' + note.germany: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Berlin (ES)' + field.country-es: + intent: merge + value: 'Germany (ES)' + note.iceland: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Reykjavik (ES)' + field.country-es: + intent: merge + value: 'Iceland (ES)' + note.ireland: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Dublin (ES)' + field.country-es: + intent: merge + value: 'Ireland (ES)' + note.italy: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Rome (ES)' + field.country-es: + intent: merge + value: 'Italy (ES)' + note.japan: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Tokyo (ES)' + field.country-es: + intent: merge + value: 'Japan (ES)' + note.latvia: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Riga (ES)' + field.country-es: + intent: merge + value: 'Latvia (ES)' + note.lithuania: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Vilnius (ES)' + field.country-es: + intent: merge + value: 'Lithuania (ES)' + note.mexico: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Mexico City (ES)' + field.country-es: + intent: merge + value: 'Mexico (ES)' + note.netherlands: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Amsterdam (ES)' + field.country-es: + intent: merge + value: 'Netherlands (ES)' + note.norway: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Oslo (ES)' + field.country-es: + intent: merge + value: 'Norway (ES)' + note.poland: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Warsaw (ES)' + field.country-es: + intent: merge + value: 'Poland (ES)' + note.portugal: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Lisbon (ES)' + field.country-es: + intent: merge + value: 'Portugal (ES)' + note.spain: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Madrid (ES)' + field.country-es: + intent: merge + value: 'Spain (ES)' + note.sweden: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Stockholm (ES)' + field.country-es: + intent: merge + value: 'Sweden (ES)' + note.switzerland: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'Bern (ES)' + field.country-es: + intent: merge + value: 'Switzerland (ES)' + note.united-kingdom: + intent: merge + fields: + field.capital-es: + intent: merge + value: 'London (ES)' + field.country-es: + intent: merge + value: 'United Kingdom (ES)' diff --git a/fixtures/ug-style/translation-sv.yaml b/fixtures/ug-style/translation-sv.yaml new file mode 100644 index 0000000..e3ec8e8 --- /dev/null +++ b/fixtures/ug-style/translation-sv.yaml @@ -0,0 +1,228 @@ +id: overlay.translation.sv +kind: translation +notes: + note.australia: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Canberra (SV)' + field.country-sv: + intent: merge + value: 'Australia (SV)' + note.austria: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Vienna (SV)' + field.country-sv: + intent: merge + value: 'Austria (SV)' + note.belgium: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Brussels (SV)' + field.country-sv: + intent: merge + value: 'Belgium (SV)' + note.canada: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Ottawa (SV)' + field.country-sv: + intent: merge + value: 'Canada (SV)' + note.czechia: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Prague (SV)' + field.country-sv: + intent: merge + value: 'Czechia (SV)' + note.denmark: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Copenhagen (SV)' + field.country-sv: + intent: merge + value: 'Denmark (SV)' + note.estonia: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Tallinn (SV)' + field.country-sv: + intent: merge + value: 'Estonia (SV)' + note.finland: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Helsinki (SV)' + field.country-sv: + intent: merge + value: 'Finland (SV)' + note.france: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Paris (SV)' + field.country-sv: + intent: merge + value: 'France (SV)' + note.germany: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Berlin (SV)' + field.country-sv: + intent: merge + value: 'Germany (SV)' + note.iceland: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Reykjavik (SV)' + field.country-sv: + intent: merge + value: 'Iceland (SV)' + note.ireland: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Dublin (SV)' + field.country-sv: + intent: merge + value: 'Ireland (SV)' + note.italy: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Rome (SV)' + field.country-sv: + intent: merge + value: 'Italy (SV)' + note.japan: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Tokyo (SV)' + field.country-sv: + intent: merge + value: 'Japan (SV)' + note.latvia: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Riga (SV)' + field.country-sv: + intent: merge + value: 'Latvia (SV)' + note.lithuania: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Vilnius (SV)' + field.country-sv: + intent: merge + value: 'Lithuania (SV)' + note.mexico: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Mexico City (SV)' + field.country-sv: + intent: merge + value: 'Mexico (SV)' + note.netherlands: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Amsterdam (SV)' + field.country-sv: + intent: merge + value: 'Netherlands (SV)' + note.norway: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Oslo (SV)' + field.country-sv: + intent: merge + value: 'Norway (SV)' + note.poland: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Warsaw (SV)' + field.country-sv: + intent: merge + value: 'Poland (SV)' + note.portugal: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Lisbon (SV)' + field.country-sv: + intent: merge + value: 'Portugal (SV)' + note.spain: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Madrid (SV)' + field.country-sv: + intent: merge + value: 'Spain (SV)' + note.sweden: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Stockholm (SV)' + field.country-sv: + intent: merge + value: 'Sweden (SV)' + note.switzerland: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'Bern (SV)' + field.country-sv: + intent: merge + value: 'Switzerland (SV)' + note.united-kingdom: + intent: merge + fields: + field.capital-sv: + intent: merge + value: 'London (SV)' + field.country-sv: + intent: merge + value: 'United Kingdom (SV)' diff --git a/fixtures/ultimate-geography/brainbrew.yaml b/fixtures/ultimate-geography/brainbrew.yaml new file mode 100644 index 0000000..a14c2cf --- /dev/null +++ b/fixtures/ultimate-geography/brainbrew.yaml @@ -0,0 +1,581 @@ +package: + id: anki-geo.ultimate-geography + version: 0.1.0 + compatible_base_versions: + - '>=0.1,<0.2' +base: deck.yaml +overlays: + overlay.extension.hardcore: + file: overlays/extensions/hardcore.yaml + kind: extension + overlay.extension.hardcore.field-fills: + file: overlays/extensions/hardcore/field-fills.yaml + kind: extension + depends_on: + - overlay.extension.hardcore + overlay.extension.hardcore.field-fills.de: + file: overlays/extensions/hardcore/field-fills/de.yaml + kind: extension + depends_on: + - overlay.translation.hardcore.de + - overlay.extension.hardcore.field-fills + overlay.extension.hardcore.field-fills.es: + file: overlays/extensions/hardcore/field-fills/es.yaml + kind: extension + depends_on: + - overlay.translation.hardcore.es + - overlay.extension.hardcore.field-fills + overlay.extension.hardcore.field-fills.fr: + file: overlays/extensions/hardcore/field-fills/fr.yaml + kind: extension + depends_on: + - overlay.translation.hardcore.fr + - overlay.extension.hardcore.field-fills + overlay.extension.hardcore.field-fills.nb: + file: overlays/extensions/hardcore/field-fills/nb.yaml + kind: extension + depends_on: + - overlay.translation.hardcore.nb + - overlay.extension.hardcore.field-fills + overlay.translation.cs: + file: overlays/languages/cs.yaml + kind: translation + overlay.translation.da: + file: overlays/languages/da.yaml + kind: translation + overlay.translation.de: + file: overlays/languages/de.yaml + kind: translation + overlay.translation.es: + file: overlays/languages/es.yaml + kind: translation + overlay.translation.fr: + file: overlays/languages/fr.yaml + kind: translation + overlay.translation.hardcore.cs: + file: overlays/extensions/hardcore/translations/cs.yaml + kind: translation + depends_on: + - overlay.translation.cs + - overlay.extension.hardcore + overlay.translation.hardcore.de: + file: overlays/extensions/hardcore/translations/de.yaml + kind: translation + depends_on: + - overlay.translation.de + - overlay.extension.hardcore + overlay.translation.hardcore.es: + file: overlays/extensions/hardcore/translations/es.yaml + kind: translation + depends_on: + - overlay.translation.es + - overlay.extension.hardcore + overlay.translation.hardcore.fr: + file: overlays/extensions/hardcore/translations/fr.yaml + kind: translation + depends_on: + - overlay.translation.fr + - overlay.extension.hardcore + overlay.translation.hardcore.it: + file: overlays/extensions/hardcore/translations/it.yaml + kind: translation + depends_on: + - overlay.translation.it + - overlay.extension.hardcore + overlay.translation.hardcore.nb: + file: overlays/extensions/hardcore/translations/nb.yaml + kind: translation + depends_on: + - overlay.translation.nb + - overlay.extension.hardcore + overlay.translation.hardcore.nl: + file: overlays/extensions/hardcore/translations/nl.yaml + kind: translation + depends_on: + - overlay.translation.nl + - overlay.extension.hardcore + overlay.translation.hardcore.pl: + file: overlays/extensions/hardcore/translations/pl.yaml + kind: translation + depends_on: + - overlay.translation.pl + - overlay.extension.hardcore + overlay.translation.hardcore.pt: + file: overlays/extensions/hardcore/translations/pt.yaml + kind: translation + depends_on: + - overlay.translation.pt + - overlay.extension.hardcore + overlay.translation.hardcore.ru: + file: overlays/extensions/hardcore/translations/ru.yaml + kind: translation + depends_on: + - overlay.translation.ru + - overlay.extension.hardcore + overlay.translation.hardcore.sv: + file: overlays/extensions/hardcore/translations/sv.yaml + kind: translation + depends_on: + - overlay.translation.sv + - overlay.extension.hardcore + overlay.translation.hardcore.zh: + file: overlays/extensions/hardcore/translations/zh.yaml + kind: translation + depends_on: + - overlay.translation.zh + - overlay.extension.hardcore + overlay.translation.it: + file: overlays/languages/it.yaml + kind: translation + overlay.translation.nb: + file: overlays/languages/nb.yaml + kind: translation + overlay.translation.nl: + file: overlays/languages/nl.yaml + kind: translation + overlay.translation.pl: + file: overlays/languages/pl.yaml + kind: translation + overlay.translation.pt: + file: overlays/languages/pt.yaml + kind: translation + overlay.translation.ru: + file: overlays/languages/ru.yaml + kind: translation + overlay.translation.sv: + file: overlays/languages/sv.yaml + kind: translation + overlay.translation.zh: + file: overlays/languages/zh.yaml + kind: translation + overlay.translation.zh-tw: + file: overlays/languages/zh-tw.yaml + kind: translation + overlay.variant.experimental: + file: overlays/variants/experimental.yaml + kind: extension + depends_on: + - overlay.variant.extended + overlay.variant.experimental.cs: + file: overlays/variants/experimental/cs.yaml + kind: extension + depends_on: + - overlay.translation.cs + - overlay.variant.experimental + overlay.variant.experimental.da: + file: overlays/variants/experimental/da.yaml + kind: extension + depends_on: + - overlay.translation.da + - overlay.variant.experimental + overlay.variant.experimental.de: + file: overlays/variants/experimental/de.yaml + kind: extension + depends_on: + - overlay.translation.de + - overlay.variant.experimental + overlay.variant.experimental.en: + file: overlays/variants/experimental/en.yaml + kind: extension + depends_on: + - overlay.variant.experimental + overlay.variant.experimental.es: + file: overlays/variants/experimental/es.yaml + kind: extension + depends_on: + - overlay.translation.es + - overlay.variant.experimental + overlay.variant.experimental.fr: + file: overlays/variants/experimental/fr.yaml + kind: extension + depends_on: + - overlay.translation.fr + - overlay.variant.experimental + overlay.variant.experimental.it: + file: overlays/variants/experimental/it.yaml + kind: extension + depends_on: + - overlay.translation.it + - overlay.variant.experimental + overlay.variant.experimental.nb: + file: overlays/variants/experimental/nb.yaml + kind: extension + depends_on: + - overlay.translation.nb + - overlay.variant.experimental + overlay.variant.experimental.nl: + file: overlays/variants/experimental/nl.yaml + kind: extension + depends_on: + - overlay.translation.nl + - overlay.variant.experimental + overlay.variant.experimental.pl: + file: overlays/variants/experimental/pl.yaml + kind: extension + depends_on: + - overlay.translation.pl + - overlay.variant.experimental + overlay.variant.experimental.pt: + file: overlays/variants/experimental/pt.yaml + kind: extension + depends_on: + - overlay.translation.pt + - overlay.variant.experimental + overlay.variant.experimental.ru: + file: overlays/variants/experimental/ru.yaml + kind: extension + depends_on: + - overlay.translation.ru + - overlay.variant.experimental + overlay.variant.experimental.sv: + file: overlays/variants/experimental/sv.yaml + kind: extension + depends_on: + - overlay.translation.sv + - overlay.variant.experimental + overlay.variant.experimental.zh: + file: overlays/variants/experimental/zh.yaml + kind: extension + depends_on: + - overlay.translation.zh + - overlay.variant.experimental + overlay.variant.experimental.zh-tw: + file: overlays/variants/experimental/zh-tw.yaml + kind: extension + depends_on: + - overlay.translation.zh-tw + - overlay.variant.experimental + overlay.variant.extended: + file: overlays/variants/extended.yaml + kind: extension + overlay.variant.extended.cs: + file: overlays/variants/extended/cs.yaml + kind: extension + depends_on: + - overlay.translation.cs + - overlay.variant.extended + overlay.variant.extended.da: + file: overlays/variants/extended/da.yaml + kind: extension + depends_on: + - overlay.translation.da + - overlay.variant.extended + overlay.variant.extended.de: + file: overlays/variants/extended/de.yaml + kind: extension + depends_on: + - overlay.translation.de + - overlay.variant.extended + overlay.variant.extended.en: + file: overlays/variants/extended/en.yaml + kind: extension + depends_on: + - overlay.variant.extended + overlay.variant.extended.es: + file: overlays/variants/extended/es.yaml + kind: extension + depends_on: + - overlay.translation.es + - overlay.variant.extended + overlay.variant.extended.fr: + file: overlays/variants/extended/fr.yaml + kind: extension + depends_on: + - overlay.translation.fr + - overlay.variant.extended + overlay.variant.extended.it: + file: overlays/variants/extended/it.yaml + kind: extension + depends_on: + - overlay.translation.it + - overlay.variant.extended + overlay.variant.extended.nb: + file: overlays/variants/extended/nb.yaml + kind: extension + depends_on: + - overlay.translation.nb + - overlay.variant.extended + overlay.variant.extended.nl: + file: overlays/variants/extended/nl.yaml + kind: extension + depends_on: + - overlay.translation.nl + - overlay.variant.extended + overlay.variant.extended.pl: + file: overlays/variants/extended/pl.yaml + kind: extension + depends_on: + - overlay.translation.pl + - overlay.variant.extended + overlay.variant.extended.pt: + file: overlays/variants/extended/pt.yaml + kind: extension + depends_on: + - overlay.translation.pt + - overlay.variant.extended + overlay.variant.extended.ru: + file: overlays/variants/extended/ru.yaml + kind: extension + depends_on: + - overlay.translation.ru + - overlay.variant.extended + overlay.variant.extended.sv: + file: overlays/variants/extended/sv.yaml + kind: extension + depends_on: + - overlay.translation.sv + - overlay.variant.extended + overlay.variant.extended.zh: + file: overlays/variants/extended/zh.yaml + kind: extension + depends_on: + - overlay.translation.zh + - overlay.variant.extended + overlay.variant.extended.zh-tw: + file: overlays/variants/extended/zh-tw.yaml + kind: extension + depends_on: + - overlay.translation.zh-tw + - overlay.variant.extended +targets: + cs-experimental: + overlays: + - overlay.variant.experimental.cs + cs-extended: + overlays: + - overlay.variant.extended.cs + cs-hardcore-extended: + overlays: + - overlay.variant.extended.cs + - overlay.translation.hardcore.cs + - overlay.extension.hardcore.field-fills + cs-hardcore-standard: + overlays: + - overlay.translation.hardcore.cs + - overlay.extension.hardcore.field-fills + cs-standard: + overlays: + - overlay.translation.cs + da-experimental: + overlays: + - overlay.variant.experimental.da + da-extended: + overlays: + - overlay.variant.extended.da + da-standard: + overlays: + - overlay.translation.da + de-experimental: + overlays: + - overlay.variant.experimental.de + de-extended: + overlays: + - overlay.variant.extended.de + de-hardcore-extended: + overlays: + - overlay.variant.extended.de + - overlay.extension.hardcore.field-fills.de + de-hardcore-standard: + overlays: + - overlay.extension.hardcore.field-fills.de + de-standard: + overlays: + - overlay.translation.de + en-experimental: + overlays: + - overlay.variant.experimental.en + en-extended: + overlays: + - overlay.variant.extended.en + en-hardcore-extended: + overlays: + - overlay.variant.extended.en + - overlay.extension.hardcore.field-fills + en-hardcore-standard: + overlays: + - overlay.extension.hardcore.field-fills + en-standard: + overlays: [] + es-experimental: + overlays: + - overlay.variant.experimental.es + es-extended: + overlays: + - overlay.variant.extended.es + es-hardcore-extended: + overlays: + - overlay.variant.extended.es + - overlay.extension.hardcore.field-fills.es + es-hardcore-standard: + overlays: + - overlay.extension.hardcore.field-fills.es + es-standard: + overlays: + - overlay.translation.es + fr-experimental: + overlays: + - overlay.variant.experimental.fr + fr-extended: + overlays: + - overlay.variant.extended.fr + fr-hardcore-extended: + overlays: + - overlay.variant.extended.fr + - overlay.extension.hardcore.field-fills.fr + fr-hardcore-standard: + overlays: + - overlay.extension.hardcore.field-fills.fr + fr-standard: + overlays: + - overlay.translation.fr + it-experimental: + overlays: + - overlay.variant.experimental.it + it-extended: + overlays: + - overlay.variant.extended.it + it-hardcore-extended: + overlays: + - overlay.variant.extended.it + - overlay.translation.hardcore.it + - overlay.extension.hardcore.field-fills + it-hardcore-standard: + overlays: + - overlay.translation.hardcore.it + - overlay.extension.hardcore.field-fills + it-standard: + overlays: + - overlay.translation.it + nb-experimental: + overlays: + - overlay.variant.experimental.nb + nb-extended: + overlays: + - overlay.variant.extended.nb + nb-hardcore-extended: + overlays: + - overlay.variant.extended.nb + - overlay.extension.hardcore.field-fills.nb + nb-hardcore-standard: + overlays: + - overlay.extension.hardcore.field-fills.nb + nb-standard: + overlays: + - overlay.translation.nb + nl-experimental: + overlays: + - overlay.variant.experimental.nl + nl-extended: + overlays: + - overlay.variant.extended.nl + nl-hardcore-extended: + overlays: + - overlay.variant.extended.nl + - overlay.translation.hardcore.nl + - overlay.extension.hardcore.field-fills + nl-hardcore-standard: + overlays: + - overlay.translation.hardcore.nl + - overlay.extension.hardcore.field-fills + nl-standard: + overlays: + - overlay.translation.nl + pl-experimental: + overlays: + - overlay.variant.experimental.pl + pl-extended: + overlays: + - overlay.variant.extended.pl + pl-hardcore-extended: + overlays: + - overlay.variant.extended.pl + - overlay.translation.hardcore.pl + - overlay.extension.hardcore.field-fills + pl-hardcore-standard: + overlays: + - overlay.translation.hardcore.pl + - overlay.extension.hardcore.field-fills + pl-standard: + overlays: + - overlay.translation.pl + pt-experimental: + overlays: + - overlay.variant.experimental.pt + pt-extended: + overlays: + - overlay.variant.extended.pt + pt-hardcore-extended: + overlays: + - overlay.variant.extended.pt + - overlay.translation.hardcore.pt + - overlay.extension.hardcore.field-fills + pt-hardcore-standard: + overlays: + - overlay.translation.hardcore.pt + - overlay.extension.hardcore.field-fills + pt-standard: + overlays: + - overlay.translation.pt + ru-experimental: + overlays: + - overlay.variant.experimental.ru + ru-extended: + overlays: + - overlay.variant.extended.ru + ru-hardcore-extended: + overlays: + - overlay.variant.extended.ru + - overlay.translation.hardcore.ru + - overlay.extension.hardcore.field-fills + ru-hardcore-standard: + overlays: + - overlay.translation.hardcore.ru + - overlay.extension.hardcore.field-fills + ru-standard: + overlays: + - overlay.translation.ru + sv-experimental: + overlays: + - overlay.variant.experimental.sv + sv-extended: + overlays: + - overlay.variant.extended.sv + sv-hardcore-extended: + overlays: + - overlay.variant.extended.sv + - overlay.translation.hardcore.sv + - overlay.extension.hardcore.field-fills + sv-hardcore-standard: + overlays: + - overlay.translation.hardcore.sv + - overlay.extension.hardcore.field-fills + sv-standard: + overlays: + - overlay.translation.sv + zh-experimental: + overlays: + - overlay.variant.experimental.zh + zh-extended: + overlays: + - overlay.variant.extended.zh + zh-hardcore-extended: + overlays: + - overlay.variant.extended.zh + - overlay.translation.hardcore.zh + - overlay.extension.hardcore.field-fills + zh-hardcore-standard: + overlays: + - overlay.translation.hardcore.zh + - overlay.extension.hardcore.field-fills + zh-standard: + overlays: + - overlay.translation.zh + zh-tw-experimental: + overlays: + - overlay.variant.experimental.zh-tw + zh-tw-extended: + overlays: + - overlay.variant.extended.zh-tw + zh-tw-standard: + overlays: + - overlay.translation.zh-tw diff --git a/fixtures/ultimate-geography/deck.yaml b/fixtures/ultimate-geography/deck.yaml new file mode 100644 index 0000000..202f954 --- /dev/null +++ b/fixtures/ultimate-geography/deck.yaml @@ -0,0 +1,7036 @@ +deck: + id: deck.ultimate-geography + name: Ultimate Geography + description: | + 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! + adapter_ids: + crowdanki:deck_config_name: Ultimate Geography + crowdanki:deck_config_uuid: 43d37fac-9a65-11e8-9cc5-a0481cc15658 + crowdanki:uuid: 43c5ba66-9a65-11e8-90c9-a0481cc15658 +note_types: + note-type.ultimate-geography: + name: '${note-type.name}${variant.name-suffix}' + variables: + label.capital: Capital + label.capital-hint.answer: 'Hint: {{Capital hint}}' + label.capital-hint.question: 'Hint: {{Capital hint}}' + label.flag: Flag + label.location: Location + note-type.name: Ultimate Geography + sentence.flag-similar: 'Flag similar to {{Flag similarity}}.' + variant.name-suffix: '' + field_order: + - field.country + - field.country-info + - field.capital + - field.capital-info + - field.capital-hint + - field.flag + - field.flag-similarity + - field.map + fields: + field.capital: + name: Capital + field.capital-hint: + name: Capital hint + field.capital-info: + name: Capital info + field.country: + name: Country + field.country-info: + name: Country info + field.flag: + name: Flag + field.flag-similarity: + name: Flag similarity + field.map: + name: Map + card_template_order: + - template.country-capital + - template.capital-country + - template.flag-country + - template.map-country + card_templates: + template.capital-country: + name: Capital - Country + question_format: |- + {{#Capital}} +
?
+ +
+ +
${label.capital}
+
{{Capital}}
+ {{#Capital hint}}
${label.capital-hint.question}
{{/Capital hint}} + {{/Capital}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.capital}
+
{{Capital}}
+ {{#Capital hint}}
${label.capital-hint.answer}
{{/Capital hint}} + {{#Capital info}}
{{Capital info}}
{{/Capital info}} + adapter_ids: {} + template.country-capital: + name: Country - Capital + question_format: |- + {{#Capital}} +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.capital}
+
?
+ {{/Capital}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.capital}
+
{{Capital}}
+ {{#Capital info}}
{{Capital info}}
{{/Capital info}} + adapter_ids: {} + template.flag-country: + name: Flag - Country + question_format: |- + {{#Flag}} +
?
+ +
+ +
${label.flag}
+
{{Flag}}
+ {{/Flag}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.flag}
+
{{Flag}}
+ {{#Flag similarity}}
${sentence.flag-similar}
{{/Flag similarity}} + adapter_ids: {} + template.map-country: + name: Map - Country + question_format: |- + {{#Map}} +
?
+ +
+ +
${label.location}
+
{{Map}}
+ {{/Map}} + answer_format: | +
{{Country}}
+ {{#Country info}}
{{Country info}}
{{/Country info}} + +
+ +
${label.location}
+
{{Map}}
+ adapter_ids: {} + styling: | + .card { + padding: 1em 0; + background-color: white; + color: black; + font-family: Verdana; + font-size: 16px; + text-align: center; + } + + .type { + margin-bottom: 0.25em; + color: #333; + font-size: 70%; + font-weight: bold; + text-transform: uppercase; + } + + .info { + max-width: 30em; + margin: 0.75em auto; + color: #333; + font-size: 90%; + font-style: italic; + } + + .value { + font-size: 150%; + } + + .value--top { + margin-top: 1em; + } + + .value--image { + margin-top: 0.75em; + } + + .value--map { + width: 90%; + height: 65vh; + margin: 2em auto 0 auto; + } + + .value > img, + .value > .placeholder { + max-width: 100%; + height: auto; + } + + /** + * Apply shadow to images, notably to bring out white areas on flags. + * Ignore images with non-rectangular outlines (e.g. flag of Nepal). + */ + .value > img:not([src*="-nobox"]) { + box-shadow: 0 1px 4px 1px rgba(0, 0, 0, 0.2); + } + + /** + * Some flags (e.g. Guam's) contain identifying words that can give away the answer. + * If a blurred version is available, show it on the front but not on the back. + */ + .value--front > img[src*="-blur"] + img { + display: none; + } + + .value--back > img[src*="-blur"] { + display: none; + } + + /** + * Placeholder SVG to hint at the type of answer that is expected. + * Used on "Country - Flag" and "Country - Map" templates. + */ + .placeholder { + color: #333; + } + + .placeholder > path { + fill: none; + stroke: currentColor; + stroke-width: 1; + } + + .night_mode .info, + .night_mode .type, + .night_mode .placeholder, + .nightMode .info, + .nightMode .type, + .nightMode .placeholder { + color: #ccc; + } + + /** + * Apply shadow to images, to bring out black areas on flags, in night + mode. + */ + .nightMode .value > img:not([src*="-nobox"]), + .night_mode .value > img:not([src*="-nobox"]) { + box-shadow: 0 0 4px 1px rgba(54, 54, 54, 0.9); + } + + hr { + margin: 1.5em 0; + } + adapter_ids: + crowdanki:uuid: 43e2586a-9a65-11e8-a777-a0481cc15658 +notes: + note.abkhazia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sukhumi + field.capital-hint: '' + field.capital-info: '' + field.country: Abkhazia + field.country-info: Independent state claimed by Georgia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sRw-ik:I$v' + note.adriatic-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Adriatic Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'yX:tmrd5;]' + note.aegean-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Aegean Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'iB-8Br~onq' + note.afghanistan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kabul + field.capital-hint: '' + field.capital-info: '' + field.country: Afghanistan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm5j,b/y' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'pwwZu{7f~(' + note.akrotiri-and-dhekelia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Akrotiri and Dhekelia + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + adapter_ids: + crowdanki:guid: 'wa>{k!2cXc' + note.alaska: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Alaska + field.country-info: State of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'ssbyA9}]QP' + note.albania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tirana + field.capital-hint: '' + field.capital-info: '' + field.country: Albania + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'gz9${p}er*' + note.algeria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Algiers + field.capital-hint: '' + field.capital-info: '' + field.country: Algeria + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'bd~Ght5tLR' + note.american-samoa: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: American Samoa + field.country-info: Unincorporated territory of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'niJUFZF$Y:' + note.andorra: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Andorra la Vella + field.capital-hint: '' + field.capital-info: '' + field.country: Andorra + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Moldova (wider, coat of arms with eagle)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'dY96`C-f(_' + note.angola: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Luanda + field.capital-hint: '' + field.capital-info: '' + field.country: Angola + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'onMKID2d;D' + note.anguilla: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Anguilla + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'e' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'Cf2@;p' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm2F|cUkKhv' + note.arabian-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Arabian Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'r(h9&#wc~B' + note.aral-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Aral Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'qiv/4tOlf=' + note.arctic-ocean: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Arctic Ocean + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'gG6SWfM>H0' + note.argentina: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Buenos Aires + field.capital-hint: '' + field.capital-info: '' + field.country: Argentina + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'n&>e`2FO/S' + note.armenia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Yerevan + field.capital-hint: '' + field.capital-info: '' + field.country: Armenia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'c(,2{yXI#E' + note.aruba: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Oranjestad + field.capital-hint: '' + field.capital-info: '' + field.country: Aruba + field.country-info: Constituent country of the Kingdom of the Netherlands. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'uv1fGXHN@)' + note.asia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Asia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'Qlib4)q2Hk' + note.atlantic-ocean: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Atlantic Ocean + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'nuU8A_$i2M' + note.australia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Canberra + field.capital-hint: '' + field.capital-info: '' + field.country: Australia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'New Zealand (red stars, two fewer stars)' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k0{O[6l]bH' + note.austria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Vienna + field.capital-hint: '' + field.capital-info: '' + field.country: Austria + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Latvia (darker red, narrower white band)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r>aI-$$|wT' + note.azerbaijan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Baku + field.capital-hint: '' + field.capital-info: '' + field.country: Azerbaijan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k9E.p^' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'j=L,YBvsBk' + note.bahrain: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Manama + field.capital-hint: '' + field.capital-info: '' + field.country: Bahrain + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Qatar (wider, more serrated edges, maroon)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'stRa/S(0$F' + note.bali: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bali + field.country-info: Island of Indonesia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: 'O~pXkAbd3' + note.balkan-peninsula: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Balkan Peninsula + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'zSc]-^U=0=' + note.baltic-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Baltic Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'epvt3>HH?U' + note.banda-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Banda Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'kopwzzB``A' + note.bangladesh: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dhaka + field.capital-hint: '' + field.capital-info: '' + field.country: Bangladesh + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l+>Ki!j#v+' + note.barbados: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bridgetown + field.capital-hint: '' + field.capital-info: '' + field.country: Barbados + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e{e8vi@^PY' + note.barents-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Barents Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'h9Jr(H[=1~' + note.bay-of-bengal: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bay of Bengal + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'z:*d{z(~V2' + note.bay-of-biscay: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bay of Biscay + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'H*c[ML+`s=' + note.belarus: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Minsk + field.capital-hint: '' + field.capital-info: '' + field.country: Belarus + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l$zM~ihPFE' + note.belgium: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Brussels + field.capital-hint: '' + field.capital-info: '' + field.country: Belgium + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jTNoKo}Bu+' + note.belize: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Belmopan + field.capital-hint: '' + field.capital-info: '' + field.country: Belize + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'tcneuK7v%`' + note.benin: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Porto-Novo + field.capital-hint: '' + field.capital-info: While Porto-Novo is the official capital, Cotonou is the de facto seat of government. + field.country: Benin + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'q8fF$4,c6_' + note.bering-strait: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bering Strait + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'J%,fuysl*&' + note.bermuda: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bermuda + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'gBCR?!O*;E' + note.bhutan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Thimphu + field.capital-hint: '' + field.capital-info: '' + field.country: Bhutan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'kQ:NIhJy~E' + note.black-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Black Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'e_;dzXj8de' + note.bolivia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sucre + field.capital-hint: '' + field.capital-info: While Sucre is the constitutional capital, La Paz is the seat of government. + field.country: Bolivia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Ghana (star instead of coat of arms)' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'khB~(m^@Z5' + note.bosnia-and-herzegovina: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sarajevo + field.capital-hint: '' + field.capital-info: '' + field.country: Bosnia and Herzegovina + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mz[>>AxIr4' + note.botswana: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Gaborone + field.capital-hint: '' + field.capital-info: '' + field.country: Botswana + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: upiX0g1quR + note.bougainville: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Bougainville + field.country-info: Autonomous region of Papua New Guinea. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'vKx<0Qa!aP' + note.brazil: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Brasília' + field.capital-hint: '' + field.capital-info: '' + field.country: Brazil + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: spKoFRM4if + note.british-virgin-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: British Virgin Islands + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'q[}7hk(+=e' + note.brunei: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bandar Seri Begawan + field.capital-hint: '' + field.capital-info: '' + field.country: Brunei + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sY>@9w~i^p' + note.bulgaria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sofia + field.capital-hint: '' + field.capital-info: '' + field.country: Bulgaria + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'u4^lH.xem5' + note.burkina-faso: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ouagadougou + field.capital-hint: '' + field.capital-info: '' + field.country: Burkina Faso + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'n:7&>YT5A=' + note.burundi: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Gitega + field.capital-hint: '' + field.capital-info: Official capital was moved from Bujumbura to Gitega in 2019. + field.country: Burundi + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'EWXNc' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'bW)ch:vs1P' + note.cameroon: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Yaoundé' + field.capital-hint: '' + field.capital-info: '' + field.country: Cameroon + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Senegal (green/yellow/red, green star)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j2IQ=f=w3@' + note.canada: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ottawa + field.capital-hint: '' + field.capital-info: '' + field.country: Canada + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e?h=Xhs+K&' + note.canary-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Canary Islands + field.country-info: Autonomous community of Spain. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + adapter_ids: + crowdanki:guid: 'e?Xq9gy=@[' + note.cape-verde: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Praia + field.capital-hint: '' + field.capital-info: '' + field.country: Cape Verde + field.country-info: Also known as Cabo Verde. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'gFOojQO(MH' + note.caribbean-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Caribbean Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'jYz[ibrr9m' + note.caspian-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Caspian Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: x2d4n4ADcP + note.cayman-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Cayman Islands + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'vwc3}.=Z#&' + note.celebes-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Celebes Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'e!KSebP}pt' + note.celtic-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Celtic Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'E3u*2.<9#`' + note.central-african-republic: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bangui + field.capital-hint: '' + field.capital-info: '' + field.country: Central African Republic + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'vNiD6A.v#s' + note.chad: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'N''Djamena' + field.capital-hint: '' + field.capital-info: '' + field.country: Chad + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Romania (slightly lighter blue)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: '?n!)c/h=Q' + note.chile: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Santiago + field.capital-hint: '' + field.capital-info: '' + field.country: Chile + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'q8][)Ri}=q' + note.china: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Beijing + field.capital-hint: '' + field.capital-info: '' + field.country: China + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: mJH4kwPF.- + note.colombia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Bogotá' + field.capital-hint: '' + field.capital-info: '' + field.country: Colombia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Ecuador (with coat of arms)' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: hoHandbYAy + note.comoros: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Moroni + field.capital-hint: '' + field.capital-info: '' + field.country: Comoros + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 't`cagaWApo' + note.cook-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Avarua + field.capital-hint: '' + field.capital-info: '' + field.country: Cook Islands + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 's4E?ZF[E<>' + note.coral-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Coral Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 's|K&2S,UP|' + note.corsica: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Corsica + field.country-info: Region of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'xb&P*pH(Q' + note.costa-rica: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'San José' + field.capital-hint: '' + field.capital-info: '' + field.country: Costa Rica + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'uH*Q(mcRt5' + note.croatia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Zagreb + field.capital-hint: '' + field.capital-info: '' + field.country: Croatia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'bk2ByZIeU+' + note.cuba: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Havana + field.capital-hint: '' + field.capital-info: '' + field.country: Cuba + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Puerto Rico (blue triangle, red stripes)' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'rPekx@n9:L' + note.cura-ao: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Willemstad + field.capital-hint: '' + field.capital-info: '' + field.country: 'Curaçao' + field.country-info: Constituent country of the Kingdom of the Netherlands. + field.flag: '' + field.flag-similarity: 'Nauru (single star below yellow band)' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'oa87&PB&Bm' + note.cyprus: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Nicosia + field.capital-hint: '' + field.capital-info: '' + field.country: Cyprus + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'kkbYQXw5b;' + note.czech-republic: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Prague + field.capital-hint: '' + field.capital-info: '' + field.country: Czech Republic + field.country-info: Also known as Czechia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'twrhzJ[k:Z' + note.dead-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Dead Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'B:x' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'o3VS!KWb/@' + note.denmark: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Copenhagen + field.capital-hint: '' + field.capital-info: '' + field.country: Denmark + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'kS)hDm%w}R' + note.denmark-strait: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Denmark Strait + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'eN#^kPGv*L' + note.djibouti: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Djibouti + field.capital-hint: '' + field.capital-info: '' + field.country: Djibouti + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'iR;U' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k}4/oj#$~s' + note.dominican-republic: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Santo Domingo + field.capital-hint: '' + field.capital-info: '' + field.country: Dominican Republic + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lAxH.CfJB@' + note.east-china-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: East China Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'pYRw[=6;JI' + note.east-siberian-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: East Siberian Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'gRVZ]qJ#>O' + note.ecuador: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Quito + field.capital-hint: '' + field.capital-info: '' + field.country: Ecuador + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Colombia (no coat of arms)' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lmRujD!(A7' + note.egypt: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Cairo + field.capital-hint: '' + field.capital-info: '' + field.country: Egypt + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Iraq (text instead of emblem), Yemen (no emblem)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j%*8gN)q>d' + note.el-salvador: + note_type_id: note-type.ultimate-geography + fields: + field.capital: San Salvador + field.capital-hint: '' + field.capital-info: '' + field.country: El Salvador + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Nicaragua (different coat of arms, slightly lighter blue)' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k$1_yaF?9#' + note.england: + note_type_id: note-type.ultimate-geography + fields: + field.capital: London + field.capital-hint: Not a sovereign country + field.capital-info: '' + field.country: England + field.country-info: Constituent country of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'e+/O]%*qfk' + note.english-channel: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: English Channel + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'q1Q3Do8S>g' + note.equatorial-guinea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ciudad de la Paz + field.capital-hint: '' + field.capital-info: Official capital was moved from Malabo to Ciudad de la Paz in 2026. + field.country: Equatorial Guinea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'gYIoR`|AxW' + note.eritrea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Asmara + field.capital-hint: '' + field.capital-info: '' + field.country: Eritrea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'pS>DrFhd%u' + note.estonia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tallinn + field.capital-hint: '' + field.capital-info: '' + field.country: Estonia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'AES],s:8=' + note.eswatini: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Mbabane + field.capital-hint: '' + field.capital-info: While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital. + field.country: Eswatini + field.country-info: Known as Swaziland until 2018. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e5P*D#I{m{' + note.ethiopia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Addis Ababa + field.capital-hint: '' + field.capital-info: '' + field.country: Ethiopia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 't|h3|;E4g{' + note.europe: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Europe + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: oV/JG7fwZq + note.european-union: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: European Union + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: Bszsaz0Iuo + note.falkland-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Falkland Islands + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + adapter_ids: + crowdanki:guid: 'p#R$-mG/L' + note.faroe-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Tórshavn' + field.capital-hint: '' + field.capital-info: '' + field.country: Faroe Islands + field.country-info: Constituent country in the Kingdom of Denmark. + field.flag: '' + field.flag-similarity: 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'k[)#L4.[v3' + note.federated-states-of-micronesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Palikir + field.capital-hint: '' + field.capital-info: '' + field.country: Federated States of Micronesia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k1]FH+s8j@' + note.fiji: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Suva + field.capital-hint: '' + field.capital-info: '' + field.country: Fiji + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'gB|K:+r?1V' + note.finland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Helsinki + field.capital-hint: '' + field.capital-info: '' + field.country: Finland + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k}A9]O:0xw' + note.france: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Paris + field.capital-hint: '' + field.capital-info: '' + field.country: France + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm*#%sE.`.;' + note.french-guiana: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: French Guiana + field.country-info: Overseas department of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + adapter_ids: + crowdanki:guid: 'b^Try&me1A' + note.french-polynesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Papeete + field.capital-hint: '' + field.capital-info: '' + field.country: French Polynesia + field.country-info: Overseas territory of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'lH^2D]w;[H' + note.gabon: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Libreville + field.capital-hint: '' + field.capital-info: '' + field.country: Gabon + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'p.yv>BQ470' + note.georgia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tbilisi + field.capital-hint: '' + field.capital-info: '' + field.country: Georgia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jN|NAUP*h}' + note.germany: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Berlin + field.capital-hint: '' + field.capital-info: '' + field.country: Germany + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j|z@PMgdx,' + note.ghana: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Accra + field.capital-hint: '' + field.capital-info: '' + field.country: Ghana + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Bolivia (coat of arms instead of star)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'gx:;za!?C9' + note.gibraltar: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gibraltar + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Mediterranean' + adapter_ids: + crowdanki:guid: 'o``:?0>/yz' + note.greece: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Athens + field.capital-hint: '' + field.capital-info: '' + field.country: Greece + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'n+t>2`:;P/' + note.greenland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Nuuk + field.capital-hint: '' + field.capital-info: '' + field.country: Greenland + field.country-info: Constituent country in the Kingdom of Denmark. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'ilGtw#=asM' + note.grenada: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'St. George''s' + field.capital-hint: '' + field.capital-info: '' + field.country: Grenada + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'f*LO~>!0PN' + note.guadeloupe: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Guadeloupe + field.country-info: Overseas department of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'tTnCoO/*tk' + note.guam: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Hagåtña' + field.capital-hint: '' + field.capital-info: '' + field.country: Guam + field.country-info: Unincorporated territory of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'dWA49p}F{k' + note.guatemala: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Guatemala City + field.capital-hint: '' + field.capital-info: '' + field.country: Guatemala + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'dUZ^=SE5BG' + note.guernsey: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Guernsey + field.country-info: Crown dependency of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'pz(G*' + field.flag-similarity: 'Mali (red and green flipped, slightly brighter green)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'r]*Ioa%$bK' + note.guinea-bissau: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bissau + field.capital-hint: '' + field.capital-info: '' + field.country: Guinea-Bissau + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'i^J8&$(1]|' + note.gulf-of-alaska: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of Alaska + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'igiK,ff[eO' + note.gulf-of-california: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of California + field.country-info: Also known as the Sea of Cortez. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'SlOEXfq#|' + note.gulf-of-carpentaria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of Carpentaria + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'pQpX%(EVOH' + note.gulf-of-guinea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of Guinea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'BJ,,^wMC%N' + note.gulf-of-mexico: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of Mexico + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'JOT:U4ayh@' + note.gulf-of-thailand: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Gulf of Thailand + field.country-info: Also known as the Gulf of Siam. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'rSneU!3}bn' + note.guyana: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Georgetown + field.capital-hint: '' + field.capital-info: '' + field.country: Guyana + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'q:XyGf#>Wg' + note.haiti: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Port-au-Prince + field.capital-hint: '' + field.capital-info: '' + field.country: Haiti + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'hZ/V/D&rO`' + note.hawaii: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Hawaii + field.country-info: State of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 's>f40rn,O]' + note.honduras: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tegucigalpa + field.capital-hint: '' + field.capital-info: '' + field.country: Honduras + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 't_h*fbN#:(' + note.hong-kong: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Hong Kong + field.country-info: Special Administrative Region of China. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: 'lpbD0St&OU' + note.hudson-bay: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Hudson Bay + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: sM0GPt1HS1 + note.hungary: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Budapest + field.capital-hint: '' + field.capital-info: '' + field.country: Hungary + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mcc&0Kq^xE' + note.iceland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Reykjavík' + field.capital-hint: '' + field.capital-info: '' + field.country: Iceland + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: j7eEZkzsCZ + note.india: + note_type_id: note-type.ultimate-geography + fields: + field.capital: New Delhi + field.capital-hint: '' + field.capital-info: '' + field.country: India + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'q2#Yv[O0:|' + note.indian-ocean: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Indian Ocean + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'fT!w8R#>dg' + note.indonesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Jakarta + field.capital-hint: '' + field.capital-info: '' + field.country: Indonesia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sx|l+io`B@' + note.iran: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tehran + field.capital-hint: '' + field.capital-info: '' + field.country: Iran + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mbz=7n[0v9' + note.iraq: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Baghdad + field.capital-hint: '' + field.capital-info: '' + field.country: Iraq + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Egypt (emblem instead of text), Yemen (no text)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: jI9P-f6r3M + note.ireland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dublin + field.capital-hint: '' + field.capital-info: '' + field.country: Ireland + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Ivory Coast (green and orange flipped, narrower)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 's<_WLSB3I/' + note.isle-of-man: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Isle of Man + field.country-info: Crown dependency of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'cV4idO^r8W' + note.israel: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Jerusalem + field.capital-hint: Claimed and controlled + field.capital-info: 'Disputed; claimed by Palestine.' + field.country: Israel + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'ijP>aJBT!9' + note.italy: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Rome + field.capital-hint: '' + field.capital-info: '' + field.country: Italy + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'cYKvF#ptF7' + note.ivory-coast: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Yamoussoukro + field.capital-hint: '' + field.capital-info: While Yamoussoukro is the official capital, Abidjan is the de facto seat of government. + field.country: Ivory Coast + field.country-info: 'Officially Côte d''Ivoire.' + field.flag: '' + field.flag-similarity: 'Ireland (orange and green flipped, wider)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'jN^Vc%9OQ5' + note.jamaica: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kingston + field.capital-hint: '' + field.capital-info: '' + field.country: Jamaica + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'h&`>n98~V1' + note.japan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tokyo + field.capital-hint: '' + field.capital-info: '' + field.country: Japan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l}[BG9.nQS' + note.java: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Java + field.country-info: Island of Indonesia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: 'gDsLPj(9S#' + note.jeju: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Jeju + field.country-info: Autonomous province of South Korea. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: fkq7lhLkdH + note.jersey: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Jersey + field.country-info: Crown dependency of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'm*PYE=#wr^' + note.jordan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Amman + field.capital-hint: '' + field.capital-info: '' + field.country: Jordan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'i)]+09JUR&' + note.kaliningrad-oblast: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Kaliningrad Oblast + field.country-info: 'Oblast (administrative region) of the Russian Federation.' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'dC:e5G4J/p' + note.kazakhstan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Astana + field.capital-hint: '' + field.capital-info: Known as Nur-Sultan between 2019 and 2022 + field.country: Kazakhstan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'ia8as9sCY|' + note.kenya: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Nairobi + field.capital-hint: '' + field.capital-info: '' + field.country: Kenya + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: sXzvT.g31p + note.kosovo: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Pristina + field.capital-hint: '' + field.capital-info: '' + field.country: Kosovo + field.country-info: Partially recognised state claimed by Serbia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: libliptkB4 + note.kuwait: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kuwait City + field.capital-hint: '' + field.capital-info: '' + field.country: Kuwait + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'euW0jm=wfR' + note.kyrgyzstan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bishkek + field.capital-hint: '' + field.capital-info: '' + field.country: Kyrgyzstan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: pkj3RPBUrF + note.labrador-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Labrador Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'dlU7VG|3Gd' + note.land-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Mariehamn + field.capital-hint: '' + field.capital-info: '' + field.country: 'Åland Islands' + field.country-info: Autonomous region of Finland. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'd``B*6:eCx' + note.laos: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Vientiane + field.capital-hint: '' + field.capital-info: '' + field.country: Laos + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'iW>m(L/~c=' + note.latvia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Riga + field.capital-hint: '' + field.capital-info: '' + field.country: Latvia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Austria (brighter red, wider white band)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm)WUtJS`H&' + note.lebanon: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Beirut + field.capital-hint: '' + field.capital-info: '' + field.country: Lebanon + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mtd;>7T|g>' + note.lesotho: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Maseru + field.capital-hint: '' + field.capital-info: '' + field.country: Lesotho + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'oNpApS' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'nsS59c`VL,' + note.libya: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tripoli + field.capital-hint: '' + field.capital-info: '' + field.country: Libya + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jrM28*HbyG' + note.liechtenstein: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Vaduz + field.capital-hint: '' + field.capital-info: '' + field.country: Liechtenstein + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l$2O|9w`F~' + note.lithuania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Vilnius + field.capital-hint: '' + field.capital-info: '' + field.country: Lithuania + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'kZ52zV#[Iv' + note.luxembourg: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Luxembourg City + field.capital-hint: '' + field.capital-info: Officially Luxembourg. + field.country: Luxembourg + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Netherlands (darker blue)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'i2]v|D3@:j' + note.macau: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Macau + field.country-info: Special Administrative Region of China. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: 'mq)8`hi{hb' + note.madagascar: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Antananarivo + field.capital-hint: '' + field.capital-info: '' + field.country: Madagascar + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm{noqE29%I' + note.madeira: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Madeira + field.country-info: Autonomous region of Portugal. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'h}lnSN_f$_' + note.malawi: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Lilongwe + field.capital-hint: '' + field.capital-info: '' + field.country: Malawi + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sSW/?WMy17' + note.malaysia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kuala Lumpur + field.capital-hint: '' + field.capital-info: '' + field.country: Malaysia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mk20`,N=`=' + note.maldives: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Malé' + field.capital-hint: '' + field.capital-info: '' + field.country: Maldives + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'hXNTVF<:SH' + note.mali: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bamako + field.capital-hint: '' + field.capital-info: '' + field.country: Mali + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Guinea (green and red flipped, slightly darker green)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'd-q4J~CNW_' + note.malta: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Valletta + field.capital-hint: '' + field.capital-info: '' + field.country: Malta + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'nIfRn' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'eT' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'u;|vR#mVMW' + note.mauritania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Nouakchott + field.capital-hint: '' + field.capital-info: '' + field.country: Mauritania + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'jKO,9y0M;#' + note.mauritius: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Port Louis + field.capital-hint: '' + field.capital-info: '' + field.country: Mauritius + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'skOM(?n$GG' + note.mayotte: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Mayotte + field.country-info: Overseas department of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + adapter_ids: + crowdanki:guid: 'xqrJ^d[.(O' + note.mediterranean-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Mediterranean Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'Gn;q2Y)Xe4' + note.melanesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Melanesia + field.country-info: Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'o9{x' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sxOMs+bE8@' + note.micronesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Micronesia + field.country-info: Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'cv+Nq9:a' + field.flag-similarity: 'Andorra (narrower, coat of arms with motto)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: '5S(f7%O-$' + note.monaco: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Monaco + field.capital-hint: '' + field.capital-info: '' + field.country: Monaco + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm?}%V6s8]*' + note.mongolia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ulaanbaatar + field.capital-hint: '' + field.capital-info: '' + field.country: Mongolia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'd3c`&/1Y^D' + note.montenegro: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Podgorica + field.capital-hint: '' + field.capital-info: Cetinje is an honorary capital. + field.country: Montenegro + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'uRo^w#p5Be' + note.morocco: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Rabat + field.capital-hint: '' + field.capital-info: '' + field.country: Morocco + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mi+kf0LTW.' + note.mozambique: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Maputo + field.capital-hint: '' + field.capital-info: '' + field.country: Mozambique + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'vgdj#X?8dB' + note.myanmar: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Naypyidaw + field.capital-hint: '' + field.capital-info: '' + field.country: Myanmar + field.country-info: Also known as Burma. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l*j*EE:!Y?' + note.namibia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Windhoek + field.capital-hint: '' + field.capital-info: '' + field.country: Namibia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mkgcU7[bA%' + note.nauru: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Yaren + field.capital-hint: '' + field.capital-info: 'Nauru has no official capital; the Yaren District is the de facto capital.' + field.country: Nauru + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Curaçao (two stars in top-left corner)' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'vE[T$`n9T)' + note.nepal: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kathmandu + field.capital-hint: '' + field.capital-info: '' + field.country: Nepal + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'l3Ly8PBxt(' + note.netherlands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Amsterdam + field.capital-hint: '' + field.capital-info: While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches. + field.country: Netherlands + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Luxembourg (lighter blue)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'o=,%hU&yX%' + note.new-caledonia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Nouméa' + field.capital-hint: '' + field.capital-info: '' + field.country: New Caledonia + field.country-info: Overseas territory of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'tj&bK}%P!F' + note.new-zealand: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Wellington + field.capital-hint: '' + field.capital-info: '' + field.country: New Zealand + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Australia (white stars, two more stars)' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: ')Y}Qy.3GK' + note.nicaragua: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Managua + field.capital-hint: '' + field.capital-info: '' + field.country: Nicaragua + field.country-info: '' + field.flag: '' + field.flag-similarity: 'El Salvador (different coat of arms, slightly darker blue)' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'q=w[' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'jcu!gLw/&r' + note.nigeria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Abuja + field.capital-hint: '' + field.capital-info: '' + field.country: Nigeria + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'sB7rZQsR' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'kX]hN=_.c-' + note.north-america: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: North America + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'bD?R}i8_[c' + note.north-korea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Pyongyang + field.capital-hint: '' + field.capital-info: '' + field.country: North Korea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r6BV%|..*5' + note.north-macedonia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Skopje + field.capital-hint: '' + field.capital-info: '' + field.country: North Macedonia + field.country-info: Formerly known as Macedonia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r5C646H*+*' + note.north-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: North Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'b`5Q_Z=HQT' + note.northern-cyprus: + note_type_id: note-type.ultimate-geography + fields: + field.capital: North Nicosia + field.capital-hint: '' + field.capital-info: '' + field.country: Northern Cyprus + field.country-info: State recognised only by Turkey and claimed by Cyprus. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r/B|ra%L,.' + note.northern-ireland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Belfast + field.capital-hint: '' + field.capital-info: '' + field.country: Northern Ireland + field.country-info: Constituent country of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'cPO|Cru8n<' + note.northern-mariana-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Northern Mariana Islands + field.country-info: Unincorporated territory of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'p}Qa|!7PpR' + note.norway: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Oslo + field.capital-hint: '' + field.capital-info: '' + field.country: Norway + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lq`T^ZBF2Y' + note.norwegian-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Norwegian Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'lp@e##m.!+' + note.oceania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Oceania + field.country-info: World region covering the Australian continent and most of the islands in the Pacific Ocean. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'qFuGdDx^Yv' + note.oman: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Muscat + field.capital-hint: '' + field.capital-info: '' + field.country: Oman + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'eALZe^HCHq' + note.pacific-ocean: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Pacific Ocean + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'f*2@qiHh_1' + note.pakistan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Islamabad + field.capital-hint: '' + field.capital-info: '' + field.country: Pakistan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'iIS#Nw]tr]' + note.palau: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ngerulmud + field.capital-hint: '' + field.capital-info: '' + field.country: Palau + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r?E$4f-d0/' + note.palestine: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Jerusalem + field.capital-hint: Claimed but not controlled + field.capital-info: 'Disputed; claimed by Israel; Ramallah is the administrative centre.' + field.country: Palestine + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'g<[f89~U)A' + note.panama: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Panama City + field.capital-hint: '' + field.capital-info: '' + field.country: Panama + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'vpOTK6tMr@' + note.papua-new-guinea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Port Moresby + field.capital-hint: '' + field.capital-info: '' + field.country: Papua New Guinea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lhWI;$JC9(' + note.paraguay: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Asunción' + field.capital-hint: '' + field.capital-info: '' + field.country: Paraguay + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 's#:aIK$9^x' + note.persian-gulf: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Persian Gulf + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'M$bc0[jYf0' + note.peru: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Lima + field.capital-hint: '' + field.capital-info: '' + field.country: Peru + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jBd@A?>g|}' + note.philippine-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Philippine Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'G/^_##fcPN' + note.philippines: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Manila + field.capital-hint: '' + field.capital-info: '' + field.country: Philippines + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'qx%>fB$d+$' + note.poland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Warsaw + field.capital-hint: '' + field.capital-info: '' + field.country: Poland + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'f>c7x2E]Q6' + note.polynesia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Polynesia + field.country-info: Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'd5m8%D6,;u' + note.portugal: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Lisbon + field.capital-hint: '' + field.capital-info: '' + field.country: Portugal + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'q[S4`,F0D=' + note.puerto-rico: + note_type_id: note-type.ultimate-geography + fields: + field.capital: San Juan + field.capital-hint: '' + field.capital-info: '' + field.country: Puerto Rico + field.country-info: Unincorporated territory of the United States. + field.flag: '' + field.flag-similarity: 'Cuba (red triangle, blue stripes)' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: t3aZXtpNyi + note.qatar: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Doha + field.capital-hint: '' + field.capital-info: '' + field.country: Qatar + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Bahrain (narrower, fewer serrated edges, red)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm{oM,jr>C#' + note.r-union: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: 'Réunion' + field.country-info: Overseas department of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + adapter_ids: + crowdanki:guid: 'vrn%j%6{nu' + note.red-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Red Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: '_]DH+g9!$' + note.republic-of-the-congo: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Brazzaville + field.capital-hint: '' + field.capital-info: '' + field.country: Republic of the Congo + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'u|I}oElaH)' + note.romania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bucharest + field.capital-hint: '' + field.capital-info: '' + field.country: Romania + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Chad (slightly darker blue)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jCd]`-=k,:' + note.russia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Moscow + field.capital-hint: '' + field.capital-info: '' + field.country: Russia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Slovakia (with coat of arms)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'vf)G?[_?Pf' + note.rwanda: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kigali + field.capital-hint: '' + field.capital-info: '' + field.country: Rwanda + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j)i)pB*HJ,' + note.s-o-tom-and-pr-ncipe: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'São Tomé' + field.capital-hint: '' + field.capital-info: '' + field.country: 'São Tomé and Príncipe' + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'gePC:Xt_nW' + note.sahrawi-arab-democratic-republic: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Laayoune + field.capital-hint: '' + field.capital-info: 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.' + field.country: Sahrawi Arab Democratic Republic + field.country-info: Partially recognised state claimed by Morocco. Also known as Western Sahara. + field.flag: '' + field.flag-similarity: 'Palestine (no symbol)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'cAlyc~G.y%' + note.saint-kitts-and-nevis: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Basseterre + field.capital-hint: '' + field.capital-info: '' + field.country: Saint Kitts and Nevis + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'b[(2`4t&?J' + note.saint-lucia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Castries + field.capital-hint: '' + field.capital-info: '' + field.country: Saint Lucia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'leh^fmrt*#' + note.saint-martin: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Saint Martin + field.country-info: Overseas territory of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 't2&ri|[Un:' + note.saint-vincent-and-the-grenadines: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kingstown + field.capital-hint: '' + field.capital-info: '' + field.country: Saint Vincent and the Grenadines + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'va/ky=f5pK' + note.samoa: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Apia + field.capital-hint: '' + field.capital-info: '' + field.country: Samoa + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: qvITjNxkLN + note.san-marino: + note_type_id: note-type.ultimate-geography + fields: + field.capital: City of San Marino + field.capital-hint: '' + field.capital-info: '' + field.country: San Marino + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'j9:K,~DQ2v' + note.sardinia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sardinia + field.country-info: Autonomous region of Italy. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'jKIDq|}J&c' + note.saudi-arabia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Riyadh + field.capital-hint: '' + field.capital-info: '' + field.country: Saudi Arabia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'ln^3p)j3b.' + note.scandinavia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Scandinavia + field.country-info: Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'He!e=h?_2V' + note.scotland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Edinburgh + field.capital-hint: '' + field.capital-info: '' + field.country: Scotland + field.country-info: Constituent country of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'h~5xz+=ke~' + note.sea-of-galilee: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sea of Galilee + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'QSq%~rFaP-' + note.sea-of-japan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sea of Japan + field.country-info: 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'd}R:qHj1,7' + note.sea-of-okhotsk: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sea of Okhotsk + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: tfCY4j6KEZ + note.senegal: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dakar + field.capital-hint: '' + field.capital-info: '' + field.country: Senegal + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Cameroon (green/red/yellow, yellow star)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 't-0>F]bu>P' + note.serbia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Belgrade + field.capital-hint: '' + field.capital-info: '' + field.country: Serbia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'ui3>b-?a@m' + note.seychelles: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Victoria + field.capital-hint: '' + field.capital-info: '' + field.country: Seychelles + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'hrI_uiW>E]' + note.sicily: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sicily + field.country-info: Autonomous region of Italy. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'n{{o2K1:dw' + note.sierra-leone: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Freetown + field.capital-hint: '' + field.capital-info: '' + field.country: Sierra Leone + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'uc61ToT%gt' + note.singapore: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Singapore + field.capital-hint: '' + field.capital-info: '' + field.country: Singapore + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'cL?8!xP#Wj' + note.sint-maarten: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sint Maarten + field.country-info: Constituent country of the Kingdom of the Netherlands. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'bnEK#)[;qT' + note.slovakia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bratislava + field.capital-hint: '' + field.capital-info: '' + field.country: Slovakia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'qVi;t%/`F|' + note.slovenia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ljubljana + field.capital-hint: '' + field.capital-info: '' + field.country: Slovenia + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Slovakia (narrower, bigger coat of arms)' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'g-]bb&[.E!' + note.solomon-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Honiara + field.capital-hint: '' + field.capital-info: '' + field.country: Solomon Islands + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r>g<|,FWi%' + note.somalia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Mogadishu + field.capital-hint: '' + field.capital-info: '' + field.country: Somalia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'c,5(mUGzYT' + note.somaliland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Hargeisa + field.capital-hint: '' + field.capital-info: '' + field.country: Somaliland + field.country-info: Independent state claimed by Somalia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jX=#G8wu#(' + note.south-africa: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Pretoria, Cape Town, Bloemfontein + field.capital-hint: '' + field.capital-info: 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).' + field.country: South Africa + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e.t$Vi5,>f' + note.south-america: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: South America + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Continents' + adapter_ids: + crowdanki:guid: 'Lol+XIvY21' + note.south-china-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: South China Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 't$y1C2R-Av' + note.south-korea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Seoul + field.capital-hint: '' + field.capital-info: '' + field.country: South Korea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'h?a*UKfT_M' + note.south-ossetia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tskhinvali + field.capital-hint: '' + field.capital-info: '' + field.country: South Ossetia + field.country-info: Independent state claimed by Georgia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'p]t8:Tka7q' + note.south-sudan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Juba + field.capital-hint: '' + field.capital-info: '' + field.country: South Sudan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lI)Ah/E' + note.southern-ocean: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Southern Ocean + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: LPF4AfaHKR + note.spain: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Madrid + field.capital-hint: '' + field.capital-info: '' + field.country: Spain + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'f21=-A~W;~' + note.sri-lanka: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sri Jayawardenepura Kotte + field.capital-hint: '' + field.capital-info: Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital. + field.country: Sri Lanka + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'g9v~y/;=z(' + note.sudan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Khartoum + field.capital-hint: '' + field.capital-info: '' + field.country: Sudan + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Palestine (black/white/green, red arrow)' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'k/u1:B%DJH' + note.sumatra: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Sumatra + field.country-info: Island of Indonesia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + adapter_ids: + crowdanki:guid: 'ck!6;xK$/^' + note.suriname: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Paramaribo + field.capital-hint: '' + field.capital-info: '' + field.country: Suriname + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'koI(}OyJ:@' + note.svalbard: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Svalbard + field.country-info: Unincorporated internal area of Norway. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: '5JJ2]i)p4' + note.sweden: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Stockholm + field.capital-hint: '' + field.capital-info: '' + field.country: Sweden + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::European_Union' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'sT5=H|@KU|' + note.switzerland: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bern + field.capital-hint: '' + field.capital-info: 'Switzerland has no official capital; Bern is the de facto capital.' + field.country: Switzerland + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'lU@C3' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e=.VHQ9E+M' + note.taiwan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Taipei + field.capital-hint: '' + field.capital-info: '' + field.country: Taiwan + field.country-info: Partially recognised state claimed by China. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'm[96fa+HDS' + note.tajikistan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dushanbe + field.capital-hint: '' + field.capital-info: '' + field.country: Tajikistan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'g~%pF`(x{u' + note.tanzania: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dodoma + field.capital-hint: '' + field.capital-info: While Dodoma is the official capital, Dar es Salaam is the de facto seat of government. + field.country: Tanzania + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'p:SPZ:e/*[' + note.tasman-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Tasman Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'eHJ?)TAdTo' + note.thailand: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Bangkok + field.capital-hint: '' + field.capital-info: '' + field.country: Thailand + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'hE5j*e,Q?[' + note.the-bahamas: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Nassau + field.capital-hint: '' + field.capital-info: '' + field.country: The Bahamas + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'W5?6yQZD2' + note.the-gambia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Banjul + field.capital-hint: '' + field.capital-info: '' + field.country: The Gambia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'f#4-L[mp5*' + note.timor-leste: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Dili + field.capital-hint: '' + field.capital-info: '' + field.country: Timor-Leste + field.country-info: Also known as East Timor. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'fRSM{K+;[w' + note.timor-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Timor Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'vppEA|$@~g' + note.togo: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Lomé' + field.capital-hint: '' + field.capital-info: '' + field.country: Togo + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Sovereign_State' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 't+v9b--,qs' + note.tonga: + note_type_id: note-type.ultimate-geography + fields: + field.capital: 'Nukuʻalofa' + field.capital-hint: '' + field.capital-info: '' + field.country: Tonga + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'c^,~EC6Pb2' + note.transnistria: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tiraspol + field.capital-hint: '' + field.capital-info: '' + field.country: Transnistria + field.country-info: Independent state claimed by Moldova. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'tMMtBUpb$&' + note.trinidad-and-tobago: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Port of Spain + field.capital-hint: '' + field.capital-info: '' + field.country: Trinidad and Tobago + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 't50y/[AEhF' + note.tunisia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tunis + field.capital-hint: '' + field.capital-info: '' + field.country: Tunisia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::Mediterranean' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'fV`&p81%d(' + note.turkey: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ankara + field.capital-hint: '' + field.capital-info: '' + field.country: Turkey + field.country-info: 'Also known as Türkiye' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Mediterranean' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'jDA-V?/hVj' + note.turkmenistan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Ashgabat + field.capital-hint: '' + field.capital-info: '' + field.country: Turkmenistan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'u-`cspdp)D' + note.turks-and-caicos-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Turks and Caicos Islands + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'q7do-wLx%E' + note.tuvalu: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Funafuti + field.capital-hint: '' + field.capital-info: '' + field.country: Tuvalu + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'de=9c:,@j9' + note.uganda: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kampala + field.capital-hint: '' + field.capital-info: '' + field.country: Uganda + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'gsS{jR,]|Q' + note.ukraine: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kyiv + field.capital-hint: '' + field.capital-info: Also known as Kiev. + field.country: Ukraine + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'h_9=mkZXAH' + note.united-arab-emirates: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Abu Dhabi + field.capital-hint: '' + field.capital-info: '' + field.country: United Arab Emirates + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'o~}KVA:VbI' + note.united-kingdom: + note_type_id: note-type.ultimate-geography + fields: + field.capital: London + field.capital-hint: Sovereign country + field.capital-info: '' + field.country: United Kingdom + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 's9-GL*@AXD' + note.united-states-of-america: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Washington, D.C. + field.capital-hint: '' + field.capital-info: '' + field.country: United States of America + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::North_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'b@M[XTc?}5' + note.united-states-virgin-islands: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Charlotte Amalie + field.capital-hint: '' + field.capital-info: '' + field.country: United States Virgin Islands + field.country-info: Unincorporated territory of the United States. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'kE!iKHLN_g' + note.uruguay: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Montevideo + field.capital-hint: '' + field.capital-info: '' + field.country: Uruguay + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e?gIaPc$Ox' + note.uzbekistan: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Tashkent + field.capital-hint: '' + field.capital-info: '' + field.country: Uzbekistan + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: mbGXc0y3Pe + note.vanuatu: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Port Vila + field.capital-hint: '' + field.capital-info: '' + field.country: Vanuatu + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'K*b}' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'mk1VltJl<]' + note.venezuela: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Caracas + field.capital-hint: '' + field.capital-info: '' + field.country: Venezuela + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::South_America' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'o#&RMA_REo' + note.vietnam: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Hanoi + field.capital-hint: '' + field.capital-info: '' + field.country: Vietnam + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'pD' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'hNE9}>;Q&-' + note.wallis-and-futuna: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Wallis and Futuna + field.country-info: Overseas territory of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'dbP/Cj}sB+' + note.white-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: White Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'cEO^y)Md[G' + note.yellow-sea: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Yellow Sea + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceans+Seas' + adapter_ids: + crowdanki:guid: 'L5M1*yG7eH' + note.yemen: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Sanaa + field.capital-hint: '' + field.capital-info: 'Also spelled as Sana''a.' + field.country: Yemen + field.country-info: '' + field.flag: '' + field.flag-similarity: 'Egypt (with emblem), Iraq (with text)' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Middle_East' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'e42BpV,P}C' + note.zambia: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Lusaka + field.capital-hint: '' + field.capital-info: '' + field.country: Zambia + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'fxAN),FAc$' + note.zanzibar: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Zanzibar + field.country-info: Semi-autonomous region of Tanzania. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + adapter_ids: + crowdanki:guid: 'gp[h@]y13l' + note.zimbabwe: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Harare + field.capital-hint: '' + field.capital-info: '' + field.country: Zimbabwe + field.country-info: '' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::East_Africa' + - 'UG::Sovereign_State' + adapter_ids: + crowdanki:guid: 'r' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: '~)7-62ytBX' + note.british-indian-ocean-territory: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Camp Justice + field.capital-hint: '' + field.capital-info: '' + field.country: British Indian Ocean Territory + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + adapter_ids: + crowdanki:guid: 'ArmciP%a;a' + note.british-virgin-islands: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.canary-islands: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.cayman-islands: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.ceuta: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Ceuta + field.country-info: Autonomous city of Spain. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'rHh8*,y.yc' + note.christmas-island: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Flying Fish Cove + field.capital-hint: '' + field.capital-info: '' + field.country: Christmas Island + field.country-info: External territory of Australia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + adapter_ids: + crowdanki:guid: 'k3]]2^DP1R' + note.cocos-keeling-islands: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: West Island + field.capital-hint: '' + field.capital-info: '' + field.country: 'Cocos (Keeling) Islands' + field.country-info: External territory of Australia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Asia' + - 'UG::Southeast_Asia' + adapter_ids: + crowdanki:guid: mvNnHkGnVl + note.corsica: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.easter-island: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Easter Island + field.country-info: Polynesian island and special territory of Chile. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'rNv|0qxR]K' + note.falkland-islands: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.french-guiana: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.gal-pagos-islands: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Puerto Baquerizo Moreno + field.capital-hint: '' + field.capital-info: '' + field.country: 'Galápagos Islands' + field.country-info: Province of Ecuador. + field.flag: '' + field.flag-similarity: 'Sierra Leone (slightly lighter blue)' + field.map: '' + tags: + - 'UG::South_America' + adapter_ids: + crowdanki:guid: 'j=,1:f39$]' + note.gibraltar: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.guadeloupe: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.guernsey: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.isle-of-man: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.jersey: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.kaliningrad-oblast: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.madeira: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.martinique: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.mayotte: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.melilla: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Melilla + field.country-info: Autonomous city of Spain + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'd~3m`ihA*b' + note.montserrat: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Brades + field.capital-hint: '' + field.capital-info: Eruptions in 1997 destroyed the capital city of Plymouth, making Brades the de facto capital. + field.country: Montserrat + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'iq7o#n0juD' + note.mount-athos: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Karyes + field.capital-hint: '' + field.capital-info: '' + field.country: Mount Athos + field.country-info: Autonomous orthodox monastic state in northeastern Greece, also known as the Holy Mountain. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Europe' + adapter_ids: + crowdanki:guid: 'qAsU}ONgC#' + note.norfolk-island: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Kingston + field.capital-hint: Not a sovereign country + field.capital-info: '' + field.country: Norfolk Island + field.country-info: External territory of Australia. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'nipI.lOoC;' + note.northern-mariana-islands: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.pitcairn-islands: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Adamstown + field.capital-hint: '' + field.capital-info: '' + field.country: Pitcairn Islands + field.country-info: Overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'mCh>s18~IX' + note.r-union: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.saba: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: The Bottom + field.capital-hint: '' + field.capital-info: '' + field.country: Saba + field.country-info: Special municipality of the Netherlands. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'i;=jdbhl(w' + note.saint-barth-lemy: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Gustavia + field.capital-hint: '' + field.capital-info: '' + field.country: 'Saint Barthélemy' + field.country-info: Overseas territory of France. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'ga97y4Zy0{' + note.saint-helena: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Jamestown + field.capital-hint: '' + field.capital-info: '' + field.country: Saint Helena + field.country-info: Part of Saint Helena, Ascension and Tristan da Cunha, an overseas territory of the United Kingdom. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Africa' + - 'UG::West_Africa' + adapter_ids: + crowdanki:guid: 'khTp#J9C!!' + note.saint-martin: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.sardinia: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.sicily: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.sint-eustatius: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: Oranjestad + field.capital-hint: Special municipality + field.capital-info: '' + field.country: Sint Eustatius + field.country-info: Special municipality of the Netherlands. + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Caribbean' + - 'UG::North_America' + adapter_ids: + crowdanki:guid: 'r3?J{NB0J3' + note.sint-maarten: + intent: merge + tags: + 'UG::Overlapping': + intent: add + note.tokelau: + intent: add + note: + note_type_id: note-type.ultimate-geography + fields: + field.capital: '' + field.capital-hint: '' + field.capital-info: '' + field.country: Tokelau + field.country-info: 'Dependent territory of New Zealand. Tokelau has no official capital; the meeting place of the Tokelau Council rotates annually.' + field.flag: '' + field.flag-similarity: '' + field.map: '' + tags: + - 'UG::Oceania' + adapter_ids: + crowdanki:guid: 'nN' + note.anguilla: + field.capital: The Valley + field.flag: '' + note.azores: + field.capital: Ponta Delgada + field.flag: '' + note.bali: + field.capital: Denpasar + field.flag: '' + note.bermuda: + field.capital: Hamilton + field.flag: '' + note.british-virgin-islands: + field.capital: Road Town + 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. + field.flag: '' + note.cayman-islands: + field.capital: George Town + field.capital-hint: Not a sovereign country + field.flag: '' + note.corsica: + field.capital: Ajaccio + field.flag: '' + note.falkland-islands: + field.capital: Stanley + field.flag: '' + note.french-guiana: + field.capital: Cayenne + note.gibraltar: + field.capital: Gibraltar + field.flag: '' + note.guadeloupe: + field.capital: Basse-Terre + field.capital-hint: Not a sovereign country + note.guernsey: + field.capital: St. Peter Port + field.flag: '' + note.isle-of-man: + field.capital: Douglas + field.flag: '' + note.jersey: + field.capital: Saint Helier + field.flag: '' + note.kaliningrad-oblast: + field.capital: Kaliningrad + field.flag: '' + note.madeira: + field.capital: Funchal + field.flag: '' + note.martinique: + field.capital: Fort-de-France + note.mayotte: + field.capital: Mamoudzou + note.northern-mariana-islands: + field.capital: Saipan + field.flag: '' + note.r-union: + field.capital: Saint-Denis + note.saint-martin: + field.capital: Marigot + note.sardinia: + field.capital: Cagliari + field.flag: '' + note.sicily: + field.capital: Palermo + field.flag: '' + note.sint-maarten: + field.capital: Philipsburg + field.flag: '' + note.turks-and-caicos-islands: + field.capital: Cockburn Town + field.flag: '' + note.zanzibar: + field.capital: Zanzibar City + field.flag: '' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/de.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/de.yaml new file mode 100644 index 0000000..7779813 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/de.yaml @@ -0,0 +1,40 @@ +id: overlay.extension.hardcore.field-fills.de +kind: extension +notes: + note.canary-islands: + intent: merge + fields: + field.capital: + intent: override + value: Santa Cruz de Tenerife, Las Palmas de Gran Canaria + expected_base: + value: Santa Cruz de Tenerife, Las Palmas + field.capital-info: + intent: override + value: 'Die zwei Städte Santa Cruz de Tenerife und Las Palmas de Gran Canaria sind zusammen Hauptstädte der Autonomen Gemeinschaft der Kanaren.' + expected_base: + value: The capital is shared between the two cities of Santa Cruz de Tenerife and Las Palmas. + note.cayman-islands: + intent: merge + fields: + field.capital-hint: + intent: override + value: 'Kein souveräner Staat' + expected_base: + value: Not a sovereign country + note.guadeloupe: + intent: merge + fields: + field.capital-hint: + intent: override + value: 'Kein souveräner Staat' + expected_base: + value: Not a sovereign country + note.zanzibar: + intent: merge + fields: + field.capital: + intent: override + value: Sansibar-Stadt + expected_base: + value: Zanzibar City diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/es.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/es.yaml new file mode 100644 index 0000000..2e7ac59 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/es.yaml @@ -0,0 +1,88 @@ +id: overlay.extension.hardcore.field-fills.es +kind: extension +notes: + note.anguilla: + intent: merge + fields: + field.capital: + intent: override + value: El Valle + expected_base: + value: The Valley + note.canary-islands: + intent: merge + fields: + field.capital: + intent: override + value: Santa Cruz de Tenerife, Las Palmas de Gran Canaria + expected_base: + value: Santa Cruz de Tenerife, Las Palmas + field.capital-info: + intent: override + value: La capitalidad es compartida entre las dos ciudades de Santa Cruz de Tenerife y Las Palmas de Gran Canaria. + expected_base: + value: The capital is shared between the two cities of Santa Cruz de Tenerife and Las Palmas. + note.cayman-islands: + intent: merge + fields: + field.capital-hint: + intent: override + value: 'No es un país soberano' + expected_base: + value: Not a sovereign country + note.falkland-islands: + intent: merge + fields: + field.capital: + intent: override + value: Puerto Argentino/Stanley + expected_base: + value: Stanley + note.french-guiana: + intent: merge + fields: + field.capital: + intent: override + value: Cayena + expected_base: + value: Cayenne + note.guadeloupe: + intent: merge + fields: + field.capital-hint: + intent: override + value: 'No es un país soberano' + expected_base: + value: Not a sovereign country + note.guernsey: + intent: merge + fields: + field.capital: + intent: override + value: Saint Peter Port + expected_base: + value: St. Peter Port + note.kaliningrad-oblast: + intent: merge + fields: + field.capital: + intent: override + value: Kaliningrado + expected_base: + value: Kaliningrad + note.northern-mariana-islands: + intent: merge + fields: + field.capital: + intent: override + value: 'Saipán' + expected_base: + value: Saipan + note.zanzibar: + intent: merge + fields: + field.capital: + intent: override + value: 'Zanzíbar' + expected_base: + value: Zanzibar City diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/fr.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/fr.yaml new file mode 100644 index 0000000..088806d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/fr.yaml @@ -0,0 +1,64 @@ +id: overlay.extension.hardcore.field-fills.fr +kind: extension +notes: + note.canary-islands: + intent: merge + fields: + field.capital: + intent: override + value: Santa Cruz de Tenerife, Las Palmas de Grande Canarie + expected_base: + value: Santa Cruz de Tenerife, Las Palmas + field.capital-info: + intent: override + value: 'Le statut de capitale est partagé entre les villes de Santa Cruz et Las Palmas.' + expected_base: + value: The capital is shared between the two cities of Santa Cruz de Tenerife and Las Palmas. + note.cayman-islands: + intent: merge + fields: + field.capital-hint: + intent: override + value: Pas une nation souveraine + expected_base: + value: Not a sovereign country + note.guadeloupe: + intent: merge + fields: + field.capital-hint: + intent: override + value: Pas une nation souveraine + expected_base: + value: Not a sovereign country + note.guernsey: + intent: merge + fields: + field.capital: + intent: override + value: Saint-Pierre-Port + expected_base: + value: St. Peter Port + note.jersey: + intent: merge + fields: + field.capital: + intent: override + value: 'Saint-Hélier' + expected_base: + value: Saint Helier + note.sicily: + intent: merge + fields: + field.capital: + intent: override + value: Palerme + expected_base: + value: Palermo + note.zanzibar: + intent: merge + fields: + field.capital: + intent: override + value: Zanzibar + expected_base: + value: Zanzibar City diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/nb.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/nb.yaml new file mode 100644 index 0000000..323b532 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/field-fills/nb.yaml @@ -0,0 +1,48 @@ +id: overlay.extension.hardcore.field-fills.nb +kind: extension +notes: + note.canary-islands: + intent: merge + fields: + field.capital: + intent: override + value: 'Santa Cruz på Tenerife, Las Palmas på Gran Canaria' + expected_base: + value: Santa Cruz de Tenerife, Las Palmas + field.capital-info: + intent: override + value: 'De to byene Santa Cruz på Tenerife og Las Palmas på Gran Canaria deler hovedstadsstatusen.' + expected_base: + value: The capital is shared between the two cities of Santa Cruz de Tenerife and Las Palmas. + note.cayman-islands: + intent: merge + fields: + field.capital-hint: + intent: override + value: Ikke selvstendig land + expected_base: + value: Not a sovereign country + note.corsica: + intent: merge + fields: + field.capital: + intent: override + value: Bastia i Haute-Corse og Ajaccio i Corse-du-Sud + expected_base: + value: Ajaccio + note.guadeloupe: + intent: merge + fields: + field.capital-hint: + intent: override + value: Ikke selvstendig land + expected_base: + value: Not a sovereign country + note.zanzibar: + intent: merge + fields: + field.capital: + intent: override + value: Zanzibar by + expected_base: + value: Zanzibar City diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/cs.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/cs.yaml new file mode 100644 index 0000000..5f75e15 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/cs.yaml @@ -0,0 +1,22 @@ +id: overlay.translation.hardcore.cs +kind: translation +translations: + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'Ju3]WYq65s' + 'd~3m`ihA*b': 'Ni[B!/Wc_H' + 'ga97y4Zy0{': 'BN*,Os+[o{' + 'i;=jdbhl(w': 'n*:!>x->:X' + 'iq7o#n0juD': 'M]Y[7L`(o2' + 'j=,1:f39$]': 'czIuoMXe-@' + 'k3]]2^DP1R': 'nJP%0B-.&0' + 'khTp#J9C!!': 'Lg*P{H@!&|' + 'mCh>s18~IX': 'HUgiJtE-@m' + mvNnHkGnVl: 'jg5b%hj_mx' + 'nN2' + 'rNv|0qxR]K': 'bRZEGpT*)f' + '~)7-62ytBX': 'O#]/)`FDUL' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/de.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/de.yaml new file mode 100644 index 0000000..28e1ae7 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/de.yaml @@ -0,0 +1,79 @@ +id: overlay.translation.hardcore.de +kind: translation +translations: + changes: + Autonomous city of Spain: + notes.note.melilla.fields.field.country-info: Autonome spanische Stadt. + Autonomous city of Spain.: + notes.note.ceuta.fields.field.country-info: Autonome spanische Stadt. + Autonomous orthodox monastic state in northeastern Greece, also known as the Holy Mountain.: + notes.note.mount-athos.fields.field.country-info: 'Autonomer orthodoxer Klosterstaat im Nordosten Griechenlands, auch bekannt als „Heilige Berg“.' + British Indian Ocean Territory: + notes.note.british-indian-ocean-territory.fields.field.country: Britisches Territorium im Indischen Ozean + Christmas Island: + notes.note.christmas-island.fields.field.country: Weihnachtsinsel + 'Cocos (Keeling) Islands': + notes.note.cocos-keeling-islands.fields.field.country: Kokosinseln + 'Dependent territory of New Zealand. Tokelau has no official capital; the meeting place of the Tokelau Council rotates annually.': + notes.note.tokelau.fields.field.country-info: 'Von Neuseeland abhängiges Gebiet. Tokelau hat keine offizielle Hauptstadt; der Treffpunkt des Tokelauer Rates wechselt jährlich.' + Easter Island: + notes.note.easter-island.fields.field.country: Osterinsel + Eruptions in 1997 destroyed the capital city of Plymouth, making Brades the de facto capital.: + notes.note.montserrat.fields.field.capital-info: 'Nach einer Reihe von Vulkanausbrüchen wurde die eigentliche Hauptstadt Plymouth 1997 aufgegeben und Brades zur de-facto-Hauptstadt.' + External territory of Australia.: + notes.note.christmas-island.fields.field.country-info: 'Australisches Außengebiet.' + notes.note.cocos-keeling-islands.fields.field.country-info: 'Australisches Außengebiet.' + notes.note.norfolk-island.fields.field.country-info: 'Außengebiet von Australien.' + 'Galápagos Islands': + notes.note.gal-pagos-islands.fields.field.country: Galapagosinseln + Mount Athos: + notes.note.mount-athos.fields.field.country: Athos + Norfolk Island: + notes.note.norfolk-island.fields.field.country: Norfolkinsel + Not a sovereign country: + notes.note.norfolk-island.fields.field.capital-hint: 'Kein souveräner Staat' + Overseas territory of France.: + notes.note.saint-barth-lemy.fields.field.country-info: 'Französisches Überseegebiet.' + Overseas territory of the United Kingdom.: + notes.note.british-indian-ocean-territory.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.montserrat.fields.field.country-info: 'Überseegebiet des Vereinigten Königreichs.' + notes.note.pitcairn-islands.fields.field.country-info: 'Britisches Überseegebiet.' + Part of Saint Helena, Ascension and Tristan da Cunha, an overseas territory of the United Kingdom.: + notes.note.saint-helena.fields.field.country-info: 'Teil von St. Helena, Ascension und Tristan da Cunha, einem Überseegebiet des Vereinigten Königreichs.' + Pitcairn Islands: + notes.note.pitcairn-islands.fields.field.country: Pitcairninseln + Polynesian island and special territory of Chile.: + notes.note.easter-island.fields.field.country-info: Polynesische Insel unter Chilenischer Regierung. + Province of Ecuador.: + notes.note.gal-pagos-islands.fields.field.country-info: Ecuadorianische Provinz. + 'Saint Barthélemy': + notes.note.saint-barth-lemy.fields.field.country: 'Saint-Barthélemy' + Saint Helena: + notes.note.saint-helena.fields.field.country: St. Helena + 'Sierra Leone (slightly lighter blue)': + notes.note.gal-pagos-islands.fields.field.flag-similarity: 'Sierra Leone (etwas helleres Blau)' + Special municipality: + notes.note.sint-eustatius.fields.field.capital-hint: Besondere Gemeinde + Special municipality of the Netherlands.: + notes.note.bonaire.fields.field.country-info: Besondere Gemeinde der Niederlande. + notes.note.saba.fields.field.country-info: Besondere Gemeinde der Niederlande. + notes.note.sint-eustatius.fields.field.country-info: Besondere Gemeinde der Niederlande. + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'MkUL%72%~W' + 'd~3m`ihA*b': 'LQy|aYu1j>' + 'ga97y4Zy0{': 'H3`{Wgs:76' + 'i;=jdbhl(w': 'Q.;-RG8ri4' + 'iq7o#n0juD': 'pan$P6;e=>' + 'j=,1:f39$]': 'Fpw!^G_FDy' + 'k3]]2^DP1R': 'qfk|s},0L9' + 'khTp#J9C!!': 'P@lPUyD7Lm' + 'mCh>s18~IX': 'PleM${.)8^' + mvNnHkGnVl: 's(%/V{:-A(' + 'nNM;lK%' + 'qAsU}ONgC#': 'z1k!ACW8^y' + 'r3?J{NB0J3': 'prEIV(Y5@o' + 'rHh8*,y.yc': 't!8/EM-' + 'rNv|0qxR]K': 'j_W6YP&/cN' + '~)7-62ytBX': 'L$iBOE&p}o' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/es.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/es.yaml new file mode 100644 index 0000000..ac88a6a --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/es.yaml @@ -0,0 +1,81 @@ +id: overlay.translation.hardcore.es +kind: translation +translations: + changes: + Autonomous city of Spain: + notes.note.melilla.fields.field.country-info: 'Ciudad autónoma de España.' + Autonomous city of Spain.: + notes.note.ceuta.fields.field.country-info: 'Ciudad autónoma de España.' + Autonomous orthodox monastic state in northeastern Greece, also known as the Holy Mountain.: + notes.note.mount-athos.fields.field.country-info: 'Estado monástico ortodoxo autónomo en la parte noreste de Grecia, también conocido como la Montaña Sagrada.' + British Indian Ocean Territory: + notes.note.british-indian-ocean-territory.fields.field.country: 'Territorio Británico del Océano Índico' + Christmas Island: + notes.note.christmas-island.fields.field.country: Isla de Navidad + 'Cocos (Keeling) Islands': + notes.note.cocos-keeling-islands.fields.field.country: Islas Cocos + 'Dependent territory of New Zealand. Tokelau has no official capital; the meeting place of the Tokelau Council rotates annually.': + notes.note.tokelau.fields.field.country-info: 'Territorio dependiente de Nueva Zelanda. Tokelau no tiene capital oficial; el sitio de encuentro del Consejo de Tokelau rota anualmente.' + Easter Island: + notes.note.easter-island.fields.field.country: Isla de Pascua + Eruptions in 1997 destroyed the capital city of Plymouth, making Brades the de facto capital.: + notes.note.montserrat.fields.field.capital-info: Las erupciones de 1997 destruyeron la capital de Plymouth, haciendo Brades la capital de facto. + External territory of Australia.: + notes.note.christmas-island.fields.field.country-info: Territorio externo de Australia. + notes.note.cocos-keeling-islands.fields.field.country-info: Territorio externo de Australia. + notes.note.norfolk-island.fields.field.country-info: Territorio externo de Australia. + 'Galápagos Islands': + notes.note.gal-pagos-islands.fields.field.country: 'Islas Galápagos' + Mount Athos: + notes.note.mount-athos.fields.field.country: Monte Athos + Norfolk Island: + notes.note.norfolk-island.fields.field.country: Isla Norfolk + Not a sovereign country: + notes.note.norfolk-island.fields.field.capital-hint: 'No es un país soberano' + Overseas territory of France.: + notes.note.saint-barth-lemy.fields.field.country-info: Territorio de ultramar de Francia. + Overseas territory of the United Kingdom.: + notes.note.british-indian-ocean-territory.fields.field.country-info: Territorio de ultramar del Reino Unido. + notes.note.montserrat.fields.field.country-info: Territorio de ultramar del Reino Unido. + notes.note.pitcairn-islands.fields.field.country-info: Territorio de ultramar del Reino Unido. + Part of Saint Helena, Ascension and Tristan da Cunha, an overseas territory of the United Kingdom.: + notes.note.saint-helena.fields.field.country-info: 'Parte de Santa Elena, Ascensión y Tristán de Acuña, un territorio de ultramar del Reino Unido.' + Pitcairn Islands: + notes.note.pitcairn-islands.fields.field.country: Islas Pitcairn + Polynesian island and special territory of Chile.: + notes.note.easter-island.fields.field.country-info: Isla de la Polinesia y territorio especial de Chile. + Province of Ecuador.: + notes.note.gal-pagos-islands.fields.field.country-info: Provincia de Ecuador. + 'Saint Barthélemy': + notes.note.saint-barth-lemy.fields.field.country: 'San Bartolomé' + Saint Helena: + notes.note.saint-helena.fields.field.country: Santa Helena + 'Sierra Leone (slightly lighter blue)': + notes.note.gal-pagos-islands.fields.field.flag-similarity: 'Sierra Leona (azul ligeramente más claro)' + Sint Eustatius: + notes.note.sint-eustatius.fields.field.country: San Eustaquio + Special municipality: + notes.note.sint-eustatius.fields.field.capital-hint: Es un municipio especial + Special municipality of the Netherlands.: + notes.note.bonaire.fields.field.country-info: 'Municipio especial de los Países Bajos.' + notes.note.saba.fields.field.country-info: 'Municipio especial de los Países Bajos.' + notes.note.sint-eustatius.fields.field.country-info: 'Municipio especial de los Países Bajos.' + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'k?SUMTaEzl' + 'd~3m`ihA*b': 'zN<)OWkrGG' + 'ga97y4Zy0{': 'N32K/E2ME^' + 'i;=jdbhl(w': 'JqG7,i}^M#' + 'iq7o#n0juD': 'Ed[OQ]II[]' + 'j=,1:f39$]': 'rKe&a,vxE:' + 'k3]]2^DP1R': 'zgEsqA)/M?' + 'khTp#J9C!!': 'n:Y4?FV.r9' + 'mCh>s18~IX': 'K/neGJj;{X' + mvNnHkGnVl: 'lcrrXX*L,[' + 'nN9VSV0}' + 'rHh8*,y.yc': 'MvAmJR.a*H' + 'rNv|0qxR]K': 'iVZ:94)BH<' + '~)7-62ytBX': 'p{AX:uXi_<' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/fr.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/fr.yaml new file mode 100644 index 0000000..975c244 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/fr.yaml @@ -0,0 +1,83 @@ +id: overlay.translation.hardcore.fr +kind: translation +translations: + changes: + Autonomous city of Spain: + notes.note.melilla.fields.field.country-info: 'Ville autonome d’Espagne.' + Autonomous city of Spain.: + notes.note.ceuta.fields.field.country-info: 'Ville autonome d’Espagne.' + Autonomous orthodox monastic state in northeastern Greece, also known as the Holy Mountain.: + notes.note.mount-athos.fields.field.country-info: 'République monastique autonome au nord-est de la Grêce, aussi connue comme la Sainte-Montagne.' + British Indian Ocean Territory: + notes.note.british-indian-ocean-territory.fields.field.country: 'Territoire britannique de l''océan Indien' + Christmas Island: + notes.note.christmas-island.fields.field.country: 'Île Christmas' + 'Cocos (Keeling) Islands': + notes.note.cocos-keeling-islands.fields.field.country: 'Îles Cocos' + 'Dependent territory of New Zealand. Tokelau has no official capital; the meeting place of the Tokelau Council rotates annually.': + notes.note.tokelau.fields.field.country-info: 'Territoire dépendant de la Nouvelle-Zélande. La capitale, de même que la présidence du Conseil permanent du Gouvernement, tournent entre les trois atolls.' + Easter Island: + notes.note.easter-island.fields.field.country: 'Île de Pâques' + Eruptions in 1997 destroyed the capital city of Plymouth, making Brades the de facto capital.: + notes.note.montserrat.fields.field.capital-info: 'Des éruptions en 1997 ont détruit la capitale Plymouth, faisant de Brades la capitale de fait.' + External territory of Australia.: + notes.note.christmas-island.fields.field.country-info: 'Territoire extérieur australien.' + notes.note.cocos-keeling-islands.fields.field.country-info: 'Territoire extérieur australien.' + notes.note.norfolk-island.fields.field.country-info: 'Territoire extérieur australien.' + 'Galápagos Islands': + notes.note.gal-pagos-islands.fields.field.country: 'Îles Galápagos' + Karyes: + notes.note.mount-athos.fields.field.capital: 'Karyès' + Mount Athos: + notes.note.mount-athos.fields.field.country: Mont Athos + Norfolk Island: + notes.note.norfolk-island.fields.field.country: 'Île Norfolk' + Not a sovereign country: + notes.note.norfolk-island.fields.field.capital-hint: Pas une nation souveraine + Overseas territory of France.: + notes.note.saint-barth-lemy.fields.field.country-info: 'Collectivité française d''outre-mer.' + Overseas territory of the United Kingdom.: + notes.note.british-indian-ocean-territory.fields.field.country-info: 'Territoire britannique d''outre-mer.' + notes.note.montserrat.fields.field.country-info: 'Territoire britannique d''outre-mer.' + notes.note.pitcairn-islands.fields.field.country-info: 'Territoire britannique d''outre-mer.' + Part of Saint Helena, Ascension and Tristan da Cunha, an overseas territory of the United Kingdom.: + notes.note.saint-helena.fields.field.country-info: 'Fait partie de Sainte-Hélène, Ascension et Tristan da Cunha, un territoire britannique d''outre-mer.' + Pitcairn Islands: + notes.note.pitcairn-islands.fields.field.country: 'Îles Pitcairn' + Polynesian island and special territory of Chile.: + notes.note.easter-island.fields.field.country-info: 'Île polynésienne et territoire spécial du Chili.' + Province of Ecuador.: + notes.note.gal-pagos-islands.fields.field.country-info: 'Province de l''Équateur.' + 'Saint Barthélemy': + notes.note.saint-barth-lemy.fields.field.country: 'Saint-Barthélemy' + Saint Helena: + notes.note.saint-helena.fields.field.country: 'Sainte-Hélène' + 'Sierra Leone (slightly lighter blue)': + notes.note.gal-pagos-islands.fields.field.flag-similarity: 'Sierra Leone (bleu légèrement plus clair)' + Sint Eustatius: + notes.note.sint-eustatius.fields.field.country: Saint-Eustache + Special municipality: + notes.note.sint-eustatius.fields.field.capital-hint: 'Commune à statut particulier' + Special municipality of the Netherlands.: + notes.note.bonaire.fields.field.country-info: 'Commune des Pays-Bas à statut particulier.' + notes.note.saba.fields.field.country-info: 'Commune des Pays-Bas à statut particulier.' + notes.note.sint-eustatius.fields.field.country-info: 'Commune des Pays-Bas à statut particulier.' + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'b{vct.#IZc' + 'd~3m`ihA*b': 'n&_>y_UGVj' + 'ga97y4Zy0{': 'j[t$1r]e!z' + 'i;=jdbhl(w': 'La@o-D7' + 'iq7o#n0juD': 'b(u8IoFmpT' + 'j=,1:f39$]': 'H@=}/tnJ+r' + 'k3]]2^DP1R': 'D&*IGrwa$z' + 'khTp#J9C!!': 'D%b^RAFZPX' + 'mCh>s18~IX': 't~:|totC7S' + mvNnHkGnVl: 'Ft%(~s0yZU' + 'nNh~F3D' + 'r3?J{NB0J3': 'ti,@|wZ>0w' + 'rHh8*,y.yc': 'Q~`$pP/xjA' + 'rNv|0qxR]K': 'B]FW$v]S+/' + '~)7-62ytBX': 'Iv0W>oAB^c' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/it.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/it.yaml new file mode 100644 index 0000000..d35e44a --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/it.yaml @@ -0,0 +1,22 @@ +id: overlay.translation.hardcore.it +kind: translation +translations: + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'n3X9D,,qq&' + 'd~3m`ihA*b': 'd5kTpV;w>8' + 'ga97y4Zy0{': 'dn-Q;H1&a6' + 'i;=jdbhl(w': 'n~BnbS~^PC' + 'iq7o#n0juD': 'o:fHe>s-zE' + 'j=,1:f39$]': 'rtTV>aanq;' + 'k3]]2^DP1R': 'M:s18~IX': 'GO^SB7bB_I' + mvNnHkGnVl: 'J-%2)|vJJh' + 'nNB;' + 'rHh8*,y.yc': tANaxvYd9 + 'rNv|0qxR]K': 'E}j_D&Q7mL' + '~)7-62ytBX': 'x!LQD,?v=4' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/nb.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/nb.yaml new file mode 100644 index 0000000..edb3adf --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/nb.yaml @@ -0,0 +1,79 @@ +id: overlay.translation.hardcore.nb +kind: translation +translations: + changes: + Autonomous city of Spain: + notes.note.melilla.fields.field.country-info: 'Spansk by og enklave på Marokkos middelhavskyst.' + Autonomous city of Spain.: + notes.note.ceuta.fields.field.country-info: Selvstyrt by i Spania. + Autonomous orthodox monastic state in northeastern Greece, also known as the Holy Mountain.: + notes.note.mount-athos.fields.field.country-info: 'Autonom munkerepublikk i Nord-Hellas. Også kalt Det hellige berget.' + British Indian Ocean Territory: + notes.note.british-indian-ocean-territory.fields.field.country: Det britiske territoriet i Indiahavet + Christmas Island: + notes.note.christmas-island.fields.field.country: 'Christmasøya' + 'Cocos (Keeling) Islands': + notes.note.cocos-keeling-islands.fields.field.country: 'Kokosøyene' + 'Dependent territory of New Zealand. Tokelau has no official capital; the meeting place of the Tokelau Council rotates annually.': + notes.note.tokelau.fields.field.country-info: Newzealandsk territorium. + Easter Island: + notes.note.easter-island.fields.field.country: 'Påskeøya' + Eruptions in 1997 destroyed the capital city of Plymouth, making Brades the de facto capital.: + notes.note.montserrat.fields.field.capital-info: 'Vulkanutbrudd i 1997 ødela hovedstaden Plymouth, og Brades ble de facto hovedstad.' + External territory of Australia.: + notes.note.christmas-island.fields.field.country-info: 'Australsk territorium. ' + notes.note.cocos-keeling-islands.fields.field.country-info: Australsk territorium. + notes.note.norfolk-island.fields.field.country-info: Selvstyrt australsk territorium. + 'Galápagos Islands': + notes.note.gal-pagos-islands.fields.field.country: 'Galápagosøyene' + Mount Athos: + notes.note.mount-athos.fields.field.country: Athos + Norfolk Island: + notes.note.norfolk-island.fields.field.country: 'Norfolkøya' + Not a sovereign country: + notes.note.norfolk-island.fields.field.capital-hint: Ikke selvstendig land + Overseas territory of France.: + notes.note.saint-barth-lemy.fields.field.country-info: 'Fransk oversjøisk land.' + Overseas territory of the United Kingdom.: + notes.note.british-indian-ocean-territory.fields.field.country-info: Britisk besittelse. + notes.note.montserrat.fields.field.country-info: 'Britisk oversjøisk territorium i Karibia.' + notes.note.pitcairn-islands.fields.field.country-info: 'Oversjøisk britisk territorium.' + Part of Saint Helena, Ascension and Tristan da Cunha, an overseas territory of the United Kingdom.: + notes.note.saint-helena.fields.field.country-info: 'Del av det britiske oversjøiske territoriet Sankt Helena, Ascencion og Tristan da Cunha.' + Pitcairn Islands: + notes.note.pitcairn-islands.fields.field.country: 'Pitcairnøyene' + Polynesian island and special territory of Chile.: + notes.note.easter-island.fields.field.country-info: Territorium i Chile. + Province of Ecuador.: + notes.note.gal-pagos-islands.fields.field.country-info: Region i Ecuador. + 'Saint Barthélemy': + notes.note.saint-barth-lemy.fields.field.country: 'Saint-Barthélemy' + Saint Helena: + notes.note.saint-helena.fields.field.country: Sankt Helena + 'Sierra Leone (slightly lighter blue)': + notes.note.gal-pagos-islands.fields.field.flag-similarity: 'Sierra Leone (noe lysere blå)' + Special municipality: + notes.note.sint-eustatius.fields.field.capital-hint: 'Kommune med særstatus' + Special municipality of the Netherlands.: + notes.note.bonaire.fields.field.country-info: Kommune med spesiell status i Nederland. + notes.note.saba.fields.field.country-info: 'Kommune med særstatus i Nederland. Øy i Karibia.' + notes.note.sint-eustatius.fields.field.country-info: 'Kommune med særstatus i Nederland.' + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': 'MbG/JnL:&+' + 'd~3m`ihA*b': 'r7?UN(TOGN' + 'ga97y4Zy0{': 'P`s}X]%%n.' + 'i;=jdbhl(w': 'jIz>`|hUIr' + 'iq7o#n0juD': 'DW#h,Zuo@q' + 'j=,1:f39$]': 'zi5;ldYAqH' + 'k3]]2^DP1R': EnAv,,VVPR + 'khTp#J9C!!': 'plI1yBs18~IX': 'vs18~IX': 'r{6o3nrs*;' + mvNnHkGnVl: 'Bv_gnaZ#pV' + 'nN|0p!->PF' + 'rHh8*,y.yc': 'uXd1K*pY=RSRGrI?' + 'k3]]2^DP1R': 'GhLqns_#`/' + 'khTp#J9C!!': 'dV/s]*7V,D' + 'mCh>s18~IX': 'GCESq|^pef' + mvNnHkGnVl: 'gO+<:S:)b,' + 'nN%|#(ccw' + 'iq7o#n0juD': 'D%[sR22:93' + 'j=,1:f39$]': 'pCQ/diK:{C' + 'k3]]2^DP1R': 'P-D1eiKP[T' + 'khTp#J9C!!': 'OPChG<,5/8' + 'mCh>s18~IX': 'GnD:(T#C' + '~)7-62ytBX': 'fJKeT[,~9>' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/ru.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/ru.yaml new file mode 100644 index 0000000..3531f4f --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/ru.yaml @@ -0,0 +1,22 @@ +id: overlay.translation.hardcore.ru +kind: translation +translations: + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': ':0I7n0Gm6' + 'd~3m`ihA*b': 'js18~IX': 'DL(Ab~)_EX' + mvNnHkGnVl: 'L]+nu>Yt>A' + 'nNs18~IX': 'A^!89R}DC*' + mvNnHkGnVl: 'M`X9g|b#NS' + 'nNW`j0' + 'rHh8*,y.yc': 'AH%Sos=Q%/' + 'rNv|0qxR]K': 'vFxoO]fhlN' + '~)7-62ytBX': 'f_}$_(^i4x' diff --git a/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/zh.yaml b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/zh.yaml new file mode 100644 index 0000000..385c4e8 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/extensions/hardcore/translations/zh.yaml @@ -0,0 +1,22 @@ +id: overlay.translation.hardcore.zh +kind: translation +translations: + adapter_ids: + crowdanki:guid: + 'ArmciP%a;a': MYBD5zZ93Q + 'd~3m`ihA*b': 's_TLGy3(#c' + 'ga97y4Zy0{': MEXI8oC8fw + 'i;=jdbhl(w': 'lLgJB4-Rl&' + 'iq7o#n0juD': 'K6gAD,Ys$-' + 'j=,1:f39$]': 'Lk811V}xOV' + 'k3]]2^DP1R': 'L$*!I-x1[H' + 'khTp#J9C!!': 'OY97X~F2>M' + 'mCh>s18~IX': 'x;K^MIlxB~' + mvNnHkGnVl: vluaMTZoBr + 'nN65' + 'E3u*2.<9#`': 'kn6vhJtt!Y' + 'EWXNcy5(sRng' + 'Lol+XIvY21': 'P4d|2~&E#h' + 'M$bc0[jYf0': 'Ngx+p%Tn$u' + 'O~pXkAbd3': 'IxLAH)+-_V' + 'QSq%~rFaP-': 'pi]:s4:9TG' + 'Qlib4)q2Hk': 'x;/B{evQ?m' + 'SlOEXfq#|': 'BU*(=#&k=b' + 'W5?6yQZD2': 'd`,;^d_nt5' + '_]DH+g9!$': 'urEGY*T)mW' + 'b@M[XTc?}5': 'hV^z0k.t6x' + 'bD?R}i8_[c': 'wsH3aZT9(O' + 'bW)ch:vs1P': 'k5Zby(Llz+' + 'b[(2`4t&?J': 'QP7}%SW/px' + 'b^Try&me1A': 't6K(T;};3}' + 'b`5Q_Z=HQT': 'pq=[Hn+x(t' + 'bd~Ght5tLR': 'z^#eqWYO-D' + 'bk2ByZIeU+': 'B&Pa`TK.W@' + 'bnEK#)[;qT': 'i:L[HB&@F[' + 'c(,2{yXI#E': 'G4t&;Hch6N' + 'c,5(mUGzYT': 'j.J_!d]H{c' + 'cAlyc~G.y%': 'MfwiBI+|=9' + 'cEO^y)Md[G': 'yKo`$PaHGg' + 'cL?8!xP#Wj': 's|EEBYpJ*s' + 'cPO|Cru8n<': 't2Q}Uw:@3V' + 'cV4idO^r8W': 'r]11B;H~[z' + 'cYKvF#ptF7': 'p2-()%uZj7' + 'c^,~EC6Pb2': 'ebha1T}R8+' + 'ck!6;xK$/^': 'D!9j=Ss[H)' + 'cv+Nq9:ab>D;vQoW' + 'd3c`&/1Y^D': 'wOmUda6!>t' + 'd5m8%D6,;u': 'LXl!1P9Nfp' + 'dC:e5G4J/p': 'x`*~=TA+FK' + 'dUZ^=SE5BG': 'u2@(1}y4a{' + 'dWA49p}F{k': 'b;o' + 'dY96`C-f(_': 'A7Cj*hqC7[' + 'd``B*6:eCx': 's{g7@QD$qr' + 'dbP/Cj}sB+': 'IToJAz{P9K' + 'de=9c:,@j9': 'e_%R[08+Z' + 'dlU7VG|3Gd': 'O_++P3q,lg' + 'd}R:qHj1,7': 'nsv]+(L(N^' + 'e!KSebP}pt': 'homhic06#&' + 'e+/O]%*qfk': 'C{]JF%%mr*' + 'e.t$Vi5,>f': 'BA4?dToa9]' + 'e42BpV,P}C': 'x(aw,hH&Y[' + 'e5P*D#I{m{': 'Qe#D4C[<+m' + 'eHH?U': 'v]C@D/(-+g' + 'euW0jm=wfR': 'Pmp+_CoZ`T' + 'e{e8vi@^PY': 'b:(mUH@(`.' + 'f#4-L[mp5*': 'IoHWaA78;3' + 'f*2@qiHh_1': 'g.N+uMm,{6' + 'f*LO~>!0PN': 'l[/(,BJdiJ' + 'f21=-A~W;~': 'k[^`V_WQ^I' + 'f>c7x2E]Q6': 'PSEKL{AHnb' + 'fRSM{K+;[w': 'uWjhG1h]m' + 'fT!w8R#>dg': 'Ja&@${-ll#' + 'fV`&p81%d(': 'Fx%kVt2%kj' + fkq7lhLkdH: 'Pv4nPI$/4$' + 'fxAN),FAc$': 'zWj%QH!yMnZjBl' + 'gFOojQO(MH': 'vI7@NGK_Rr' + 'gG6SWfM>H0': 'f#ZCPMD62V' + 'gRVZ]qJ#>O': pvm.S00zT/ + 'gYIoR`|AxW': 'm-W32Cjt+v' + 'gePC:Xt_nW': dt/qfm,mMR + 'gp[h@]y13l': 'nLS!c^B.u5' + 'gsS{jR,]|Q': 'v8pkKiW5;U' + 'gx:;za!?C9': 'uJWy?`0JRW' + 'gz9${p}er*': 'noQ|9=PqcG' + 'g~%pF`(x{u': 'v0.(wt0=5,' + 'h&`>n98~V1': 'qjIBt_WC^1' + 'h9Jr(H[=1~': 'qq/hAV~,t/' + 'h?a*UKfT_M': 'Hfw[5^1FXT' + 'hE5j*e,Q?[': 'lJGFm$U&>?' + 'hNE9}>;Q&-': 'zT0L2L~T>H' + 'hXNTVF<:SH': 'y*>>K_Rv{P' + 'hZ/V/D&rO`': 'j1e,tg|Z&b' + 'h_9=mkZXAH': 'Lh8)I$R;q>' + hoHandbYAy: 'L>tNacmJP#' + 'hrI_uiW>E]': 'o-iAF(Oe;d' + 'h}lnSN_f$_': 'H,;72`%LJX' + 'h~5xz+=ke~': 'CzhyKE{%zy' + 'i)]+09JUR&': 'tOc]hT~n}*' + 'i2]v|D3@:j': 'wO[~:de]T' + 'iB-8Br~onq': 'G4|N9mDxa&' + 'iIS#Nw]tr]': 'j}.YHgP+1)' + 'iR;Um(L/~c=': 'juCeYT&eX9' + 'i^J8&$(1]|': 'vg&(+g]]yT' + 'ia8as9sCY|': 'p|~)@{)+S*' + 'igiK,ff[eO': 'bfu8gGy]?r' + 'ijP>aJBT!9': 'Hb~5J(.Fg~' + 'ilGtw#=asM': 'l]+1ZOhWt<' + 'j%*8gN)q>d': 'JJzRb!:b#h' + 'j)i)pB*HJ,': 'Kx0E[9ibDy' + 'j2IQ=f=w3@': 'DBo3+cT-[:' + j7eEZkzsCZ: 'BCT|*-aN8t' + 'j9:K,~DQ2v': 'MY>?+-m40S' + 'jg|}': 'za4sz~iaR*' + 'jCd]`-=k,:': 'uv?Z2p<%;s' + 'jDA-V?/hVj': 'wM29>6E7w2' + jI9P-f6r3M: 'DE`CrM74%0' + 'jKIDq|}J&c': 'i9-RjgM[_,' + 'jKO,9y0M;#': 'AnTl}_8m=/' + 'jN^Vc%9OQ5': 'L!)340&$>j' + 'jN|NAUP*h}': 'rNr_y>SmaNv-o' + 'j|z@PMgdx,': 'vt!WOLxZl$' + 'k$1_yaF?9#': 'Aq@4iLUo[t' + 'k/u1:B%DJH': 'tcsX~cQjI2' + 'k0{O[6l]bH': LYe,4PfG4a + 'k1]FH+s8j@': 'B>y*UU741f' + 'k9E.p^UY=~g;v' + 'kX]hN=_.c-': 'G5M^3c_/X|' + 'kZ52zV#[Iv': 'HqC57OPq[I' + 'kopwzzB``A': sv5mAkY3Kf + 'k}4/oj#$~s': 'P,t][$~Axv' + 'k}A9]O:0xw': 'Q`OBBA(IqJ' + 'l$2O|9w`F~': 'M*;rH]4K.>' + 'l$zM~ihPFE': 'D8N9aGT(YL' + 'lI)Ah/E': 'p2,K;bOB~T' + 'l*j*EE:!Y?': 'dET^[R~4O3' + 'l+>Ki!j#v+': 'C%r3!}*o9-' + 'l3Ly8PBxt(': 'j^9KkW@1V^' + 'lAxH.CfJB@': 'stl,m~cAO&' + 'lH^2D]w;[H': 'm~5*]PDD9k' + 'lU@C3' + 'm*#%sE.`.;': 'qd!suvo&1v' + 'm*PYE=#wr^': 'CO+b,zPO_I' + 'm2F|cUkKhv': 'ddf7bzEG~m' + 'm5j,b/y`:m' + 'mk1VltJl<]': 'c@Ms0SL)sn' + 'mk20`,N=`=': 'Byv;->|tVn' + 'mkgcU7[bA%': 'dr(`ZC7T|g>': 'bhv8b|]kY9' + 'mz[>>AxIr4': 'Pqi/=VQrpE' + 'm{noqE29%I': 'p;&<%/w5b|' + 'm{oM,jr>C#': 'h~hx$TrJ9L' + 'n&>e`2FO/S': jDvPRSN7UT + 'n+t>2`:;P/': 'nG#=Zxp5Qn' + 'n:7&>YT5A=': 'xMDjNS>?hL' + 'nIfRncS>' + oV/JG7fwZq: 'Hm4bJ/yz': 'sg,bj=%n1w' + 'oa87&PB&Bm': 'y9[[$e%qC`' + 'onMKID2d;D': 'm.mLjg-{lM' + 'o~}KVA:VbI': 'BAz%}9{UE' + 'p#R$-mG/L': 'f[E%u}3F%e' + 'p.yv>BQ470': 'Nz_#ah$j&d' + 'p:SPZ:e/*[': 'C]DrFhd%u': 'G4?YxB/g': 'nlr&VdfBJA' + 'q2#Yv[O0:|': 'uq)KM}CVsF' + 'q7do-wLx%E': 'GJT]wWpqv6' + 'q8][)Ri}=q': 'm|F4][J.!m' + 'q8fF$4,c6_': 'Qt7ILf!6y2' + 'q:XyGf#>Wg': 'c#$?0jnW0K' + 'q=w[iu~' + 'q[}7hk(+=e': 'k`{m~8Eb`.' + 'qiv/4tOlf=': 'k~[RV@fB$d+$': 'vlkjlV^U~T' + 'r(h9&#wc~B': 'h:!&t0Lvqc' + 'r/B|ra%L,.': 'bbW|f)4_>u' + 'r5C646H*+*': 'B*Me@Zh_[S' + 'r6BV%|..*5': 'Cob1W^QJ3F' + 'raI-$$|wT': 'pbX.~.6OJO' + 'r>g<|,FWi%': 'Rh_*(xz;/]' + 'r?E$4f-d0/': 'fPO>]fFure' + 'rPekx@n9:L': 'K4#|XaqO:g' + 'rSneU!3}bn': 'c!B%9S]zNK' + 'r]*Ioa%$bK': 'I[3;:2u9!5' + 's#:aIK$9^x': 'g-3%G(QyEf' + 's4E?ZF[E<>': 'dL;ASbD>B}' + 's9-GL*@AXD': 'uMj[iKx*FT' + 's<_WLSB3I/': 'l]B_A,?ph!' + 's>f40rn,O]': 'HXMK8^/pUt' + 'sB7rZQsR<1FFUo' + sM0GPt1HS1: 'I_$^q=J15;' + 'sRw-ik:I$v': 'v6t>$@9w~i^p': 'iR6^@_]dvR' + 'skOM(?n$GG': 'kC{ALA>P%a' + spKoFRM4if: 'sLc+1O/t|!' + 'ssbyA9}]QP': 'Jf_>OgL@4?' + 'stRa/S(0$F': 'wC(@I#Qj=_' + 'sxOMs+bE8@': 'vFrU{gPXOA' + 'sx|l+io`B@': 'z.;^Kjw?w-' + 's|K&2S,UP|': 'mH(F;!A-^i' + 't$y1C2R-Av': 'gsF]bu>P': 'P*^/<)(KjW' + 't2&ri|[Un:': 'o!L7Ln:)+/' + t3aZXtpNyi: '^@~8x/eFp' + 't50y/[AEhF': 'FQm}Y9aG^*' + 'tMMtBUpb$&': 'CP|BiC2[I' + 't`cagaWApo': 'bu.JnK@qu%' + 'tcneuK7v%`': 'c0H>DK:hY.' + tfCY4j6KEZ: 'O_`cb-?a@m': 'KM9;RF{k!2cXc': 'wsB.ZRe68:' + x2d4n4ADcP: 'Rf#Y$PY2hD' + 'xb&P*pH(Q': 'FUCfa4kb{|K' + 'zSc]-^U=0=': 'l1uc5E8j;X' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: 47d13c81-b471-4471-8e87-ebad53fa7307 diff --git a/fixtures/ultimate-geography/overlays/languages/da.yaml b/fixtures/ultimate-geography/overlays/languages/da.yaml new file mode 100644 index 0000000..88a7315 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/da.yaml @@ -0,0 +1,703 @@ +id: overlay.translation.da +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Der eksisterer en navnekonflikt mellem landene ved havet, hvor Sydkorea især støtter navnet Østhavet.' + Abkhazia: Abkhasien + Addis Ababa: Addis Abeba + Adriatic Sea: Adriaterhavet + Aegean Sea: 'Ægæiske Hav' + Africa: Afrika + Akrotiri and Dhekelia: Akrotiri og Dhekelia + Albania: Albanien + Algeria: Algeriet + Algiers: Algier + Also known as Burma.: 'Også kendt som Burma.' + Also known as Cabo Verde.: '' + Also known as Czechia.: '' + Also known as East Timor.: 'Også kendt som Timor-Leste.' + Also known as Kiev.: 'Også kendt som Kiev.' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: 'Også kendt som Siambugten.' + Also known as the Sea of Cortez.: '' + 'Also spelled as Sana''a.': '' + American Samoa: Amerikansk Samoa + 'Andorra (narrower, coat of arms with motto)': 'Andora (smallere, våbenskjold med motto)' + Antarctica: Antarktis + Antigua and Barbuda: Antigua og Barbuda + Arabian Sea: Det Arabiske Hav + Aral Sea: 'Aralsøen' + Arctic Ocean: Det Nordlige Ishav + Armenia: Armenien + Ashgabat: Asjkhabad + Asia: Asien + Athens: Athen + Atlantic Ocean: Atlanterhavet + Australia: Australien + 'Australia (white stars, two more stars)': 'Australien (hvide stjerner, to stjerner mere)' + Austria: 'Østrig' + 'Austria (brighter red, wider white band)': 'Østrig (lysere rød, bredere hvid stribe)' + Autonomous community of Spain.: Selvstyrende region af Spanien. + Autonomous province of South Korea.: Selvstyrende provins i Sydkorea. + Autonomous region of Finland.: Selvstyrende region i Finland. + Autonomous region of Italy.: Selvstyrende region i Italien. + Autonomous region of Papua New Guinea.: Selvstyrende region i Papua New Guinea. + Autonomous region of Portugal.: + notes.note.azores.fields.field.country-info: 'Selvstændig region Autonomous region i Portugal.' + notes.note.madeira.fields.field.country-info: 'Selvstændig region i Portugal.' + Azerbaijan: Aserbajdsjan + Azores: Azorerne + Baghdad: Bagdad + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrain (smallere, færre takkede kanter, rød)' + Balkan Peninsula: 'Balkanhalvøen' + Baltic Sea: 'Østersøen' + Banda Sea: Bandahavet + Barents Sea: Barentshavet + Bay of Bengal: Bengalske Bugt + Bay of Biscay: Biscayabugten + Belgium: Belgien + Belgrade: Beograd + Bering Strait: 'Beringstrædet' + Bishkek: Bisjkek + Black Sea: Sortehavet + 'Bolivia (coat of arms instead of star)': 'Bolivia (våbenskjold i stedet for stjerne)' + Bosnia and Herzegovina: Bosnien-Hercegovina + 'Brasília': Brasilia + Brazil: Brasilien + British Virgin Islands: 'De Britiske Jomfruøer' + Brussels: Bruxelles + Bucharest: Bukarest + Bulgaria: Bulgarien + Cambodia: Cambodja + Cameroon: Cameroun + 'Cameroon (green/red/yellow, yellow star)': 'Cameroun (grøn/rød/gul, gul stjerne)' + Canary Islands: 'De Kanariske Øer' + Cape Verde: Kap Verde + Caribbean Sea: Caribiske Hav + Caspian Sea: Kaspiske Hav + Cayman Islands: 'Caymanøerne' + Celebes Sea: Celebeshavet + Celtic Sea: Det Keltiske hav + Central African Republic: Den Centralafrikanske Republik + Cetinje is an honorary capital.: Cetinje har status som Montenegros gamle royale hovedstad. + Chad: Tchad + 'Chad (slightly darker blue)': 'Tchad (lidt mørkere blå)' + China: Kina + 'Chișinău': Chisinau + City of San Marino: San Marino + Claimed and controlled: 'Gjort krav på og kontrolleret' + Claimed but not controlled: 'Gjort krav på, men ikke kontrolleret' + 'Colombia (no coat of arms)': 'Colombia (intet våbenskjold)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Colombo bliver ofte benævnt som hovedstad, men Sri Jayawardenapura Kotte, en forstad til Colombo, er den officielle hovedstad.' + Comoros: Comorerne + Constituent country in the Kingdom of Denmark.: 'Selvstyrende nation i Det Danske Rigsfællesskab.' + Constituent country of the Kingdom of the Netherlands.: 'Selvstændig stat i Kongeriget Nederlandene.' + Constituent country of the United Kingdom.: Delvis selvstyrende nation i Storbritannien. + Cook Islands: 'Cookøerne' + Copenhagen: 'København' + Coral Sea: Koralhavet + Corsica: Korsika + Croatia: Kroatien + Crown dependency of the United Kingdom.: Britisk kronbesiddelse. + 'Cuba (red triangle, blue stripes)': 'Cuba (rød trekant, blå striber)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (to stjerne i øverste venstre hjørne)' + Cyprus: Cypern + Czech Republic: Tjekkiet + Damascus: Damaskus + Dead Sea: 'Dødehavet' + Democratic Republic of the Congo: Den Demokratiske Republik Congo + Denmark: Danmark + Denmark Strait: 'Danmarksstrædet' + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Omtvistet; gjort krav på af Israel; Ramallah er det administrative centrum.' + 'Disputed; claimed by Palestine.': 'Omtvistet; gjort krav på af Palæstina.' + Dominican Republic: Den Dominikanske Republik + Dushanbe: Dusjanbe + East China Sea: 'Det Østkinesiske Hav' + East Siberian Sea: 'Det Østsibiriske Hav' + 'Ecuador (with coat of arms)': 'Ecuador (med våbenskjold)' + Egypt: 'Ægypten' + 'Egypt (emblem instead of text), Yemen (no text)': 'Ægypten (emblem i stedet for tekst), Yemen (ingen tekst)' + 'Egypt (with emblem), Iraq (with text)': 'Ægypten (med emblem), Irak (med tekst)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (forskelligt våbenskjold, lidt mørkere blå)' + English Channel: Den Engelske Kanal + Equatorial Guinea: 'Ækvatorialguinea' + Estonia: Estland + Ethiopia: Etiopien + Europe: Europa + European Union: 'Den Europæiske Union' + Falkland Islands: 'Falklandsøerne' + Faroe Islands: 'Færøerne' + Federated States of Micronesia: Mikronesien + Formerly Zaire.: Tidligere kendt som Zaire. + Formerly known as Macedonia.: Tidligere kendt som Makedonien. + France: Frankrig + French Guiana: Fransk Guyana + French Polynesia: Fransk Polynesien + Georgia: Georgien + Germany: Tyskland + 'Ghana (star instead of coat of arms)': 'Ghana (stjerner i stedet for våbenskjold)' + Greece: 'Grækenland' + Greenland: 'Grønland' + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (grøn og rød byttet rundt, lidt mørkere grøn)' + Gulf of Alaska: Alaskagolfen + Gulf of California: Den Californiske Havbugt + Gulf of Carpentaria: Carpentariabugten + Gulf of Guinea: Guineabugten + Gulf of Mexico: Den Mexicanske Golf + Gulf of Thailand: Thailandbugten + 'Hagåtña': Agana + Havana: Havanna + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: Historisk og kulturel region i Nordeuropa som indeholder landene Danmark, Norge og Sverige, og nogle gange Finland og Island. + Hong Kong: HongKong + Hudson Bay: Hudsonbugten + Hungary: Ungarn + Iceland: Island + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Island (blå baggrund, rødt og hvidt kors), Norge (rød baggrund, blåt og hvidt kors)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Island (blå baggrun, rødt kors), Færøerne (hvid baggrund, rødt og blåt kors)' + Independent state claimed by Georgia.: 'Uafhængig stat som Georgien gør krav på.' + Independent state claimed by Moldova.: 'Uafhængig stat som Moldova gør krav på.' + Independent state claimed by Somalia.: 'Uafhængig stat som Somalia gør krav på.' + India: Indien + Indian Ocean: Det Indiske Ocean + Indonesia: Indonesien + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesien (hvid og rød byttet rundt, lysere rød), Monaco (hvid og rød byttet rundt, smallere)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesien (bredere, lysere rød), Polen (rød og hvid byttet rundt, bredere)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (tekst i stedet for emblem), Yemen (intet emblem)' + Ireland: Irland + 'Ireland (orange and green flipped, wider)': 'Irland (orange og grøn byttet rundt, bredere)' + Island of Indonesia.: 'Ø i Indonesien.' + Italy: Italien + Ivory Coast: Elfenbenskysten + 'Ivory Coast (green and orange flipped, narrower)': 'Elfenbenskysten (grøn og orange byttet rundt, smallere)' + Jakarta: Djakarta + Jeju: Cheju-do + Kathmandu: Katmandu + Kazakhstan: Kasakhstan + Known as Nur-Sultan between 2019 and 2022: Kendt som Nur-Sultan mellem 2019 og 2022 + Known as Swaziland until 2018.: Kendt som Swaziland til 2018. + Kuwait City: Kuwait + Kyrgyzstan: Kirgisistan + Laayoune: El Aaiun + Labrador Sea: Labradorhavet + Latvia: Letland + 'Latvia (darker red, narrower white band)': 'Letland (mørkere rød, smallere hvid stribe)' + Lebanon: Libanon + Libya: Libyen + Lisbon: Lissabon + Lithuania: Litauen + 'Luxembourg (lighter blue)': 'Luxembourg (lysere blå)' + Luxembourg City: Luxembourg + Macau: Macao + Madagascar: Madagaskar + Maldives: Maldiverne + 'Mali (red and green flipped, slightly brighter green)': 'Mali (rød og grøn byttet rundt, lidt lysere grøn)' + Marshall Islands: 'Marshalløerne' + Mauritania: Mauretanien + Mediterranean Sea: Middelhavet + Melanesia: Melanesien + Micronesia: Mikronesien + 'Moldova (wider, coat of arms with eagle)': 'Moldova (bredere, våbenskjold med ørn)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (smallere, mørkere rød), Polen (rød og hvid byttet rundt, mørkere rød)' + Mongolia: Mongoliet + Morocco: Marokko + Moscow: Moskva + 'N''Djamena': Ndjamena + 'Nauru (single star below yellow band)': 'Nauru (enkelt stjerne under gul stribe)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru har ingen officiel hovedstad; Yaren distriktet er de facto hovedstad.' + Netherlands: Nederlandene + 'Netherlands (darker blue)': 'Nederlandene (mørkere blå)' + New Caledonia: Ny Kaledonien + 'New Zealand (red stars, two fewer stars)': 'New Zealand (røde stjerner, to færre stjerner)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (forskelligt våbenskjold, lidt lysere blå)' + North America: Nordamerika + North Korea: Nordkorea + North Macedonia: Nordmakedonien + North Nicosia: Nordnicosia + North Sea: 'Nordsøen' + Northern Cyprus: Nordcypern + Northern Ireland: Nordirland + Northern Mariana Islands: Nordmarianerne + Norway: Norge + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norge (rød baggrund, blåt og hvidt kors), Færøerne (hvid baggrund, rødt og blåt kors)' + Norwegian Sea: Norskehavet + Not a sovereign country: 'Ikke et selvstændigt land' + 'Nukuʻalofa': Nukualofa + 'Oblast (administrative region) of the Russian Federation.': Administrationsenhed i Rusland. + Oceania: Oceanien + Official capital was moved from Bujumbura to Gitega in 2019.: Den officielle hovedstad blev flyttet fra Bujumbura til Gitega i 2019. + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: Den officielle hovedstad blev flyttet fra Malabo til Ciudad de la Paz i 2026. + 'Officially Côte d''Ivoire.': 'Officielt Cote d’Ivoire.' + Officially Luxembourg.: '' + Overseas department of France.: 'Fransk oversøisk departement.' + Overseas territory of France.: + notes.note.french-polynesia.fields.field.country-info: 'Fransk oversøisk territorie.' + notes.note.new-caledonia.fields.field.country-info: 'Fransk oversøisk departement.' + notes.note.saint-martin.fields.field.country-info: 'Fransk oversøisk departement.' + notes.note.wallis-and-futuna.fields.field.country-info: 'Fransk oversøisk departement.' + Overseas territory of the United Kingdom.: + notes.note.akrotiri-and-dhekelia.fields.field.country-info: 'Britisk oversøisk territorie.' + notes.note.anguilla.fields.field.country-info: 'Oversøisk territorie i Storbritannien.' + notes.note.bermuda.fields.field.country-info: Delvis selvstyrende nation i Storbritannien. + notes.note.british-virgin-islands.fields.field.country-info: 'Oversøisk territorie i Storbritannien.' + notes.note.cayman-islands.fields.field.country-info: 'Oversøisk territorie i Storbritannien.' + notes.note.falkland-islands.fields.field.country-info: 'Britisk oversøisk territorie.' + notes.note.gibraltar.fields.field.country-info: 'Oversøisk territorie i Storbritannien.' + notes.note.turks-and-caicos-islands.fields.field.country-info: 'Oversøisk territorie i Storbritannien.' + Pacific Ocean: Stillehavet + Palestine: 'Palæstina' + 'Palestine (black/white/green, red arrow)': 'Palæstina (sort/hvid/grøn, rød pil)' + 'Palestine (no symbol)': 'Palæstina (intet symbol)' + Papua New Guinea: Papua Ny Guinea + Partially recognised state claimed by China.: 'Delvist anerkendt stat som Kina gør krav på.' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'Delvist anerkendt stat som Marokko gør krav på. Også kendt som Saharawi Arabiske Demokratiske Republik.' + Partially recognised state claimed by Serbia.: 'Delvist anerkendt stat som Serbien gør krav på.' + Persian Gulf: Persiske Bugt + Philippine Sea: Filippinske Hav + Philippines: Filippinerne + Poland: Polen + Polynesia: Polynesien + Porto-Novo: Porto Novo + Prague: Prag + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (blå trekant, røde striber)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (bredere, flere takkede kanter, kastanjabrun)' + Red Sea: 'Det Røde Hav' + Region of France.: Region i Frankrig. + Republic of the Congo: Republikken Congo + Romania: 'Rumænien' + 'Romania (slightly lighter blue)': 'Rumænien (lidt lysere blå)' + Rome: Rom + Russia: Rusland + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Rusland (intet våbenskjold), Slovenien (bredere, mindre våbenskjold)' + Sahrawi Arab Democratic Republic: Vestsahara + Saint Kitts and Nevis: Saint Kitts og Nevis + Saint Martin: Saint-Martin + Saint Vincent and the Grenadines: Saint Vincent og Grenadinerne + Sanaa: Sana + Sardinia: Sardinien + Saudi Arabia: Saudi-Arabien + Scandinavia: Skandinavien + Scotland: Skotland + Sea of Galilee: 'Genesaret Sø' + Sea of Japan: Japanske Hav + Sea of Okhotsk: Det Okhotske Hav + Semi-autonomous region of Tanzania.: Semi-autonom del af Tanzania. + 'Senegal (green/yellow/red, green star)': 'Senegal (grøn/gul/rød, grøn stjerne)' + Serbia: Serbien + Seychelles: Seychellerne + Sicily: Sicilien + Sint Maarten: Sint-Maarten + Slovakia: Slovakiet + 'Slovakia (narrower, bigger coat of arms)': 'Slovakiet (smallere, større våbenskjold)' + 'Slovakia (with coat of arms)': 'Slovakiet (med våbenskjold)' + Slovenia: Slovenien + Solomon Islands: 'Salomonøerne' + South Africa: Sydafrika + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Sydafrika har ingen juridisk defineret hovedstad: regeringens grene er delt i tre byer: Pretoria (udøvende), Cape Town (lovgivende) og Bloemfontein (dømmende).' + South America: Sydamerika + South China Sea: Det Sydkinesiske Hav + South Korea: Sydkorea + South Ossetia: Sydossetien + South Sudan: Sydsudan + South Tarawa: Sydtarawa + Southern Ocean: Det Sydlige Ishav + Sovereign country: 'Selvstændigt land' + Spain: Spanien + Special Administrative Region of China.: 'Særlig administrativ region i Kina.' + Sri Jayawardenepura Kotte: Sri Jayawardenapura Kotte + 'St. George''s': 'St. George’s' + 'St. John''s': 'St. John’s' + State of the United States.: Amerikansk stat. + State recognised only by Turkey and claimed by Cyprus.: 'Stat som kun bliver anerkendt af Tyrkiet og som Cypern gør krav på.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Område af Oceanien som indeholder tusindvis af små øer i det centrale og sydlige Stillehav.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Område af Oceanien der indeholder tusindvis af små øer i det vestlige Stillehav.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Område af Oceanien som indeholder Vanuatu, Salomonøerne, Fiji og Papua Ny Guinea.' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudan (rød/hvid/sort, grøn pil), Vestsahara (med stjerne og halvmåne)' + Suriname: Surinam + Sweden: Sverige + Switzerland: Schweiz + 'Switzerland has no official capital; Bern is the de facto capital.': 'Schweiz har ingen officiel hovedstad; Bern er de facto hovedstad.' + Syria: Syrien + 'São Tomé and Príncipe': 'São Tomé og Príncipe' + Tajikistan: Tadsjikistan + Tashkent: Tasjkent + Tasman Sea: Tasmanske Hav + Tehran: Teheran + The Bahamas: Bahamas + The Gambia: Gambia + Thimphu: Timphu + Timor Sea: Timorhavet + Timor-Leste: 'Østtimor' + Transnistria: Transnistrien + Trinidad and Tobago: Trinidad og Tobago + Tunisia: Tunesien + Turkey: Tyrkiet + Turks and Caicos Islands: 'Turks- og Caicosøerne' + 'Tórshavn': Thorshavn + Ulaanbaatar: Ulan Bator + Unincorporated internal area of Norway.: 'Norsk øgruppe.' + Unincorporated territory of the United States.: 'Et såkaldt Unincorporated Territory under USA.' + United Arab Emirates: De Forenede Arabiske Emirater + United Kingdom: Storbritannien + United States Virgin Islands: 'De Amerikanske Jomfruøer' + United States of America: USA + Uzbekistan: Usbekistan + Vatican City: Vatikanstaten + Vienna: Wien + Wallis and Futuna: Wallis og Futuna + Warsaw: Warszawa + Washington, D.C.: Washington + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Selvom Amsterdam er den forfatningsmæssige hovedstad, ligger regeringen i Haag.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Selvom Dodoma er den officielle hovedstad er Dar es Salaam de facto sæde for regeringen government.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Selvom El Aaiun er den erklærede hovedstad er Tifariti de facto sæde for regeringen.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Selvom Mbabane er den officielle og udøvende hovedstad er Lobamba den traditionelle, sprituelle og lovgivende hovedstad.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Selvom Porto-Novo er den officielle hovedstad, er Cotonou de facto sæde for regeringen.' + While Sucre is the constitutional capital, La Paz is the seat of government.: 'Sucre er officiel hovedstad, mens La Paz er regeringssædet.' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Selvom Yamoussoukro er den officielle hovedstad er Abidjan de facto sæde for regeringen.' + White Sea: Hvidehavet + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Verdensdel som dækker det Australske kontinent og de fleste af øerne i Stillehavet.' + Yellow Sea: Gule Hav + Yerevan: Jerevan + 'Åland Islands': 'Åland' + additions: + notes.note.arctic-ocean.fields.field.country-info: 'Også kendt som Det Arktiske Ocean.' + notes.note.balkan-peninsula.fields.field.country-info: 'Også kendt som Balkan.' + notes.note.baltic-sea.fields.field.country-info: 'Også kaldt Baltiske Hav.' + notes.note.belarus.fields.field.country-info: 'Også kendt som Hviderusland. Udenrigsministeriet er begyndt at bruge betegnelsen Belarus i marts 2021.' + notes.note.netherlands.fields.field.country-info: 'Også kendt som Holland.' + notes.note.united-kingdom.fields.field.country-info: 'Officielt benævnt Det Forenede Kongerige Storbritannien og Nordirland.' + notes.note.united-states-of-america.fields.field.country-info: Formelt Amerikas Forenede Stater. + variables: + label.capital: + Capital: Hovedstad + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Ledetråd: {{Capital hint}}' + label.location: + Location: Placering + note-type.name: + Ultimate Geography: 'Ultimate Geography [DA]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Flaget ligner {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'Kb<43(~@]3' + '5JJ2]i)p4': 'K2q=PpJ[C>' + '5S(f7%O-$': 'lO0wObq%O:' + '?n!)c/h=Q': 'l`9rE:$x+g' + 'AES],s:8=': 'x@&HED.U@#' + 'B:x`Z' + 'E3u*2.<9#`': 'sg.X:y`!db' + 'EWXNcTLrI' + 'G/^_##fcPN': 'OD>z~q-UNy' + 'Gn;q2Y)Xe4': 'D].(J{*+wj' + 'H*c[ML+`s=': 'q/Q9$4A[.0' + 'He!e=h?_2V': 'EOL]eWfA}#' + 'J%,fuysl*&': 'xp$uSWv4N4' + 'JOT:U4ayh@': 'h,wvQiW}f.' + 'K*b}' + 'L5M1*yG7eH': 'INxYDi%#ji' + LPF4AfaHKR: 'J+[lvz&;-,' + 'Lol+XIvY21': 'op{,HY,R^K' + 'M$bc0[jYf0': 'kIX7j=SKtG' + 'O~pXkAbd3': 'vzrC7`Rysy' + 'QSq%~rFaP-': 'F#aFYkho+R' + 'Qlib4)q2Hk': 'Oa[MIj/dzh' + 'SlOEXfq#|': 's&2|=WcRuB' + 'W5?6yQZD2': 'x!QK:aR+Kd' + '_]DH+g9!$': 'e6LM|~Rxa(' + 'b@M[XTc?}5': 'I|/.Z?idjd' + 'bD?R}i8_[c': 'GvAa_Oi{c}' + 'bW)ch:vs1P': Ql49tXZ51x + 'b[(2`4t&?J': 'JA26u,2`hg' + 'b^Try&me1A': 'ICS}K]:uYU' + 'b`5Q_Z=HQT': 'kwXL1I+,Te' + 'bd~Ght5tLR': 'soL3XG}20w' + 'bk2ByZIeU+': /E5Rk.RSJ + 'bnEK#)[;qT': 'm&XT%bsGw(' + 'c(,2{yXI#E': rt5LkWRaLo + 'c,5(mUGzYT': 'Ou]hx.]gl>' + 'cAlyc~G.y%': 'fuUqIYrwq`' + 'cEO^y)Md[G': 'jGWB8^WXiP' + 'cL?8!xP#Wj': 'rPc:I:)OP!' + 'cPO|Cru8n<': 'mspp{o}BqI' + 'cV4idO^r8W': 'i.e%#9&x' + 'dUZ^=SE5BG': 'vv!U3<)YQd' + 'dWA49p}F{k': 'CdND;!8x4h' + 'dY96`C-f(_': 'd.8!=y^r?T' + 'd``B*6:eCx': 'DZ4H}xv.@0' + 'dbP/Cj}sB+': 2DE5ZZSpp + 'de=9c:,@j9': 'qu7$T~h$aQ' + 'dlU7VG|3Gd': fuKjK4-6uB + 'd}R:qHj1,7': 'NUU&F_n.K*' + 'e!KSebP}pt': 'mc06W]r~in' + 'e+/O]%*qfk': 'HhCAt{`FkH' + 'e.t$Vi5,>f': 'm/#`_nWJ8r' + 'e42BpV,P}C': 'd[f%5t;UH5' + 'e5P*D#I{m{': y/GSUYA4Uz + 'eHH?U': 'bs8M8s)6X|' + 'euW0jm=wfR': 'vE8R)ABG0b' + 'e{e8vi@^PY': 'M(G<3?R+|c' + 'f#4-L[mp5*': 'E$0$>x=Zhj' + 'f*2@qiHh_1': t27nr9YA-z + 'f*LO~>!0PN': 'yO`O$6WvVk' + 'f21=-A~W;~': 'h{>w_]xn^~' + 'f>c7x2E]Q6': 'o860?{U!P?' + 'fRSM{K+;[w': mn3Fwwz5Ll + 'fT!w8R#>dg': 'HL![RP|Urq' + 'fV`&p81%d(': 'oaC6@Yele]' + fkq7lhLkdH: 'O:h(.Em}Y@' + 'fxAN),FAc$': 'OsLM>,b7vo' + 'g-]bb&[.E!': 'M&|@(/.of4' + 'g9v~y/;=z(': 'yyX`4Po,+5' + 'g<[f89~U)A': 'G:Du!hF%=;' + 'gBCR?!O*;E': 'ALw.(aKml7' + 'gB|K:+r?1V': 'm!,_BMop/#' + 'gDsLPj(9S#': 'i+DaDVG`8F' + 'gFOojQO(MH': 'G0Zq}|J@Hf' + 'gG6SWfM>H0': 'ofIXVVnbk+' + 'gRVZ]qJ#>O': 'z+R(l-qu:w' + 'gYIoR`|AxW': 'p>4`R>@ts5' + 'gePC:Xt_nW': 'j,M#?Q{BE<' + 'gp[h@]y13l': wxybJM,rY + 'gsS{jR,]|Q': 'I5Xpi}2t{j' + 'gx:;za!?C9': 'grAkl2|g`7' + 'gz9${p}er*': 'z2[w)b%6;V' + 'g~%pF`(x{u': 'A345Xn98~V1': 'uv4>|I(g|3' + 'h9Jr(H[=1~': 'u>1xt-=|mM' + 'h?a*UKfT_M': 'v3Q(-Lz6-w' + 'hE5j*e,Q?[': 'luR#M^0uhv' + 'hNE9}>;Q&-': JPnLHNC0x3 + 'hXNTVF<:SH': 'mf=T5@S@B$' + 'hZ/V/D&rO`': 'xJoB:Z/33$' + 'h_9=mkZXAH': 'HrvW&[P0V6' + hoHandbYAy: DE8jGf4.p_ + 'hrI_uiW>E]': 'y$a<]#=[kh' + 'h}lnSN_f$_': 'va7a~Jl^Dx' + 'h~5xz+=ke~': 'v%l&+z)c=t' + 'i)]+09JUR&': 'QtP;fVY}?{' + 'i2]v|D3@:j': 'X[]sE;=s@' + 'iB-8Br~onq': 'J:QDHRvbY^' + 'iIS#Nw]tr]': dHQ3bH/E/s + 'iR;Um(L/~c=': 'y!zI/vjcP(' + 'i^J8&$(1]|': 'nMZd2^,fLG' + 'ia8as9sCY|': 'uWMPk~=Q<>' + 'igiK,ff[eO': 'q.,&c}-x,G' + 'ijP>aJBT!9': 'OUD.,!OrcL' + 'ilGtw#=asM': 'qL#}r|5Mt`' + 'j%*8gN)q>d': x6sS60yxJ, + 'j)i)pB*HJ,': 'E&c?sL:0${' + 'j2IQ=f=w3@': 'smO=&Woj34' + j7eEZkzsCZ: 'v?p{P_T(=Y' + 'j9:K,~DQ2v': 'N5^^fmYEhO' + 'jg|}': 'M~CY8#w%,9' + 'jCd]`-=k,:': 'HS19wR2TJ{' + 'jDA-V?/hVj': 'cZjPi}s{>f' + jI9P-f6r3M: 's=~NbDpTw@' + 'jKIDq|}J&c': 'e(}%?$1n1Z' + 'jKO,9y0M;#': 'Pp{]{fO0V3' + 'jN^Vc%9OQ5': 'k0&Sa)F$sm' + 'jN|NAUP*h}': 'n#ItI:gZyE' + 'jTNoKo}Bu+': k1U3Xpt_Fz + 'jX=#G8wu#(': 'hL&<+w5j^&' + 'jYz[ibrr9m': 'm)CY|PH/iq' + 'jcu!gLw/&r': 'F`QTam?)B[' + 'jrM28*HbyG': 'b|sXd=z![^' + 'j|z@PMgdx,': 'Mv>trkGo{S' + 'k$1_yaF?9#': 'o>?99Fvx%/' + 'k/u1:B%DJH': 'L,t09Ht&6I' + 'k0{O[6l]bH': 'G?.zu_GD}*' + 'k1]FH+s8j@': kdIst1pWxd + 'k9E.p^Yhx5' + 'kS)hDm%w}R': 'r;&9#reAq>' + 'kX]hN=_.c-': P0cb3EIuka + 'kZ52zV#[Iv': 'hQ61{_HFBB' + 'k[)#L4.[v3': 'Jb!k3Zk952' + 'khB~(m^@Z5': 'p/zP}McYF(' + 'kkbYQXw5b;': 'K:KG8,Ch+~' + 'koI(}OyJ:@': 'v(zX&lYI;&' + 'kopwzzB``A': 'vTW7o0@Cbi' + 'k}4/oj#$~s': 'C}IA~L{U6@' + 'k}A9]O:0xw': 'k/--0=CQ24' + 'l$2O|9w`F~': 'kxy_[68Eh$' + 'l$zM~ihPFE': 'cK+qJN(Y0Z' + 'lI)Ah/E': 'K6KK5O7|e$' + 'l*j*EE:!Y?': 'o1s-Ke1!}|' + 'l+>Ki!j#v+': IvS7NktDMU + 'l3Ly8PBxt(': 'v%__gCc&;=' + 'lAxH.CfJB@': 'OIp9T`E.zP' + 'lH^2D]w;[H': 'I#a1r}a^ox' + 'lU@C3G/=c~s' + 'm2F|cUkKhv': 'gOMsqm#1v:' + 'm5j,b/yg%WMX2DQ' + 'mcc&0Kq^xE': 'DP|7nmfMGQ' + 'mi+kf0LTW.': 'qtG?nh5zIU' + 'mk1VltJl<]': 'Rgv*IZqSmF' + 'mk20`,N=`=': 'Fohes67)Oz' + 'mkgcU7[bA%': 'k~VOx&I}t{' + 'mq)8`hi{hb': wcmCaTm14V + 'mtd;>7T|g>': 'qzrPU{F1.#' + 'mz[>>AxIr4': 'mf?[qcKLQN' + 'm{noqE29%I': 'EsG3PP1%gq' + 'm{oM,jr>C#': 'N-VDo?Rx@P' + 'n&>e`2FO/S': 'qAd]|6O,7Z' + 'n+t>2`:;P/': 'iYgSvir%)V' + 'n:7&>YT5A=': iGsrHmA9Bc + 'nIfRn/yz': 'zgf^l#Fr_!' + 'oa87&PB&Bm': 'mOz:A8Cv+c' + 'onMKID2d;D': 'GrBHv<@H+7' + 'o~}KVA:VbI': 'C.u~aYFs^y' + 'p#R$-mG/L': 'qs1#;Ke~GW' + 'p.yv>BQ470': 'yA.vuGT<.b' + 'p:SPZ:e/*[': 'P]Er5fA%dB' + 'pDDrFhd%u': 'oC.ex]c%ci' + 'pYRw[=6;JI': 'KbHb&_S;-v' + 'p]t8:Tka7q': 'j$(6)I{3Mv' + pkj3RPBUrF: 'Iap>xuM=!p' + 'pwwZu{7f~(': 'tmUABORc%v' + 'pz(G*]A' + 'p}Qa|!7PpR': 'g}l,l&fs(:' + 'q1Q3Do8S>g': 'q@B#VXN;Jf' + 'q2#Yv[O0:|': '6uQ8zE+bN' + 'q7do-wLx%E': 'CTevYb-Dt]' + 'q8][)Ri}=q': 'so#V7SThWD' + 'q8fF$4,c6_': 'G~F&>Mc><4' + 'q:XyGf#>Wg': 'xW`%t:aC:q' + 'q=w[$qo8FD' + 'qFuGdDx^Yv': 'qObv%Y4Tz' + 'qVi;t%/`F|': 'z[&.Zoz9Wr' + 'q[S4`,F0D=': 'P6uJ_GAwG&' + 'q[}7hk(+=e': 'jnY+~6&jpG' + 'qiv/4tOlf=': 'm^=fB$d+$': 'baI-$$|wT': 'G1Q&LOe!Nv' + 'r>g<|,FWi%': 'd$r4uW9udC' + 'r?E$4f-d0/': 'hn&pw!0': 'LJ7~]dZ,pS' + 's9-GL*@AXD': 'd{V%m8RM^K' + 's<_WLSB3I/': 'M%2Z1/~Y[S' + 's>f40rn,O]': 'HM/uONn&+7' + 'sB7rZQsR@9w~i^p': 'Ecs;]/<;`%' + 'skOM(?n$GG': 'zn;(Mg+s.+' + spKoFRM4if: 'w6BvfhT&-R' + 'ssbyA9}]QP': '>v6oM{`%o' + 'stRa/S(0$F': 'Fh61;335zk' + 'sxOMs+bE8@': 'im}0hozT&<' + 'sx|l+io`B@': 'D]4yXjvC:9' + 's|K&2S,UP|': 'tmF}dZ1EH!' + 't$y1C2R-Av': JUjb1XwjuC + 't+v9b--,qs': 'BXXqj;W#6T' + 't-0>F]bu>P': 'dDaj)OD&@~' + 't2&ri|[Un:': 'O.$J.A)V?N' + t3aZXtpNyi: 'j&&6JU`wC`' + 't50y/[AEhF': 'cV/[QK2H^,' + 'tMMtBUpb$&': 'i!?4Hc^[WK' + 'tTnCoO/*tk': 'wmUbr/A' + 'tcneuK7v%`': 'y(>fAw?M7h' + tfCY4j6KEZ: mFQvXkOFXY + 'tj&bK}%P!F': cPwvsMSC1N + 'twrhzJ[k:Z': 'M4*OI7|Ubj' + 't|h3|;E4g{': 'qB3J]6{#j4' + 'u-`cspdp)D': 'pA0=t$_>hR' + 'u4^lH.xem5': 'D=o(;#f23a' + 'u;|vR#mVMW': 'fV5iO9=}H,' + 'uH*Q(mcRt5': 'x5_%[P=#b-' + 'uRo^w#p5Be': 'J:E?C=p8O;' + 'uc61ToT%gt': 'jxv@,7idOy' + 'ui3>b-?a@m': 'HU`]%dF5Op' + upiX0g1quR: 'c=InO0-mt?' + 'uv1fGXHN@)': 'uSPUaH0];!' + 'u|I}oElaH)': 'i!E{?dzJj_' + 'vE[T$`n9T)': g0yTLOPs_3 + 'vKx<0Qa!aP': 'p1z;2bU$}H' + 'vNiD6A.v#s': 'pEjiMe.U`M' + 'va/ky=f5pK': 'it>eGSc0;R' + 'vf)G?[_?Pf': 'sF=7^K-Xpq' + 'vgdj#X?8dB': 'Fi~Du[Cg`F' + 'vpOTK6tMr@': 'vp[7n&^y0?' + 'vppEA|$@~g': 'c.?kH`a?8B' + 'vrn%j%6{nu': 'M]!aUbbgQ)' + 'vwc3}.=Z#&': 'qRrQ1!Q_|1' + 'wa>{k!2cXc': 'bfrzr8~u|(' + x2d4n4ADcP: 'slIL&6*m)J' + 'xb&P*pH(Q': 'uju+7hizrf' + 'xqrJ^d[.(O': 'sx6g!v8gw>' + 'yX:tmrd5;]': 'vTvl[[S&ba' + 'z:*d{z(~V2': 'O,O]lf&18]' + 'zSc]-^U=0=': 'kQ{}[-(!z,' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: 15daaec7-4097-4d97-ba94-9db44f8b8f51 diff --git a/fixtures/ultimate-geography/overlays/languages/de.yaml b/fixtures/ultimate-geography/overlays/languages/de.yaml new file mode 100644 index 0000000..c6dfff5 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/de.yaml @@ -0,0 +1,747 @@ +id: overlay.translation.de +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Zwischen den Anrainerstaaten des Meeres besteht ein Namensstreit, wobei Südkorea den Namen Ostmeer besonders unterstützt.' + Abkhazia: Abchasien + Addis Ababa: Addis Abeba + Adriatic Sea: Adriatisches Meer + Aegean Sea: 'Ägäisches Meer' + Africa: Afrika + Akrotiri and Dhekelia: Akrotiri und Dekelia + Albania: Albanien + Algeria: Algerien + Algiers: Algier + Also known as Burma.: Auch Birma oder Burma. + Also known as Cabo Verde.: Auch Cabo Verde. + Also known as Czechia.: '' + Also known as East Timor.: Auch Timor-Leste. + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: Auch Golf von Siam. + Also known as the Sea of Cortez.: 'Auch Cortés-See.' + 'Also spelled as Sana''a.': '' + American Samoa: Amerikanisch-Samoa + 'Andorra (narrower, coat of arms with motto)': 'Andorra (schmaler, Wappen mit Wahlspruch)' + Antarctica: Antarktis + Antigua and Barbuda: Antigua und Barbuda + Arabian Sea: Arabisches Meer + Aral Sea: Aralsee + Arctic Ocean: Arktischer Ozean + Argentina: Argentinien + Armenia: Armenien + Ashgabat: 'Aşgabat' + Asia: Asien + Athens: Athen + Atlantic Ocean: Atlantischer Ozean + Australia: Australien + 'Australia (white stars, two more stars)': 'Australien (weiße Sterne, zwei weitere Sterne)' + Austria: 'Österreich' + 'Austria (brighter red, wider white band)': 'Österreich (helleres Rot, breiteres weißes Band)' + Autonomous community of Spain.: Autonome Gemeinschaft Spaniens. + Autonomous province of South Korea.: 'Autonome Provinz Südkoreas.' + Autonomous region of Finland.: 'Auch Ålandinseln. Autonome Region Finnlands.' + Autonomous region of Italy.: Autonome Region Italiens. + Autonomous region of Papua New Guinea.: Autonome Region Papua-Neuguineas. + Autonomous region of Portugal.: Autonome Region Portugals. + Azerbaijan: Aserbaidschan + Azores: 'Azoren (Habichtsinseln)' + Baghdad: Bagdad + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrain (enger, weniger Zacken, rot)' + Balkan Peninsula: Balkanhalbinsel + Baltic Sea: Ostsee + Banda Sea: Bandasee + Bangladesh: Bangladesch + Barents Sea: Barentssee + Bay of Bengal: Golf von Bengalen + Bay of Biscay: Biskaya + Beijing: Peking + Belgium: Belgien + Belgrade: Belgrad + Bering Strait: 'Beringstraße' + Bishkek: Bischkek + Black Sea: Schwarzes Meer + Bolivia: Bolivien + 'Bolivia (coat of arms instead of star)': 'Bolivien (Wappen anstatt Stern)' + Bosnia and Herzegovina: Bosnien und Herzegowina + Brazil: Brasilien + British Virgin Islands: Britische Jungferninseln + Brussels: 'Brüssel' + Bucharest: Bukarest + Bulgaria: Bulgarien + Cairo: Kairo + Cambodia: Kambodscha + Cameroon: Kamerun + 'Cameroon (green/red/yellow, yellow star)': 'Kamerun (Grün/Rot/Gelb, gelber Stern)' + Canada: Kanada + Canary Islands: Kanarische Inseln + Cape Verde: Kap Verde + Caribbean Sea: Karibisches Meer + Caspian Sea: Kaspisches Meer + Cayman Islands: 'Cayman Islands (Kaimaninseln)' + Celebes Sea: Celebessee + Celtic Sea: Keltische See + Central African Republic: Zentralafrikanische Republik + Cetinje is an honorary capital.: Cetinje ist eine Ehrenhauptstadt. + Chad: Tschad + 'Chad (slightly darker blue)': 'Tschad (etwas dunkleres Blau)' + China: Volksrepublik China + City of San Marino: Stadt San Marino + Claimed and controlled: Behauptet und kontrolliert + Claimed but not controlled: Behauptet, aber nicht kontrolliert + Colombia: Kolumbien + 'Colombia (no coat of arms)': 'Kolumbien (kein Wappen)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: Colombo wird oft als die Hauptstadt bezeichnet, aber Sri Jayawardenepura Kotte, ein Vorort von Colombo, ist die offizielle, legislative Hauptstadt. + Comoros: Komoren + Constituent country in the Kingdom of Denmark.: 'Autonomer Bestandteil des Königreichs Dänemark.' + Constituent country of the Kingdom of the Netherlands.: 'Autonomes Land innerhalb des Königreichs der Niederlande.' + Constituent country of the United Kingdom.: 'Landesteil des Vereinigten Königreichs.' + Cook Islands: Cookinseln + Copenhagen: Kopenhagen + Coral Sea: Korallenmeer + Corsica: Korsika + Croatia: Kroatien + Crown dependency of the United Kingdom.: Kronbesitz der britischen Krone. + Cuba: Kuba + 'Cuba (red triangle, blue stripes)': 'Kuba (rotes Dreieck, blaue Streifen)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (zwei Sterne in der oberen linken Ecke)' + Cyprus: Republik Zypern + Czech Republic: Tschechien + Damascus: Damaskus + Dead Sea: Totes Meer + Democratic Republic of the Congo: Demokratische Republik Kongo + Denmark: 'Dänemark' + Denmark Strait: 'Dänemarkstraße' + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Umstritten; von Israel beansprucht; Ramallah ist das Verwaltungszentrum.' + 'Disputed; claimed by Palestine.': 'Umstritten; von Palästina beansprucht.' + Djibouti: Dschibuti + Dominican Republic: Dominikanische Republik + Dushanbe: Duschanbe + East China Sea: Ostchinesisches Meer + East Siberian Sea: Ostsibirische See + 'Ecuador (with coat of arms)': 'Ecuador (mit Wappen)' + Egypt: 'Ägypten' + 'Egypt (emblem instead of text), Yemen (no text)': 'Ägypten (Emblem statt Text), Jemen (kein Text)' + 'Egypt (with emblem), Iraq (with text)': 'Ägypten (mit Emblem), Irak (mit Text)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (anderes Wappen, etwas dunkleres Blau)' + English Channel: 'Ärmelkanal' + Equatorial Guinea: 'Äquatorialguinea' + Estonia: Estland + Ethiopia: 'Äthiopien' + Europe: Europa + European Union: 'Europäische Union' + Falkland Islands: Falklandinseln + Faroe Islands: 'Färöer' + Federated States of Micronesia: 'Föderierte Staaten von Mikronesien' + Fiji: Fidschi + Finland: Finnland + Formerly Zaire.: 'Früher Zaire.' + Formerly known as Macedonia.: 'Früher bekannt als Mazedonien.' + France: Frankreich + French Guiana: 'Französisch-Guayana' + French Polynesia: 'Französisch-Polynesien' + Gabon: Gabun + Georgia: Georgien + Germany: Deutschland + 'Ghana (star instead of coat of arms)': 'Ghana (Stern anstatt Wappen)' + Greece: Griechenland + Greenland: 'Grönland' + Guatemala City: Guatemala-Stadt + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (Grün und Rot vertauscht, etwas dunkleres Grün)' + Gulf of Alaska: Golf von Alaska + Gulf of California: Golf von Kalifornien + Gulf of Carpentaria: Golf von Carpentaria + Gulf of Guinea: Golf von Guinea + Gulf of Mexico: Golf von Mexiko + Gulf of Thailand: Golf von Thailand + Hargeisa: Hargeysa + Havana: Havanna + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Historische und kulturelle Region in Nordeuropa, die die Länder Dänemark, Norwegen und Schweden und manchmal Finnland und Island umfasst.' + Hong Kong: Hongkong + Hungary: Ungarn + Iceland: Island + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Island (blauer Hintergrund, rotes und weißes Kreuz), Norwegen (roter Hintergrund, blaues und weißes Kreuz)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Island (blauer Hintergrund, rotes Kreuz), Färöer (weißer Hintergrund, rotes und blaues Kreuz)' + Independent state claimed by Georgia.: 'Unabhängiger Staat, von Georgien beansprucht.' + Independent state claimed by Moldova.: 'Unabhängiger Staat, von Republik Moldau (Moldawien) beansprucht.' + Independent state claimed by Somalia.: 'Unabhängiger Staat, von Somalia beansprucht.' + India: Indien + Indian Ocean: Indischer Ozean + Indonesia: Indonesien + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesien (Weiß und Rot vertauscht, helleres Rot), Monaco (Weiß und Rot vertauscht, schmaler)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesien (breiter, helleres Rot), Polen (Rot und Weiß vertauscht, breiter)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (Text anstatt Emblem), Jemen (kein Emblem)' + Ireland: Irland + 'Ireland (orange and green flipped, wider)': 'Irland (Orange und Grün vertauscht, breiter)' + Island of Indonesia.: + notes.note.bali.fields.field.country-info: Insel von Indonesien. + notes.note.java.fields.field.country-info: Insel von Indonesien. + notes.note.sumatra.fields.field.country-info: Indonesische Insel. + Italy: Italien + Ivory Coast: 'Elfenbeinküste' + 'Ivory Coast (green and orange flipped, narrower)': 'Elfenbeinküste (Grün und Orange vertauscht, schmaler)' + Jamaica: Jamaika + Jeju: Jeju-do + Jordan: Jordanien + Kaliningrad Oblast: Oblast Kaliningrad + Kazakhstan: Kasachstan + Kenya: Kenia + Khartoum: Khartum + Known as Nur-Sultan between 2019 and 2022: Bekannt als Nur-Sultan zwischen 2019 und 2022 + Known as Swaziland until 2018.: 'Bis 2018: Swasiland.' + Kuwait City: Kuwait-Stadt + Kyiv: Kiew + Kyrgyzstan: Kirgisistan + Laayoune: 'El Aaiún' + Labrador Sea: Labradorsee + Latvia: Lettland + 'Latvia (darker red, narrower white band)': 'Lettland (dunkleres rot, dünneres weißes Band)' + Lebanon: Libanon + Libya: Libyen + Lisbon: Lissabon + Lithuania: Litauen + Luxembourg: Luxemburg + 'Luxembourg (lighter blue)': 'Luxemburg (helleres Blau)' + Luxembourg City: Luxemburg + Madagascar: Madagaskar + Madeira: Autonome Region Madeira + Maldives: Malediven + 'Mali (red and green flipped, slightly brighter green)': 'Mali (Rot und Grün vertauscht, etwas helleres Grün)' + Marshall Islands: Marshallinseln + Mauritania: Mauretanien + Mediterranean Sea: Mittelmeer + Melanesia: Melanesien + Mexico: Mexiko + Mexico City: Mexiko-Stadt + Micronesia: Mikronesien + Mogadishu: Mogadischu + Moldova: Republik Moldau + 'Moldova (wider, coat of arms with eagle)': 'Republik Moldau/Moldawien (breiter, Wappen mit Adler)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (schmaler, dunkleres rot), Polen (Rot und Weiß vertauscht, dunkleres rot)' + Mongolia: Mongolei + Morocco: Marokko + Moscow: Moskau + Mozambique: Mosambik + Muscat: Maskat + 'N''Djamena': 'N’Djamena' + 'Nauru (single star below yellow band)': 'Nauru (einzelner Stern unter gelbem Band)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru hat keine offizielle Hauptstadt; der Yaren District ist der Regierungssitz.' + Netherlands: Niederlande + 'Netherlands (darker blue)': 'Niederlande (dunkleres Blau)' + New Caledonia: Neukaledonien + New Delhi: Neu-Delhi + New Zealand: Neuseeland + 'New Zealand (red stars, two fewer stars)': 'Neuseeland (rote Sterne, zwei Sterne weniger)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (anderes Wappen, etwas helleres Blau)' + Nicosia: Nikosia + North America: Nordamerika + North Korea: Nordkorea + North Macedonia: Nordmazedonien + North Nicosia: Nord-Nikosia + North Sea: Nordsee + Northern Cyprus: Nordzypern + Northern Ireland: Nordirland + Northern Mariana Islands: 'Nördliche Marianen' + Norway: Norwegen + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norwegen (roter Hintergrund, blaues Kreuz), Färöer (weißer Hintergrund, rotes und blaues Kreuz)' + Norwegian Sea: 'Europäisches Nordmeer' + Not a sovereign country: 'Kein souveräner Staat' + 'Oblast (administrative region) of the Russian Federation.': 'Oblast (administratives Gebiet) der Russischen Föderation.' + Oceania: Ozeanien + Official capital was moved from Bujumbura to Gitega in 2019.: Die offizielle Hauptstadt wurde 2019 von Bujumbura nach Gitega verlegt. + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: Die offizielle Hauptstadt wurde 2026 von Malabo nach Ciudad de la Paz verlegt. + 'Officially Côte d''Ivoire.': 'Offiziell Côte d''Ivoire.' + Officially Luxembourg.: '' + Overseas department of France.: 'Übersee-Département Frankreichs.' + Overseas territory of France.: 'Französisches Überseegebiet.' + Overseas territory of the United Kingdom.: + notes.note.akrotiri-and-dhekelia.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.anguilla.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.bermuda.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.british-virgin-islands.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.cayman-islands.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.falkland-islands.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.gibraltar.fields.field.country-info: 'Britisches Überseegebiet.' + notes.note.turks-and-caicos-islands.fields.field.country-info: 'Auch Turks & Caicos. Britisches Überseegebiet.' + Pacific Ocean: Pazifischer Ozean + Palestine: 'Palästina' + 'Palestine (black/white/green, red arrow)': 'Palästina (Schwarz/Weiß/Grün, roter Pfeil)' + 'Palestine (no symbol)': 'Palästina (kein Symbol)' + Panama City: Panama-Stadt + Papua New Guinea: Papua-Neuguinea + Partially recognised state claimed by China.: Teilweise anerkannter Staat, von China beansprucht. + Partially recognised state claimed by Morocco. Also known as Western Sahara.: Teilweise anerkannter Staat, von Marokko beansprucht. + Partially recognised state claimed by Serbia.: Teilweise anerkannter Staat, von Serbien beansprucht. + Persian Gulf: Persischer Golf + Philippine Sea: Philippinensee + Philippines: Philippinen + Poland: Polen + Polynesia: Polynesien + Prague: Prag + Pretoria, Cape Town, Bloemfontein: Pretoria, Kapstadt, Bloemfontein + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (blaues Dreieck, rote Streifen)' + Pyongyang: 'Pjöngjang' + Qatar: Katar + 'Qatar (wider, more serrated edges, maroon)': 'Katar (breiter, mehr kleinere Zacken, rotbraun)' + Red Sea: Rotes Meer + Region of France.: 'Gebietskörperschaft Frankreichs.' + Republic of the Congo: Republik Kongo + Riyadh: Riad + Romania: 'Rumänien' + 'Romania (slightly lighter blue)': 'Rumänien (etwas helleres Blau)' + Rome: Rom + Russia: Russland + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Russland (kein Wappen), Slowenien (breiter, kleineres Wappen)' + Rwanda: Ruanda + 'Réunion': 'La Réunion' + Sahrawi Arab Democratic Republic: Demokratische Arabische Republik Sahara + Saint Kitts and Nevis: St. Kitts und Nevis + Saint Lucia: St. Lucia + Saint Martin: Saint-Martin + Saint Vincent and the Grenadines: St. Vincent und die Grenadinen + Santiago: Santiago de Chile + Sardinia: Sardinien + Saudi Arabia: Saudi-Arabien + Scandinavia: Skandinavien + Scotland: Schottland + Sea of Galilee: See Genezareth + Sea of Japan: Japanisches Meer + Sea of Okhotsk: Ochotskisches Meer + Semi-autonomous region of Tanzania.: Halbautonomer Teilstaat Tansanias. + 'Senegal (green/yellow/red, green star)': 'Senegal (Grün/Gelb/Rot, grüner Stern)' + Serbia: Serbien + Seychelles: Seychellen + Sicily: Sizilien + Singapore: Singapur + Slovakia: Slowakei + 'Slovakia (narrower, bigger coat of arms)': 'Slowakei (schmaler, größeres Wappen)' + 'Slovakia (with coat of arms)': 'Slowakei (mit Wappen)' + Slovenia: Slowenien + Solomon Islands: Salomonen + South Africa: 'Südafrika' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Die Regierung sitzt in Pretoria (Exekutive), das Parlament in Kapstadt (Legislative) und das Oberste Berufungsgericht in Bloemfontein (Judikative).' + South America: 'Südamerika' + South China Sea: 'Südchinesisches Meer' + South Korea: 'Südkorea' + South Ossetia: 'Südossetien' + South Sudan: 'Südsudan' + Southern Ocean: 'Südlicher Ozean' + Sovereign country: 'Souveräner Staat' + Spain: Spanien + Special Administrative Region of China.: Sonderverwaltungszone Chinas. + Sri Jayawardenepura Kotte: Sri Jayewardenepura Kotte + 'St. John''s': 'Saint John’s' + State of the United States.: Bundesstaat der Vereinigten Staaten. + State recognised only by Turkey and claimed by Cyprus.: 'Nur von der Türkei anerkannter Staat, von Zypern beansprucht.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Größte Inselregion Ozeaniens.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Teilregion von Ozeanien bestehend über 2000 tropischen Inseln und Atollen im westlichen Pazifik.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Teilregion von Ozeanien, zu der Vanuatu, die Salomonen, Fidschi und Papua-Neuguinea gehören.' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudan (Rot/Weiß/Schwarz, grüner Pfeil), Demokratische Arabische Republik Sahara (mit Stern und Halbmond)' + Sukhumi: Sochumi + Svalbard: Spitzbergen + Sweden: Schweden + Switzerland: Schweiz + 'Switzerland has no official capital; Bern is the de facto capital.': 'Die Schweiz hat keine offizielle Hauptstadt; Bern ist der Regierungssitz.' + Syria: Syrien + 'São Tomé and Príncipe': 'São Tomé und Príncipe' + Taipei: Taipeh + Tajikistan: Tadschikistan + Tanzania: Tansania + Tashkent: Taschkent + Tasman Sea: Tasmansee + Tbilisi: Tiflis + Tehran: Teheran + The Bahamas: Bahamas + The Gambia: Gambia + Timor Sea: Timorsee + Timor-Leste: Osttimor + Tokyo: Tokio + Transnistria: Transnistrien + Trinidad and Tobago: Trinidad und Tobago + Tripoli: Tripolis + Tskhinvali: Zchinwali + Tunisia: Tunesien + Turkey: 'Türkei' + Turks and Caicos Islands: Turks- und Caicosinseln + Unincorporated internal area of Norway.: Gemeindefreies Gebiet Norwegens. + Unincorporated territory of the United States.: 'Nichtinkorporiertes US-amerikanisches Außengebiet.' + United Arab Emirates: Vereinigte Arabische Emirate + United Kingdom: 'Vereinigtes Königreich' + United States Virgin Islands: Amerikanische Jungferninseln + United States of America: Vereinigte Staaten von Amerika + Uzbekistan: Usbekistan + Vatican City: Vatikanstadt + Vienna: Wien + Wallis and Futuna: Wallis und Futuna + Warsaw: Warschau + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Amsterdam ist die offizielle Hauptstadt, während Den Haag Parlaments- und Regierungssitz ist.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: Dodoma ist die offizielle Hauptstadt, aber Dar es Salaam ist der Regierungssitz. + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'El Aaiún ist die erklärte Hauptstadt, aber Tifariti ist der Regierungssitz.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: Mbabane ist die offizielle Haupstadt, aber Lobamba ist die traditionelle Haupstadt und Sitz des nationalen Parlaments. + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: Porto-Novo ist die offizielle Hauptstadt, aber Cotonou ist der Regierungssitz. + While Sucre is the constitutional capital, La Paz is the seat of government.: Sucre ist die offizielle Hauptstadt, aber La Paz ist der Regierungssitz. + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: Yamoussoukro ist die offizielle Hauptstadt, aber Abidjan ist der Regierungssitz. + White Sea: 'Weißes Meer' + World region covering the Australian continent and most of the islands in the Pacific Ocean.: Weltregion, die den australischen Kontinent und die meisten Inseln im Pazifischen Ozean umfasst. + Yellow Sea: Gelbes Meer + Yemen: Jemen + Yerevan: Jerewan + Zambia: Sambia + Zanzibar: Sansibar + Zimbabwe: Simbabwe + 'Åland Islands': 'Åland' + additions: + notes.note.aegean-sea.fields.field.country-info: 'Auch Ägäis.' + notes.note.arctic-ocean.fields.field.country-info: 'Auch Nordpolarmeer, nördliches Eismeer, Arktische See oder Arktik.' + notes.note.atlantic-ocean.fields.field.country-info: Auch Atlantik. + notes.note.balkan-peninsula.fields.field.country-info: Auch Balkan. + notes.note.baltic-sea.fields.field.country-info: Auch Baltisches Meer. + notes.note.bay-of-biscay.fields.field.country-info: Auch Golf von Biskaya. + notes.note.belarus.fields.field.country-info: 'Auch Weißrussland.' + notes.note.burkina-faso.fields.field.capital-info: Auch Wagadugu. + notes.note.caspian-sea.fields.field.country-info: Auch Kaspisee. + notes.note.celebes-sea.fields.field.country-info: Auch Sulawesisee. + notes.note.moldova.fields.field.country-info: Auch Moldawien. + notes.note.norwegian-sea.fields.field.country-info: Auch Norwegische See oder Norwegisches Meer. + notes.note.pacific-ocean.fields.field.country-info: Auch Pazifik. + notes.note.persian-gulf.fields.field.country-info: Auch Arabischer Golf. + notes.note.solomon-islands.fields.field.country-info: Auch Salomoninseln. + notes.note.suriname.fields.field.country-info: Auch Surinam. + notes.note.white-sea.fields.field.country-info: 'Auch Weißmeer.' + variables: + label.capital: + Capital: Hauptstadt + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Hinweis: {{Capital hint}}' + label.flag: + Flag: Flagge + label.location: + Location: Lage + note-type.name: + Ultimate Geography: 'Ultimate Geography [DE]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Flagge ähnlich wie {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': '9|1g!uc+D' + '5JJ2]i)p4': 'cYpd' + 'Gn;q2Y)Xe4': '{Zx2bRpQQu' + 'H*c[ML+`s=': '|qOD;Er>4Q' + 'He!e=h?_2V': '|QjQzaB=bl' + 'J%,fuysl*&': '~msR6r4eqE' + 'JOT:U4ayh@': '~=]w^XMrTT' + 'K*b}*d$}MA/h' + 'Lol+XIvY21': 'b0Xr{B7Rbr' + 'M$bc0[jYf0': 'clNO~:VRRq' + 'O~pXkAbd3': 'HL+nW$NPW' + 'QSq%~rFaP-': 'g[2mLk*^>K' + 'Qlib4)q2Hk': 'gXUNd92V,%' + 'SlOEXfq#|': 'LXe_{R2k@' + 'W5?6yQZD2': 'PeSw!?}(V' + '_]DH+g9!$': '=E^|rSij5' + 'b@M[XTc?}5': '4C;D{MO.Kv' + 'bD?R}i8_[c': '4(B@Kbh=D5' + 'bW)ch:vs1P': '4`pOT)7laf' + 'b[(2`4t&?J': '4DobHX57B~' + 'b^Try&me1A': '4F]3!7Y|a@' + 'b`5Q_Z=HQT': 'o9MT>Zr^kl' + 'bd~Ght5tLR': '4PL+Tmem:h' + 'bk2ByZIeU+': '4Wb%!S-|^I' + 'bnEK#)[;qT': '4Z)/k9D*2j' + 'c(,2{yXI#E': '5osbIr{Bk_' + 'c,5(mUGzYT': '5seoYN+s|j' + 'cAlyc~G.y%': '5$X!O]+&!D' + 'cEO^y)Md[G': '5)=F!9;{D{' + 'cL?8!xP#Wj': '5:Bhjq>4`$' + 'cPO|Cru8n<': '5>=J&k61ZP' + 'cV4idO^r8W': 5_dUPHFkhm + 'cYKvF#ptF7': '5|/7*41m*x' + 'c^,~EC6Pb2': '5FsL)vfINs' + 'ck!6;xK$/^': 5Wjfxq/5vW + 'cv+Nq9:af': '7u5l_be$A8' + 'e42BpV,P}C': '7db%1OsIK]' + 'e5P*D#I{m{': '7e>q(4-?YZ' + 'eHH?U': 7175c-,ABk + 'euW0jm=wfR': '76`~VfzpRh' + 'e{e8vi@^PY': '7IQh7bC<>o' + 'f#4-L[mp5*': '8kdt::YieH' + 'f*2@qiHh_1': 8qbC2b,aGr + 'f*LO~>!0PN': '8q:=L-jT>d' + 'f21=-A~W;~': 8bazttLPx2 + 'f>c7x2E]Q6': '8AOg9V);?w' + 'fRSM{K+;[w': '8@[;IDr*D<' + 'fT!w8R#>dg': '8]j8hKk-P9' + 'fV`&p81%d(': 8_Hn11a6PF + fkq7lhLkdH: 'b,nf]GF[[?' + 'fxAN),FAc$': '89$co2[B' + 'gFOojQO(MH': '9*=0VJ=8;|' + 'gG6SWfM>H0': '9+f[`};-,q' + 'gRVZ]qJ#>O': '9@_}Ej.4Ae' + 'gYIoR`|AxW': '9|-0@>Jt9m' + 'gePC:Xt_nW': '9Q>&wQ5=Zm' + 'gp[h@]y13l': '91DTC;!Uc&' + 'gsS{jR,]|Q': '94[IVKs;Jg' + 'gx:;za!?C9': '99wx#^j.&z' + 'gz9${p}er*': '9#ilIiK|3H' + 'g~%pF`(x{u': '9Lm1*>oqI:' + 'h&`>n98~V1': '!nHAZ2h]_r' + 'h9Jr(H[=1~': 'hrp$LcsG^>' + 'h?a*UKfT_M': '!BMq^DRMGc' + 'hE5j*e,Q?[': '!)eVq|sJBU' + 'hNE9}>;Q&-': '!<)iK-xJnK' + 'hXNTVF<:SH': '!{<]_yy)[|' + 'hZ/V/D&rO`': '!}v_vwnk=Y' + 'h_9=mkZXAH': '!GizYd}Q$|' + hoHandbYAy: '!0,MZ{NR$>' + 'hrI_uiW>E]': '!3-G6b`-)V' + 'h}lnSN_f$_': '!KXZ[GG}lX' + 'h~5xz+=ke~': '!Le9##zdQ2' + 'i)]+09JUR&': '#pEr~2.N@E' + 'i2]v|D3@:j': '#bE7Jwc/w$' + 'iB-8Br~onq': '#%th%kLhZ,' + 'iIS#Nw]tr]': '#-[km(L/~c=': '#`AYoEv]OQ' + 'i^J8&$(1]|': '#F.hn5oUE0' + 'ia8as9sCY|': '#MhM424v|0' + 'igiK,ff[eO': 'MO*aWaoK4b' + 'ijP>aJBT!9': '#V>AMC%Mjz' + 'ilGtw#=asM': '#X+584z^4c' + 'j%*8gN)q>d': '$mqhSGpjA6' + 'j)i)pB*HJ,': '$pUp1uqA.J' + 'j2IQ=f=w3@': '$b-?z}zpcT' + j7eEZkzsCZ: '$gQ)}d#l&p' + 'j9:K,~DQ2v': '$iw/s](Jb;' + 'jg|}': '$%PC$.A~J1' + 'jCd]`-=k,:': '$&PEH%zdsN' + 'jDA-V?/hVj': '$($t_.va_$' + jI9P-f6r3M: '$-i>t}fkcc' + 'jKIDq|}J&c': '$/-(2@KCn5' + 'jKO,9y0M;#': '$/=sir~FxB' + 'jN^Vc%9OQ5': '$!T1' + 'jTNoKo}Bu+': '$]<0/hKu6I' + 'jX=#G8wu#(': '${zk+18nkF' + 'jYz[ibrr9m': '$|#DU_3ki(' + 'jcu!gLw/&r': '$O6jSE8(n-' + 'jrM28*HbyG': '$3;bh!,_!{' + 'j|z@PMgdx,': '$J#C>FS{9J' + 'k$1_yaF?9#': '%laG!^*.iB' + 'k/u1:B%DJH': '%v6awumw.|' + 'k0{O[6l]bH': '%~I=DZX;N|' + 'k1]FH+s8j@': '%aE*,#41VT' + 'k9E.p^*2' + 'l$zM~ihPFE': '&l#;LbTI*_' + 'lI)Ah/E': '&nkg-9$av_' + 'l*j*EE:!Y?': '&qVq)xw3|S' + 'l+>Ki!j#v+': '&rA/U3V47I' + 'l3Ly8PBxt(': '&c:!hI%q5F' + 'lAxH.CfJB@': '&$9,uvRC%T' + 'lH^2D]w;[H': '&,Fb(;8*D|' + 'lU@C3,E' + 'm*#%sE.`.;': '(qkm4xu>uO' + 'm*PYE=#wr^': '(q>|),kp3W' + 'm2F|cUkKhv': '(b*JONWDT;' + 'm5j,b/yyuK' + 'm[96fa+HDS': '(DifR^rA(i' + mbGXc0y3Pe: '(N+{OT!W>7' + 'mbz=7n[0v9': '(N#zggDT7z' + 'mcc&0Kq^xE': '(OOn~D2<9_' + 'mi+kf0LTW.': '(UrWRT:M`L' + 'mk1VltJl<]': '(Wa_Xm.eyV' + 'mk20`,N=`=': '(Wb~H$<,HQ' + 'mkgcU7[bA%': '(WSO^0D_$D' + 'mq)8`hi{hb': '(2phHaU?T4' + 'mtd;>7T|g>': '(5PxA0]@SR' + 'mz[>>AxIr4': '(#DAAt9B3u' + 'm{noqE29%I': '(IZ02xb2m}' + 'm{oM,jr>C#': '(I0;sc3-&B' + 'n&>e`2FO/S': ')nAQHV*Hvi' + 'n+t>2`:;P/': ')r5Ab>w*>M' + 'n:7&>YT5A=': ')wgnAR]Y$Q' + 'nIfRn/yz': '*HHwBTA(!?' + 'oa87&PB&Bm': '*MhgnI%7%(' + 'onMKID2d;D': '*Z;/-wb{x^' + 'o~}KVA:VbI': '*LK/_twON}' + 'p#R$-mG/L': 'ikhCtY+vE' + 'p.yv>BQ470': '+u!7Au?Xgq' + 'p:SPZ:e/*[': '+w[>})Q(qU' + 'pDP5$~C~' + 'pQpX%(EVOH': '+?1{m8)O=|' + 'pS>DrFhd%u': '+[A(3yT{m:' + 'pYRw[=6;JI': 'tk,=<`H?)!' + 'p]t8:Tka7q': '+E5hwMW^g,' + pkj3RPBUrF: '+WVc@I%N3`' + 'pwwZu{7f~(': '+88}6?g}LF' + 'pz(G*g': ',a?c(hhLA9' + 'q2#Yv[O0:|': ',bk|7:=Tw0' + 'q7do-wLx%E': ',gP0tp:qm_' + 'q8][)Ri}=q': ',hEDpKU[z,' + 'q8fF$4,c6_': ',hR*lXs`fX' + 'q:XyGf#>Wg': ',w{!+}k-`9' + 'q=w[*0' + 'q[S4`,F0D=': ',D[dH$*T(Q' + 'q[}7hk(+=e': ',DKgTdo#z7' + 'qiv/4tOlf=': ',U7vdm=eRQ' + qvITjNxkLN: ',7-]VG9d:d' + 'qx%>fB$d+$': ',9mARul{rC' + 'r(h9&#wc~B': '-oTin48`L[' + 'r/B|ra%L,.': '-v%J3^mEsL' + 'r5C646H*+*': '-e&fdZ,!rH' + 'r6BV%|..*5': '-f%_m@u&qv' + 'raI-$$|wT': '-AM-t5l@8j' + 'r>g<|,FWi%': '-ASyJ$*PUD' + 'r?E$4f-d0/': '-B)ld}t{~M' + 'rPekx@n9:L': '->QW9/Z2wb' + 'rSneU!3}bn': '-[ZQ^3c[N)' + 'r]*Ioa%$bK': '-Eq-0^m5Na' + 's#:aIK$9^x': '.kwM-Dl2F=' + 's4E?ZF[E<>': '.d)B}yDxyR' + 's9-GL*@AXD': '.it+:!Ct{^' + 's<_WLSB3I/': '.yG`:L%W-M' + 's>f40rn,O]': 'x<|U~!Rw@;' + 'sB7rZQsRmaA[r' + 'sRw-ik:I$v': '.@8tUdwBl;' + 'sSW/?WMy17': '.[`vBP;rax' + 'sT5=H|@KU|': '.]ez,@CD^0' + sXzvT.g31p: '.{#7]&SWa+' + 'sY>@9w~i^p': '.|ACipLbF+' + 'skOM(?n$GG': '.W=;o.Z5+{' + spKoFRM4if: '.1/0*K;XU8' + 'ssbyA9}]QP': 'wTi%T' + 's|K&2S,UP|': '.J/nbLsN>0' + 't$y1C2R-Av': '/l!a&V@%$;' + 't+v9b--,qs': '/r7iN%t$2.' + 't-0>F]bu>P': '/t~A*;NnAf' + 't2&ri|[Un:': '/bn3U@DNZN' + t3aZXtpNyi: '/cM}{m1G!#' + 't50y/[AEhF': '/e~!v:$xT`' + 'tMMtBUpb$&': '/;;5%N1_lE' + 'tTnCoO/*tk': '/]Z&0Hv!5%' + 't_h*fbN#:(': '/GTqR_<4wF' + 't`cagaWApo': '/HOMS^`t1*' + 'tcneuK7v%`': /OZQ6DgomY + tfCY4j6KEZ: 'Du}tm|Ku-b' + 'tj&bK}%P!F': '/VnN/[mIj`' + 'twrhzJ[k:Z': '/83T#CDdwp' + 't|h3|;E4g{': '/JTcJ*)XSZ' + 'u-`cspdp)D': ':tHO4iPip^' + 'u4^lH.xem5': ':dFX,&9|Yv' + 'u;|vR#mVMW': ':xJ7@4YO;m' + 'uH*Q(mcRt5': ':,q?ofOK5v' + 'uRo^w#p5Be': ':@0F841Y%7' + 'uc61ToT%gt': ':Ofa]h]6S/' + 'ui3>b-?a@m': ':UcAN%B^C(' + upiX0g1quR: ':1U{~~aj6h' + 'uv1fGXHN@)': ':7aR+Q,GCG' + 'u|I}oElaH)': ':J-K0xX^,G' + 'vE[T$`n9T)': ';)D]l>Z2]G' + 'vKx<0Qa!aP': 'o*$5]rH:r[' + 'vNiD6A.v#s': ';8' + 'vgdj#X?8dB': ';SPVkQB1P[' + 'vpOTK6tMr@': ';1=]/Z5F3T' + 'vppEA|$@~g': 'OD{?KH{=z3' + 'vrn%j%6{nu': ';3ZmV6f?Z:' + 'vwc3}.=Z#&': ';8OcK&zSkE' + 'wa>{k!2cXc': NnxZS,Tdmh + x2d4n4ADcP: '=bPdZX$wOf' + 'xb&P*pH(Q': qNEfq1,oJ + 'xqrJ^d[.(O': '=23.F{D&oe' + 'yX:tmrd5;]': '>{w5YkPYxV' + 'z:*d{z(~V2': '?wqPIso]_s' + 'zSc]-^U=0=': '?[OEt<^,~Q' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: cb3de16f-7c9c-4944-994a-e17ec2018c28 diff --git a/fixtures/ultimate-geography/overlays/languages/es.yaml b/fixtures/ultimate-geography/overlays/languages/es.yaml new file mode 100644 index 0000000..6e7ae61 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/es.yaml @@ -0,0 +1,832 @@ +id: overlay.translation.es +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Existe una disputa sobre el nombre entre los países que lo rodean, con Corea del Sur apoyando notablemente el nombre de mar del Este.' + Abkhazia: Abjasia + Abu Dhabi: Abu Dabi + Abuja: Abuya + Accra: Acra + Addis Ababa: 'Adís Abeba' + Adriatic Sea: 'Mar Adriático' + Aegean Sea: Mar Egeo + Afghanistan: 'Afganistán' + Africa: 'África' + Akrotiri and Dhekelia: Acrotiri y Dhekelia + Algeria: Argelia + Algiers: Argel + Also known as Burma.: 'También conocida como Myanmar.' + Also known as Cabo Verde.: '' + Also known as Czechia.: 'También conocida como Chequia.' + Also known as East Timor.: '' + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: 'También conocido como golfo de Siam.' + Also known as the Sea of Cortez.: 'También conocido como mar de Cortés.' + 'Also spelled as Sana''a.': '' + American Samoa: Samoa Americana + Amman: 'Amán' + Amsterdam: 'Ámsterdam' + 'Andorra (narrower, coat of arms with motto)': 'Andorra (más estrecha, escudo con lema)' + Andorra la Vella: Andorra la Vieja + Anguilla: Anguila + Antarctica: 'Antártida' + Antigua and Barbuda: Antigua y Barbuda + Arabian Sea: 'Mar arábigo' + Aral Sea: Mar de Aral + Arctic Ocean: 'Océano Ártico' + Ashgabat: Asjabad + Astana: 'Astaná' + Athens: Atenas + Atlantic Ocean: 'Océano Atlántico' + 'Australia (white stars, two more stars)': 'Australia (estrellas blancas, dos estrellas más)' + 'Austria (brighter red, wider white band)': 'Austria (rojo más vivo, banda blanca más ancha)' + Autonomous community of Spain.: 'Comunidad autónoma de España.' + Autonomous province of South Korea.: 'Provincia autónoma de Corea del Sur.' + Autonomous region of Finland.: 'Región autónoma de Finlandia.' + Autonomous region of Italy.: 'Región autónoma de Italia.' + Autonomous region of Papua New Guinea.: 'Región autónoma de Papúa Nueva Guinea.' + Autonomous region of Portugal.: 'Región autónoma de Portugal.' + Azerbaijan: 'Azerbaiyán' + Baghdad: Bagdad + Bahrain: 'Baréin' + 'Bahrain (narrower, fewer serrated edges, red)': 'Baréin (más estrecha, menos dientes de sierra, roja)' + Baku: 'Bakú' + Balkan Peninsula: 'Península balcánica' + Baltic Sea: 'Mar Báltico' + Banda Sea: Mar de Banda + Bangladesh: 'Bangladés' + Barents Sea: Mar de Barents + Bay of Bengal: 'Bahía de Bengala' + Bay of Biscay: Golfo de Vizcaya + Beijing: 'Pekín' + Belarus: Bielorrusia + Belgium: 'Bélgica' + Belgrade: Belgrado + Belize: Belice + Belmopan: 'Belmopán' + Benin: 'Benín' + Bering Strait: Estrecho de Bering + Berlin: 'Berlín' + Bermuda: Bermudas + Bern: Berna + Bhutan: 'Bután' + Bishkek: Biskek + Bissau: 'Bisáu' + Black Sea: Mar Negro + 'Bolivia (coat of arms instead of star)': 'Bolivia (escudo en lugar de la estrella)' + Bosnia and Herzegovina: Bosnia y Herzegovina + Botswana: Botsuana + 'Brasília': Brasilia + Brazil: Brasil + British Virgin Islands: 'Islas Vírgenes Británicas' + Brunei: 'Brunéi' + Brussels: Bruselas + Bucharest: Bucarest + Cairo: El Cairo + Cambodia: Camboya + Cameroon: 'Camerún' + 'Cameroon (green/red/yellow, yellow star)': 'Camerún (verde/rojo/amarillo, estrella amarilla)' + Canada: 'Canadá' + Canary Islands: Canarias + Cape Verde: Cabo Verde + Caribbean Sea: Mar Caribe + Caspian Sea: Mar Caspio + Cayman Islands: 'Islas Caimán' + Celebes Sea: 'Mar de Célebes' + Celtic Sea: 'Mar Céltico' + Central African Republic: 'República Centroafricana' + Cetinje is an honorary capital.: 'Cetiña es una capital honoraria.' + 'Chad (slightly darker blue)': 'Chad (azul ligeramente más oscuro)' + 'Chișinău': 'Chisináu' + City of San Marino: San Marino + Claimed and controlled: Reclamada y controlada + Claimed but not controlled: Reclamada pero no controlada + 'Colombia (no coat of arms)': 'Colombia (sin escudo)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: Colombo es a menudo referida como la capital, pero Sri Jayawardenapura Kotte, una ciudad a las afueras de Colombo, es la capital oficial y legislativa. + Comoros: Comoras + Conakry: Conakri + Constituent country in the Kingdom of Denmark.: 'Nación constitutiva del Reino de Dinamarca.' + Constituent country of the Kingdom of the Netherlands.: 'Nación constitutiva del Reino de los Países Bajos.' + Constituent country of the United Kingdom.: 'Nación constitutiva del Reino Unido.' + Cook Islands: Islas Cook + Copenhagen: Copenhague + Coral Sea: Mar del Coral + Corsica: 'Córcega' + Croatia: Croacia + Crown dependency of the United Kingdom.: 'Dependencia de la Corona británica.' + 'Cuba (red triangle, blue stripes)': 'Cuba (triángulo rojo, bandas azules)' + 'Curaçao': Curazao + 'Curaçao (two stars in top-left corner)': 'Curazao (dos estrellas en la esquina de arriba a la izquierda)' + Cyprus: Chipre + Czech Republic: 'República Checa' + Damascus: Damasco + Dead Sea: Mar Muerto + Democratic Republic of the Congo: 'República Democrática del Congo' + Denmark: Dinamarca + Denmark Strait: Estrecho de Dinamarca + Dhaka: Daca + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Disputada; reclamada por Israel; Ramala es el centro administrativo.' + 'Disputed; claimed by Palestine.': 'Disputada; reclamada por Palestina.' + Djibouti: Yibuti + Dominican Republic: 'República Dominicana' + Dublin: 'Dublín' + Dushanbe: 'Dusambé' + East China Sea: Mar de la China Oriental + East Siberian Sea: Mar de Siberia Oriental + 'Ecuador (with coat of arms)': 'Ecuador (con escudo)' + Edinburgh: Edimburgo + Egypt: Egipto + 'Egypt (emblem instead of text), Yemen (no text)': 'Egipto (escudo en lugar del texto), Yemen (sin texto)' + 'Egypt (with emblem), Iraq (with text)': 'Egipto (con escudo), Irak (con texto)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (escudo diferente, azul ligeramente más oscuro)' + England: Inglaterra + English Channel: Canal de la Mancha + Equatorial Guinea: Guinea Ecuatorial + Eswatini: Suazilandia + Ethiopia: 'Etiopía' + Europe: Europa + European Union: 'Unión Europea' + Falkland Islands: Islas Malvinas + Faroe Islands: Islas Feroe + Federated States of Micronesia: Estados Federados de Micronesia + Fiji: Fiyi + Finland: Finlandia + Formerly Zaire.: Anteriormente Zaire. + Formerly known as Macedonia.: Anteriormente conocida como Macedonia. + France: Francia + French Guiana: Guayana Francesa + French Polynesia: Polinesia Francesa + Gabon: 'Gabón' + Germany: Alemania + 'Ghana (star instead of coat of arms)': 'Ghana (estrella en lugar del escudo)' + Gitega: Guitega + Greece: Grecia + Greenland: Groenlandia + Grenada: Granada + Guadeloupe: Guadalupe + Guatemala City: Ciudad de Guatemala + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (verde y rojo intercambiados, verde ligeramente más oscuro)' + Guinea-Bissau: 'Guinea-Bisáu' + Gulf of Alaska: Golfo de Alaska + Gulf of California: Golfo de California + Gulf of Carpentaria: Golfo de Carpentaria + Gulf of Guinea: Golfo de Guinea + Gulf of Mexico: 'Golfo de México' + Gulf of Thailand: Golfo de Tailandia + 'Hagåtña': 'Agaña' + Haiti: 'Haití' + Hanoi: 'Hanói' + Havana: La Habana + Hawaii: 'Hawái' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Región histórica y cultural del norte de la Europa Septentrional, que incluye los países de Dinamarca, Noruega y Suecia, y a veces Islandia y Finlandia.' + Hudson Bay: 'Bahía de Hudson' + Hungary: 'Hungría' + Iceland: Islandia + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Islandia (fondo azul, cruz roja y blanca), Noruega (fondo rojo, cruz azul y blanca)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Islandia (fondo azul, cruz roja), Islas Feroe (fondo blanco, cruz roja y azul)' + Independent state claimed by Georgia.: Estado independiente reclamado por Georgia. + Independent state claimed by Moldova.: Estado independiente reclamado por Moldavia. + Independent state claimed by Somalia.: Estado independiente reclamado por Somalia. + Indian Ocean: 'Océano Índico' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesia (rojo y blanco intercambiados, rojo más vivo), Mónaco (rojo y blanco intercambiados, más estrecha)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesia (más ancha, rojo más vivo), Polonia (rojo y blanco intercambiados, más ancha)' + Iran: 'Irán' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (texto en lugar de escudo), Yemen (sin escudo)' + Ireland: Irlanda + 'Ireland (orange and green flipped, wider)': 'Irlanda (naranja y verde intercambiados, más ancha)' + Island of Indonesia.: Isla de Indonesia. + Isle of Man: Isla de Man + Italy: Italia + Ivory Coast: Costa de Marfil + 'Ivory Coast (green and orange flipped, narrower)': 'Costa de Marfil (verde y naranja intercambiados, más estrecha)' + Jakarta: Yakarta + Japan: 'Japón' + Jerusalem: 'Jerusalén' + Jordan: Jordania + Juba: Yuba + Kaliningrad Oblast: 'Óblast de Kaliningrado' + Kathmandu: 'Katmandú' + Kazakhstan: 'Kazajistán' + Kenya: Kenia + Khartoum: Jartum + Kinshasa: Kinsasa + Known as Nur-Sultan between 2019 and 2022: 'Conocida como Nursultán entre 2019 y 2022' + Known as Swaziland until 2018.: Oficialmente Esuatini desde 2018. + Kuwait City: Kuwait + Kyiv: Kiev + Kyrgyzstan: 'Kirguistán' + Laayoune: 'El Aaiún' + Labrador Sea: Mar de Labrador + Latvia: Letonia + 'Latvia (darker red, narrower white band)': 'Letonia (rojo más oscuro, banda blanca más estrecha)' + Lebanon: 'Líbano' + Lesotho: Lesoto + Libya: Libia + Lilongwe: 'Lilongüe' + Lisbon: Lisboa + Lithuania: Lituania + Ljubljana: Liubliana + London: Londres + Luxembourg: Luxemburgo + 'Luxembourg (lighter blue)': 'Luxemburgo (azul más claro)' + Luxembourg City: Luxemburgo + Macau: Macao + Malawi: Malaui + Malaysia: Malasia + Maldives: Maldivas + Mali: 'Malí' + 'Mali (red and green flipped, slightly brighter green)': 'Malí (rojo y verde intercambiados, verde ligeramente más vivo)' + Marshall Islands: Islas Marshall + Martinique: Martinica + Mauritius: Mauricio + Mediterranean Sea: 'Mar Mediterráneo' + Mexico: 'México' + Mexico City: 'Ciudad de México' + Mogadishu: Mogadiscio + Moldova: Moldavia + 'Moldova (wider, coat of arms with eagle)': 'Moldavia (más ancha, escudo con águila)' + Monaco: 'Mónaco' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Mónaco (más estrecha, rojo más oscuro), Polonia (rojo y blanco intercambiados, rojo más oscuro)' + Morocco: Marruecos + Moscow: 'Moscú' + Muscat: Mascate + Myanmar: Birmania + 'N''Djamena': Yamena + Nassau: 'Nasáu' + 'Nauru (single star below yellow band)': 'Nauru (una sola estrella debajo de la banda amarilla)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru no tiene capital oficial; el distrito de Yaren es la capital de facto.' + Naypyidaw: 'Naipyidó' + Netherlands: 'Países Bajos' + 'Netherlands (darker blue)': 'Países Bajos (azul más oscuro)' + New Caledonia: Nueva Caledonia + New Delhi: Nueva Delhi + New Zealand: Nueva Zelanda + 'New Zealand (red stars, two fewer stars)': 'Nueva Zelanda (estrellas rojas, dos estrellas menos)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (escudo diferente, azul ligeramente más claro)' + Niger: 'Níger' + North America: 'América del Norte' + North Korea: Corea del Norte + North Macedonia: Macedonia del Norte + North Nicosia: Nicosia del Norte + North Sea: Mar del Norte + Northern Cyprus: Chipre del Norte + Northern Ireland: Irlanda del Norte + Northern Mariana Islands: Islas Marianas del Norte + Norway: Noruega + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Noruega (fondo rojo, cruz azul), Islas Feroe (fondo blanco, cruz roja y azul)' + Norwegian Sea: Mar de Noruega + Not a sovereign country: 'No es un país soberano' + Nouakchott: Nuakchot + 'Nouméa': Numea + 'Nukuʻalofa': Nukualofa + 'Oblast (administrative region) of the Russian Federation.': 'Óblast (región administrativa) de la Federación de Rusia.' + Oceania: 'Oceanía' + Official capital was moved from Bujumbura to Gitega in 2019.: 'La capital oficial se cambió de Buyumbura a Guitega en 2019.' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: 'La capital oficial se cambió de Malabo a Ciudad de la Paz en 2026.' + 'Officially Côte d''Ivoire.': 'Oficialmente Côte d''Ivoire.' + Officially Luxembourg.: '' + Oman: 'Omán' + Ouagadougou: 'Uagadugú' + Overseas department of France.: Departamento de ultramar de Francia. + Overseas territory of France.: Territorio de ultramar de Francia. + Overseas territory of the United Kingdom.: Territorio de ultramar del Reino Unido. + Pacific Ocean: 'Océano Pacífico' + Pakistan: 'Pakistán' + Palau: Palaos + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (negro/blanco/verde, triángulo rojo)' + 'Palestine (no symbol)': 'Palestina (sin símbolo)' + Panama: 'Panamá' + Panama City: 'Ciudad de Panamá' + Papua New Guinea: 'Papúa Nueva Guinea' + Paris: 'París' + Partially recognised state claimed by China.: Estado parcialmente reconocido reclamado por China. + Partially recognised state claimed by Morocco. Also known as Western Sahara.: Estado parcialmente reconocido reclamado por Marruecos. + Partially recognised state claimed by Serbia.: Estado parcialmente reconocido reclamado por Serbia. + Persian Gulf: 'Golfo Pérsico' + Peru: 'Perú' + Philippine Sea: Mar de Filipinas + Philippines: Filipinas + Phnom Penh: Nom Pen + Poland: Polonia + Polynesia: Polinesia + Port Moresby: Puerto Moresby + Port of Spain: 'Puerto España' + Port-au-Prince: 'Puerto Príncipe' + Porto-Novo: Porto Novo + Prague: Praga + Pretoria, Cape Town, Bloemfontein: Pretoria, Ciudad del Cabo, Bloemfontein + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (triángulo azul, bandas rojas)' + Pyongyang: Pionyang + Qatar: Catar + 'Qatar (wider, more serrated edges, maroon)': 'Catar (más ancha, dientes más serrados, granate)' + Red Sea: Mar Rojo + Region of France.: 'Región de Francia.' + Republic of the Congo: 'República del Congo' + 'Reykjavík': Reikiavik + Riyadh: Riad + Romania: Rumania + 'Romania (slightly lighter blue)': 'Rumania (azul ligeramente más claro)' + Rome: Roma + Russia: Rusia + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Rusia (sin escudo), Eslovenia (más ancha, escudo más pequeño)' + Rwanda: Ruanda + 'Réunion': 'Reunión' + Sahrawi Arab Democratic Republic: 'República Árabe Saharaui Democrática' + Saint Kitts and Nevis: 'San Cristóbal y Nieves' + Saint Lucia: 'Santa Lucía' + Saint Martin: 'San Martín (Francia)' + Saint Vincent and the Grenadines: San Vicente y las Granadinas + Sanaa: 'Saná' + Santiago: Santiago de Chile + Sardinia: 'Cerdeña' + Saudi Arabia: Arabia Saudita + Scandinavia: Escandinavia + Scotland: Escocia + Sea of Galilee: Mar de Galilea + Sea of Japan: 'Mar del Japón' + Sea of Okhotsk: Mar de Ojotsk + Semi-autonomous region of Tanzania.: 'Región semiautónoma de Tanzania.' + 'Senegal (green/yellow/red, green star)': 'Senegal (verde/amarillo/rojo, estrella verde)' + Seoul: 'Seúl' + Sicily: Sicilia + Sierra Leone: Sierra Leona + Singapore: Singapur + Sint Maarten: 'San Martín (Países Bajos)' + Skopje: Skopie + Slovakia: Eslovaquia + 'Slovakia (narrower, bigger coat of arms)': 'Eslovaquia (más estrecha, escudo más grande)' + 'Slovakia (with coat of arms)': 'Eslovaquia (con escudo)' + Slovenia: Eslovenia + Sofia: 'Sofía' + Solomon Islands: 'Islas Salomón' + Somaliland: Somalilandia + South Africa: 'Sudáfrica' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Sudáfrica no tiene capital oficial: los poderes del Estado están repartidos en tres ciudades: Pretoria (ejecutivo), Ciudad del Cabo (legislativo) y Bloemfontein (judicial).' + South America: 'América del Sur' + South China Sea: Mar de la China Meridional + South Korea: Corea del Sur + South Ossetia: Osetia del Sur + South Sudan: 'Sudán del Sur' + South Tarawa: Tarawa Sur + Southern Ocean: 'Océano Antártico' + Sovereign country: 'Es un país soberano' + Spain: 'España' + Special Administrative Region of China.: 'Región administrativa especial de China.' + Sri Jayawardenepura Kotte: Sri Jayawardenapura Kotte + 'St. George''s': Saint George + 'St. John''s': Saint John + State of the United States.: Estado de los Estados Unidos. + State recognised only by Turkey and claimed by Cyprus.: 'Estado reconocido solo por Turquía y reclamado por Chipre.' + Stockholm: Estocolmo + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Subregión de Oceanía compuesta por miles de islas pequeñas en el centro y la parte sur del océano Pacífico.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Subregión de Oceanía compuesta por miles de islas pequeñas en la parte oeste del océano Pacífico.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Subregión de Oceanía que incluye Vanuatu, las Islas Salomón, Fiyi y Papúa Nueva Guinea.' + Sudan: 'Sudán' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudán (rojo/blanco/negro, triángulo verde), República Árabe Saharaui Democrática (con creciente y estrella)' + Sukhumi: Sujumi + Suriname: Surinam + Sweden: Suecia + Switzerland: Suiza + 'Switzerland has no official capital; Bern is the de facto capital.': 'Suiza no tiene capital oficial; Berna es la capital de facto.' + Syria: Siria + 'São Tomé': 'Santo Tomé' + 'São Tomé and Príncipe': 'Santo Tomé y Príncipe' + Taipei: 'Taipéi' + Taiwan: 'Taiwán' + Tajikistan: 'Tayikistán' + Tallinn: Tallin + Tashkent: Taskent + Tasman Sea: Mar de Tasmania + Tbilisi: Tiflis + Tehran: 'Teherán' + Thailand: Tailandia + The Bahamas: Bahamas + The Gambia: Gambia + Thimphu: Timbu + Timor Sea: Mar de Timor + Timor-Leste: Timor Oriental + Tiraspol: 'Tiráspol' + Tokyo: Tokio + Trinidad and Tobago: Trinidad y Tobago + Tripoli: 'Trípoli' + Tskhinvali: Tsjinval + Tunis: 'Túnez' + Tunisia: 'Túnez' + Turkey: 'Turquía' + Turkmenistan: 'Turkmenistán' + Turks and Caicos Islands: Islas Turcas y Caicos + Ukraine: Ucrania + Ulaanbaatar: 'Ulán Bator' + Unincorporated internal area of Norway.: Territorio dependiente de Noruega. + Unincorporated territory of the United States.: Territorio no incorporado de los Estados Unidos. + United Arab Emirates: 'Emiratos Árabes Unidos' + United Kingdom: Reino Unido + United States Virgin Islands: 'Islas Vírgenes de los Estados Unidos' + United States of America: Estados Unidos + Uzbekistan: 'Uzbekistán' + Valletta: La Valeta + Vatican City: Ciudad del Vaticano + Vienna: Viena + Vientiane: 'Vientián' + Vilnius: Vilna + Wales: Gales + Wallis and Futuna: Wallis y Futuna + Warsaw: Varsovia + Washington, D.C.: Washington D. C. + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Aunque Ámsterdam es la capital oficial, La Haya es la sede del gobierno y del poder legislativo.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: Aunque Dodoma es la capital oficial, Dar es-Salam es la capital de facto. + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Aunque El Aaiún es la capital proclamada, Tifariti es la capital de facto.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: Aunque Mbabane es la capital oficial y ejecutiva, Lobamba es la capital tradicional, espiritual y legislativa. + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Aunque Porto Novo es la capital oficial, Cotonú es la capital de facto.' + While Sucre is the constitutional capital, La Paz is the seat of government.: Aunque Sucre es la capital constitucional, La Paz es sede de gobierno. + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Aunque Yamusukro es la capital oficial, Abiyán es la capital de facto.' + White Sea: Mar Blanco + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Continente que abarca la plataforma continental de Australia y la mayoría de islas del océano Pacífico.' + Yamoussoukro: Yamusukro + 'Yaoundé': 'Yaundé' + Yellow Sea: Mar Amarillo + Yerevan: 'Ereván' + Zanzibar: 'Zanzíbar' + Zimbabwe: Zimbabue + 'Åland Islands': 'Åland' + additions: + notes.note.celebes-sea.fields.field.country-info: 'También conocido como mar de Sulawesi.' + variables: + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Pista: {{Capital hint}}' + label.flag: + Flag: Bandera + label.location: + Location: 'Ubicación' + note-type.name: + Ultimate Geography: 'Ultimate Geography [ES]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Bandera similar a {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': '860fGtb*C' + '5JJ2]i)p4': 'f=r^TR.L0,' + '5S(f7%O-$': 'X0E7(l3vg+' + 'B:x+BF' + 'BJ,,^wMC%N': 'vch6I=6.I^' + Bszsaz0Iuo: '@A!3Lr}AC)' + 'Cf2@;p|&U' + '_]DH+g9!$': 'dnXM*r6i' + 'cAlyc~G.y%': '4IW9N[*%GC' + 'cEO^y)Md[G': '4Mw' + 'e!KSebP}pt': 'CR+!|lD[|f' + 'e+/O]%*qfk': '6=uf': '6@4k^ad#{7' + 'e42BpV,P}C': '6$a$0NrHg[' + 'e5P*D#I{m{': '6%=p&3,>uY' + 'e,' + 'e_;dzXj8de': '6cwO!PU0l6' + 'epvt3>HH?U': '6x64b,+z|j' + 'euW0jm=wfR': '6C_}Ueyong' + 'e{e8vi@^PY': '6ePg6aB;Xn' + 'f#4-L[mp5*': '7,cs//Xh%G' + 'f*2@qiHh_1': '7!0PN': '7c7x2E]Q6': '7{Nf8U(:Yv' + 'fRSM{K+;[w': '7Z@:HCq)~;' + 'fT!w8R#>dg': 71i7gJj,l8 + 'fV`&p81%d(': '73Gm00~5lE' + fkq7lhLkdH: 'r[gU./Cvk`' + 'fxAN),FAc$': '7F#;o#)skB' + 'g-]bb&[.E!': '8?DMM6C%Mz' + 'g9v~y/;=z(': '8*6K9&w+HE' + 'g<[f89~U)A': '8_CQg1KM;?' + 'gBCR?!O*;E': '8J%?A2<9^^' + 'gB|K:+r?1V': '8JI.v!2-9k' + 'gDsLPj(9S#': '8L3/=bn10A' + 'gFOojQO(MH': '8NH0': '8Oe@_|:,Pp' + 'gRVZ]qJ#>O': '8Z^|Di-3{d' + 'gYIoR`|AxW': '86,Z?=IsFl' + 'gePC:Xt_nW': '8m=%vP4n98~V1': '9/GzY1g[3q' + 'h9Jr(H[=1~': 'bP!a$TSZBB' + 'h?a*UKfT_M': '9|Lp]CQLcb' + 'hE5j*e,Q?[': '9MdUp{rI|T' + 'hNE9}>;Q&-': '9V(hJ,wI/J' + 'hXNTVF<:SH': '95;[^xx(0{' + 'hZ/V/D&rO`': '97u^uvmjWX' + 'h_9=mkZXAH': '9chyXc|PI{' + hoHandbYAy: '9w+LY`MQI=' + 'hrI_uiW>E]': 9z,F5a_,MU + 'h}lnSN_f$_': '9gWY@FF|-W' + 'h~5xz+=ke~': '9hd8!!ycm1' + 'i)]+09JUR&': '!;Dq}1-MZD' + 'i2]v|D3@:j': '!!D6Ivb.]#' + 'iB-8Br~onq': '!Jsg$jKgv+' + 'iIS#Nw]tr]': '!Q@j;oDlzU' + 'iR;Um(L/~c=': '!4zXnDu[kP' + 'i^J8&$(1]|': '!b-gm4nTaZ' + 'ia8as9sCY|': '!igL313u6Z' + 'igiK,ff[eO': 'EsrUZ!O{Q%' + 'ijP>aJBT!9': '!r=zLB$L+y' + 'ilGtw#=asM': '!t*473y]Ab' + 'j%*8gN)q>d': '#.pgRFoi{5' + 'j)i)pB*HJ,': '#;To0tpzRI' + 'j2IQ=f=w3@': '#!,>y|yo#S' + j7eEZkzsCZ: '#(P(|c!kKo' + 'j9:K,~DQ2v': '#*v.r[&I!:' + 'jg|}': '#JOB#-z}f0' + 'jCd]`-=k,:': '#KODG$yc>M' + 'jDA-V?/hVj': '#L#s^-u~3#' + jI9P-f6r3M: '#Qh=s|ej#b' + 'jKIDq|}J&c': '#S,&1?JB/4' + 'jKO,9y0M;#': '#SP7XjN' + 'koI(}OyJ:@': '$w,nJG9B]S' + 'kopwzzB``A': 'N`Q4k]7};1' + 'k}4/oj#$~s': '$gcuZbj4h-' + 'k}A9]O:0xw': '$g#hDGvSF;' + 'l$2O|9w`F~': '%-aKi!j#v+': '%=z.T2U3DH' + 'l3Ly8PBxt(': '%#/9gH$pBE' + 'lAxH.CfJB@': '%I8+tuQBJS' + 'lH^2D]w;[H': '%PEa&:7)~{' + 'lU@C3p3' + 'mtd;>7T|g>': '&BOwzZ[?oQ' + 'mz[>>AxIr4': '&HCzzs8Azt' + 'm{noqE29%I': '&eYZ1wa1.|' + 'm{oM,jr>C#': '&eZ:rb2,KA' + 'n&>e`2FO/S': '(/zPGU)G[h' + 'n+t>2`:;P/': '(=4za=v)XL' + 'n:7&>YT5A=': '(]fmzQ[XIP' + 'nIfRn/yz': ')dGvASz&G>' + 'oa87&PB&Bm': ')igfmH$6J&' + 'onMKID2d;D': ')v:.,va`^]' + 'o~}KVA:VbI': ')hJ.^svNj|' + 'p#R$-mG/L': 'h,gB?X*uD' + 'p.yv>BQ470': '*@96zt>W(p' + 'p:SPZ:e/*[': '*]@=|(P&DrFhd%u': '*0z&2xS`./' + 'pYRw[=6;JI': 'xJzDb{C(wd' + 'p]t8:Tka7q': '*a4gvLV](+' + pkj3RPBUrF: '*sUb?H$Mz_' + 'pwwZu{7f~(': '*E7|5>f|hE' + 'pz(G*LI2fHxg' + 'q1Q3Do8S>g': '+9>b&ggK{8' + 'q2#Yv[O0:|': '+!j{6/Wg': '+]`9*|j,48' + 'q=w[fB$d+$': '+FlzQtk`=B' + 'r(h9&#wc~B': ',:Shm37_h@' + 'r/B|ra%L,.': ',[$I2]lD>K' + 'r5C646H*+*': ',%%ecY+9=G' + 'r6BV%|..*5': ',&$^l?t%aI-$$|wT': ',{L,s4k?Ei' + 'r>g<|,FWi%': ',{RxI#)OqC' + 'r?E$4f-d0/': ',|(kc|s`8L' + 'rPekx@n9:L': ',XPV8.Y1]a' + 'rSneU!3}bn': ',0YP]2b@j(' + 'r]*Ioa%$bK': ',ap,Z]l4j~' + 's#:aIK$9^x': '-,vL,Ck1b<' + 's4E?ZF[E<>': '-$(A|xCw_Q' + 's9-GL*@AXD': '-*s*/9Bs5]' + 's<_WLSB3I/': '-_F_/K$VQL' + 's>f40rn,O]': 'yK@HKIZR,h' + 'sB7rZQsR@9w~i^p': '-6zBhoKab*' + 'skOM(?n$GG': '-s<:n-Y4O`' + spKoFRM4if: '-x.Z)J:Wq7' + 'ssbyA9}]QP': 'izUG=]G]=<' + 'stRa/S(0$F': '-B?LuKnS-_' + 'sxOMs+bE8@': '-F<:3!Mw)S' + 'sx|l+io`B@': '-FIWqaZ=JS' + 's|K&2S,UP|': '-f.maKrMXZ' + 't$y1C2R-Av': '.-9~%U?$I:' + 't+v9b--,qs': '.=6hM$s#y-' + 't-0>F]bu>P': '.?}z):Mm{e' + 't2&ri|[Un:': '.!m2T?CMvM' + t3aZXtpNyi: '.#L|`l0FG!' + 't50y/[AEhF': '.%}9u/#wp_' + 'tMMtBUpb$&': '.U:4$M0^-D' + 'tTnCoO/*tk': '.1Y%ZGu9B$' + 't_h*fbN#:(': '.cSpQ^;3]E' + 't`cagaWApo': '.dNLR]_sx)' + 'tcneuK7v%`': .kYP5Cfn.X + tfCY4j6KEZ: 'JaKw#SXuA.' + 'tj&bK}%P!F': '.rmM.@lH+_' + 'twrhzJ[k:Z': '.E2S!BCc]o' + 't|h3|;E4g{': '.fSbI)(WoY' + 'u-`cspdp)D': '/?GN3hOh;]' + 'u4^lH.xem5': '/$EW+%8{uu' + 'u;|vR#mVMW': '/^I6?3XNUl' + 'uH*Q(mcRt5': '/Pp>neNJBu' + 'uRo^w#p5Be': /ZZE730XJ6 + 'uc61ToT%gt': '/ke~[g[5o.' + 'ui3>b-?a@m': '/qbzM$A]}&' + upiX0g1quR: '/xT`}}~iCg' + 'uv1fGXHN@)': '/D~Q*P+F}F' + 'u|I}oElaH)': '/f,JZwW]PF' + 'vE[T$`n9T)': ':MC[k=Y11F' + 'vKx<0Qa!aP': 'k=,7DzSs>p' + 'vNiD6A.v#s': ':VT&estn,-' + 'va/ky=f5pK': ':iuV9+QXx~' + 'vf)G?[_?Pf': ':no*A/F-X7' + 'vgdj#X?8dB': ':oOUjPA0l@' + 'vpOTK6tMr@': ':x<[.Y4EzS' + 'vppEA|$@~g': 'gboIok!xh^' + 'vrn%j%6{nu': ':zYlU5e>v/' + 'vwc3}.=Z#&': ':ENbJ%yR,D' + 'wa>{k!2cXc': cv-,SC1HIr + x2d4n4ADcP: ']pOHrn[3r' + 'zSc]-^U=0=': '>0NDs;]+8P' + crowdanki:uuid: + 43c5ba66-9a65-11e8-90c9-a0481cc15658: cb4d32ee-12ed-9960-1841-28c09449ded0 + 43e2586a-9a65-11e8-a777-a0481cc15658: 0bacddfd-1e81-4e62-9152-bdff33db0374 +deck: + name: + intent: replace + value: 'Ultimate Geography [ES]' + expected_base: + value: Ultimate Geography + description: + intent: replace + value: | + DESCRIPCIÓN COMPLETA | NOTAS DE LANZAMIENTO | CÓMO CONTRIBUIR + + Características de Ultimate Geography v5.3: + + - Los 205 estados soberanos del mundo (820 cartas). + - 59 territorios, regiones del mundo, y otras entidades (103 cartas). + - 48 océanos y mares (48 cartas, solo mapas). + - 7 continentes (7 cartas, solo mapas). + - Un total de 319 notas diferentes, 978 cartas, 221 banderas y 319 mapas. + + Este mazo está disponible en inglés, alemán, español, francés, noruego, checo, ruso, neerlandés, sueco, portugués, chino (simplificado y tradicional), polaco, italiano y danesa. También está disponible una versión extendida en cada idioma. Para ayudar a la memorización y contextualizar mientras se aprende, algunas notas incluyen información extra como banderas similares, información sobre la forma de gobierno, nombres alternativos del país, etc. + + Puedes usar la función de Anki de mazos filtrados para centrarte en alguna parte del mazo en particular, como estados soberanos, un tipo de plantilla de nota (por ejemplo, mapa-país) o un continente en concreto (por ejemplo, Europa). + + Este mazo se desarrolla en Github. Si encuentras algún fallo, tienes alguna sugerencia o quieres ayudar, por favor, no dudes en abrir una incidencia. ¿Quieres mantenerte informado de las nuevas versiones? ¡Mira el repositorio de Github o sigue los lanzamientos! + expected_base: + 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! diff --git a/fixtures/ultimate-geography/overlays/languages/fr.yaml b/fixtures/ultimate-geography/overlays/languages/fr.yaml new file mode 100644 index 0000000..22752f1 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/fr.yaml @@ -0,0 +1,768 @@ +id: overlay.translation.fr +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Il y a un contentieux sur le nom de cette mer entre les pays qui l''entourent, la Corée du Sud l''appelant notamment Mer de l''Est.' + Abkhazia: Abkhazie + Abu Dhabi: Abou Dabi + Addis Ababa: Addis-Abeba + Adriatic Sea: Mer Adriatique + Aegean Sea: 'Mer Égée' + Africa: Afrique + Akrotiri and Dhekelia: Akrotiri et Dhekelia + Albania: Albanie + Algeria: 'Algérie' + Algiers: Alger + Also known as Burma.: Aussi connue comme le Myanmar. + Also known as Cabo Verde.: '' + Also known as Czechia.: 'Aussi connue comme la République tchèque.' + Also known as East Timor.: Aussi connu comme le Timor-Leste. + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: Aussi connu comme le golfe du Siam. + Also known as the Sea of Cortez.: 'Aussi connu comme la mer de Cortés.' + 'Also spelled as Sana''a.': '' + American Samoa: 'Samoa américaines' + Andorra: Andorre + 'Andorra (narrower, coat of arms with motto)': 'Andorre (plus étroit, blason avec devise)' + Andorra la Vella: Andorre-la-Vieille + Antarctica: Antarctique + Antigua and Barbuda: Antigua-et-Barbuda + Arabian Sea: 'Mer d''Arabie' + Aral Sea: 'Mer d''Aral' + Arctic Ocean: 'Océan Arctique' + Argentina: Argentine + Armenia: 'Arménie' + Ashgabat: Achgabat + Asia: Asie + 'Asunción': Asuncion + Athens: 'Athènes' + Atlantic Ocean: 'Océan Atlantique' + Australia: Australie + 'Australia (white stars, two more stars)': 'Australie (étoiles blanches, deux étoiles de plus)' + Austria: Autriche + 'Austria (brighter red, wider white band)': 'Autriche (rouge plus clair, bande blanche plus large)' + Autonomous community of Spain.: 'Communauté autonome d''Espagne.' + Autonomous province of South Korea.: 'Province autonome de Corée du Sud.' + Autonomous region of Finland.: Province autonome de Finlande. + Autonomous region of Italy.: 'Région d''Italie à statut spécial.' + Autonomous region of Papua New Guinea.: 'Région autonome de Papouasie-Nouvelle-Guinée.' + Autonomous region of Portugal.: 'Région autonome du Portugal.' + Azerbaijan: 'Azerbaïdjan' + Azores: 'Açores' + Baghdad: Bagdad + Bahrain: 'Bahreïn' + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahreïn (séparation plus étroite et moins dentelée, rouge)' + Baku: Bakou + Balkan Peninsula: Balkans + Baltic Sea: Mer Baltique + Banda Sea: Mer de Banda + Barbados: Barbade + Barents Sea: Mer de Barents + Bay of Bengal: Golfe du Bengale + Bay of Biscay: Golfe de Gascogne + Beijing: 'Pékin' + Beirut: Beyrouth + Belarus: 'Biélorussie' + Belgium: Belgique + Benin: 'Bénin' + Bering Strait: 'Détroit de Béring' + Bermuda: Bermudes + Bern: Berne + Bhutan: Bhoutan + Bishkek: Bichkek + Black Sea: Mer Noire + 'Bogotá': Bogota + Bolivia: Bolivie + 'Bolivia (coat of arms instead of star)': 'Bolivie (blason à la place de l''étoile)' + Bosnia and Herzegovina: 'Bosnie-Herzégovine' + 'Brasília': Brasilia + Brazil: 'Brésil' + British Virgin Islands: 'Îles Vierges britanniques' + Brussels: Bruxelles + Bucharest: Bucarest + Bulgaria: Bulgarie + Cairo: Le Caire + Cambodia: Cambodge + Cameroon: Cameroun + 'Cameroon (green/red/yellow, yellow star)': 'Cameroun (vert/rouge/jaune, étoile jaune)' + Canary Islands: 'Îles Canaries' + Cape Verde: Cap-Vert + Caribbean Sea: 'Mer des Caraïbes' + Caspian Sea: Mer Caspienne + Cayman Islands: 'Îles Caïmans' + Celebes Sea: 'Mer de Célèbes' + Celtic Sea: Mer Celtique + Central African Republic: 'République centrafricaine' + Cetinje is an honorary capital.: Cetinje est une capitale honoraire. + Chad: Tchad + 'Chad (slightly darker blue)': 'Tchad (bleu légèrement plus foncé)' + Charlotte Amalie: 'Charlotte-Amélie' + Chile: Chili + China: Chine + City of San Marino: Ville de Saint-Marin + Claimed and controlled: 'Revendiquée et contrôlée' + Claimed but not controlled: 'Revendiquée mais non contrôlée' + Colombia: Colombie + 'Colombia (no coat of arms)': 'Colombie (pas de blason)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'On fait souvent référence à Colombo comme capitale, mais Sri Jayawardenapura Kotte, une ville de la banlieue de Colombo, est la capitale législative officielle.' + Comoros: Comores + Constituent country in the Kingdom of Denmark.: Pays constitutif du royaume du Danemark. + Constituent country of the Kingdom of the Netherlands.: 'État autonome du royaume des Pays-Bas.' + Constituent country of the United Kingdom.: Nation constitutive du Royaume-Uni. + Cook Islands: 'Îles Cook' + Copenhagen: Copenhague + Coral Sea: Mer de Corail + Corsica: Corse + Croatia: Croatie + Crown dependency of the United Kingdom.: 'Dépendance de la Couronne britannique.' + 'Cuba (red triangle, blue stripes)': 'Cuba (triangle rouge, bandes bleues)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (deux étoiles en haut à gauche)' + Cyprus: Chypre + Czech Republic: 'Tchéquie' + Damascus: Damas + Dead Sea: Mer Morte + Democratic Republic of the Congo: 'République démocratique du Congo' + Denmark: Danemark + Denmark Strait: 'Détroit de Danemark' + Dhaka: Dacca + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Contestée; revendiquée par Israël; Ramallah est la capitale administrative de fait.' + 'Disputed; claimed by Palestine.': 'Contestée; revendiquée par la Palestine.' + Dominica: Dominique + Dominican Republic: 'République dominicaine' + Dushanbe: 'Douchanbé' + East China Sea: Mer de Chine orientale + East Siberian Sea: 'Mer de Sibérie orientale' + Ecuador: 'Équateur' + 'Ecuador (with coat of arms)': 'Équateur (avec blason)' + Edinburgh: 'Édimbourg' + Egypt: 'Égypte' + 'Egypt (emblem instead of text), Yemen (no text)': 'Égypte (emblème à la place du texte), Yémen (pas de texte)' + 'Egypt (with emblem), Iraq (with text)': 'Égypte (avec un emblème), Irak (avec du texte)' + El Salvador: Salvador + 'El Salvador (different coat of arms, slightly darker blue)': 'Salvador (blason différent, bleu légèrement plus foncé)' + England: Angleterre + English Channel: Manche + Equatorial Guinea: 'Guinée équatoriale' + Eritrea: 'Érythrée' + Estonia: Estonie + Ethiopia: 'Éthiopie' + European Union: 'Union européenne' + Falkland Islands: 'Îles Malouines' + Faroe Islands: 'Îles Féroé' + Federated States of Micronesia: 'États fédérés de Micronésie' + Fiji: Fidji + Finland: Finlande + Formerly Zaire.: 'Anciennement le Zaïre.' + Formerly known as Macedonia.: 'Anciennement connue comme la Macédoine.' + French Guiana: Guyane + French Polynesia: 'Polynésie française' + Georgia: 'Géorgie' + Germany: Allemagne + 'Ghana (star instead of coat of arms)': 'Ghana (étoile à la place du blason)' + Greece: 'Grèce' + Greenland: Groenland + Grenada: Grenade + Guatemala City: Guatemala + Guernsey: Guernesey + Guinea: 'Guinée' + 'Guinea (green and red flipped, slightly darker green)': 'Guinée (vert et rouge inversés, vert légèrement plus foncé)' + Guinea-Bissau: 'Guinée-Bissau' + Gulf of Alaska: 'Golfe d''Alaska' + Gulf of California: Golfe de Californie + Gulf of Carpentaria: Golfe de Carpentarie + Gulf of Guinea: 'Golfe de Guinée' + Gulf of Mexico: Golfe du Mexique + Gulf of Thailand: 'Golfe de Thaïlande' + Haiti: 'Haïti' + Hanoi: 'Hanoï' + Havana: La Havane + Hawaii: 'Hawaï' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Région historique et culturelle d''Europe du Nord constituée de trois monarchies constitutionnelles, le Danemark, la Norvège et la Suède, et parfois de façon incorrecte l''Islande et la Finlande.' + Hudson Bay: 'Baie d''Hudson' + Hungary: Hongrie + Iceland: Islande + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Islande (fond bleu, croix rouge et blanche), Norvège (fond rouge, croix bleue et blanche)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Islande (fond bleu, croix rouge), Îles Féroé (fond blanc, croix rouge et bleue)' + Independent state claimed by Georgia.: 'État indépendant revendiqué par la Géorgie.' + Independent state claimed by Moldova.: 'État indépendant revendiqué par la Moldavie.' + Independent state claimed by Somalia.: 'État indépendant revendiqué par la Somalie.' + India: Inde + Indian Ocean: 'Océan Indien' + Indonesia: 'Indonésie' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonésie (blanc et rouge inversés, rouge plus clair), Monaco (blanc et rouge inversés, plus étroit)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonésie (plus large, rouge plus clair), Pologne (rouge et blanc inversés, plus large)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (texte à la place de l''emblème), Yémen (pas d''emblème)' + Ireland: Irlande + 'Ireland (orange and green flipped, wider)': 'Irlande (orange et vert inversés, plus large)' + Island of Indonesia.: + notes.note.bali.fields.field.country-info: 'Île d''Indonésie.' + notes.note.java.fields.field.country-info: 'Île d''Indonésie.' + notes.note.sumatra.fields.field.country-info: 'Île indonésienne.' + Isle of Man: 'Île de Man' + Israel: 'Israël' + Italy: Italie + Ivory Coast: 'Côte d''Ivoire' + 'Ivory Coast (green and orange flipped, narrower)': 'Côte d''Ivoire (vert et orange inversés, plus étroit)' + Jamaica: 'Jamaïque' + Japan: Japon + Jerusalem: 'Jérusalem' + Jordan: Jordanie + Juba: Djouba + Kabul: Kaboul + Kaliningrad Oblast: Oblast de Kaliningrad + Kathmandu: Katmandou + Known as Nur-Sultan between 2019 and 2022: Connue sous le nom de Noursoultan entre 2019 et 2022 + Known as Swaziland until 2018.: 'Connu comme le Swaziland jusqu''en 2018.' + Kuwait: 'Koweït' + Kuwait City: 'Koweït' + Kyiv: Kiev + Kyrgyzstan: Kirghizistan + Laayoune: 'Laâyoune' + Labrador Sea: Mer du Labrador + Latvia: Lettonie + 'Latvia (darker red, narrower white band)': 'Lettonie (rouge plus foncé, bande blanche plus étroite)' + Lebanon: Liban + Libya: Libye + Lisbon: Lisbonne + Lithuania: Lituanie + London: Londres + 'Luxembourg (lighter blue)': 'Luxembourg (bleu plus clair)' + Luxembourg City: Luxembourg + Macau: Macao + Madeira: 'Madère' + Malaysia: Malaisie + 'Mali (red and green flipped, slightly brighter green)': 'Mali (rouge et vert inversés, vert légèrement plus clair)' + Malta: Malte + Manila: Manille + Marshall Islands: 'Îles Marshall' + Mauritania: Mauritanie + Mauritius: Maurice + Mediterranean Sea: 'Mer Méditerranée' + Melanesia: 'Mélanésie' + Mexico: Mexique + Mexico City: Mexico + Micronesia: 'Micronésie' + Mogadishu: Mogadiscio + Moldova: Moldavie + 'Moldova (wider, coat of arms with eagle)': 'Moldavie (plus large, blason avec un aigle)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (plus étroit, rouge plus foncé), Pologne (rouge et blanc inversés, rouge plus foncé)' + Mongolia: Mongolie + Montenegro: 'Monténégro' + Morocco: Maroc + Moscow: Moscou + Muscat: Mascate + Myanmar: Birmanie + 'N''Djamena': 'N''Djaména' + Namibia: Namibie + 'Nauru (single star below yellow band)': 'Nauru (une seule étoile sous la bande jaune)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru n''a pas de capitale officielle ; le district de Yaren est la capitale de fait.' + Nepal: 'Népal' + Netherlands: Pays-Bas + 'Netherlands (darker blue)': 'Pays-Bas (bleu plus foncé)' + New Caledonia: 'Nouvelle-Calédonie' + New Zealand: 'Nouvelle-Zélande' + 'New Zealand (red stars, two fewer stars)': 'Nouvelle-Zélande (étoiles rouges, deux étoiles de moins)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (blason différent, bleu légèrement plus clair)' + Nicosia: Nicosie + North America: 'Amérique du Nord' + North Korea: 'Corée du Nord' + North Macedonia: 'Macédoine du Nord' + North Nicosia: Nicosie-Nord + North Sea: Mer du Nord + Northern Cyprus: Chypre du Nord + Northern Ireland: Irlande du Nord + Northern Mariana Islands: 'Îles Mariannes du Nord' + Norway: 'Norvège' + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norvège (fond rouge, croix bleue), Îles Féroé (fond blanc, croix rouge et bleue)' + Norwegian Sea: 'Mer de Norvège' + Not a sovereign country: Pas une nation souveraine + 'Oblast (administrative region) of the Russian Federation.': 'Oblast (région administrative) de la fédération de Russie.' + Oceania: 'Océanie' + Official capital was moved from Bujumbura to Gitega in 2019.: 'La capitale officielle a été déplacée de Bujumbura à Gitega en février 2019.' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: 'La capitale officielle a été déplacée de Malabo à Ciudad de la Paz en 2026.' + 'Officially Côte d''Ivoire.': '' + Officially Luxembourg.: '' + Overseas department of France.: 'Département français d''outre-mer.' + Overseas territory of France.: + notes.note.french-polynesia.fields.field.country-info: 'Collectivité française d''outre-mer.' + notes.note.new-caledonia.fields.field.country-info: 'Territoire français d''outre-mer.' + notes.note.saint-martin.fields.field.country-info: 'Collectivité française d''outre-mer.' + notes.note.wallis-and-futuna.fields.field.country-info: 'Collectivité française d''outre-mer.' + Overseas territory of the United Kingdom.: 'Territoire britannique d''outre-mer.' + Pacific Ocean: 'Océan Pacifique' + Palau: Palaos + 'Palestine (black/white/green, red arrow)': 'Palestine (noir/blanc/vert, flèche rouge)' + 'Palestine (no symbol)': 'Palestine (pas de symbole)' + Panama City: Panama + Papua New Guinea: 'Papouasie-Nouvelle-Guinée' + Partially recognised state claimed by China.: 'État partiellement reconnu revendiqué par la Chine.' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'État partiellement reconnu revendiqué par le Maroc.' + Partially recognised state claimed by Serbia.: 'État partiellement reconnu revendiqué par la Serbie.' + Persian Gulf: Golfe Persique + Peru: 'Pérou' + Philippine Sea: Mer des Philippines + Poland: Pologne + Polynesia: 'Polynésie' + Port Louis: Port-Louis + Port Vila: Port-Vila + Port of Spain: 'Port-d''Espagne' + Pretoria, Cape Town, Bloemfontein: Pretoria, Le Cap, Bloemfontein + Puerto Rico: Porto Rico + 'Puerto Rico (blue triangle, red stripes)': 'Porto Rico (triangle bleu, bandes rouges)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (séparation plus large et dentelée, pourpre/marron)' + Red Sea: Mer Rouge + Region of France.: 'Région française.' + Republic of the Congo: 'République du Congo' + 'Reykjavík': Reykjavik + Riyadh: Riyad + Romania: Roumanie + 'Romania (slightly lighter blue)': 'Roumanie (bleu légèrement plus clair)' + Russia: Russie + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Russie (pas de blason), Slovénie (plus large, blason plus petit)' + 'Réunion': 'La Réunion' + Sahrawi Arab Democratic Republic: 'République arabe sahraouie démocratique' + Saint Kitts and Nevis: 'Saint-Christophe-et-Niévès' + Saint Lucia: Sainte-Lucie + Saint Martin: 'Saint-Martin (Antilles françaises)' + Saint Vincent and the Grenadines: Saint-Vincent-et-les-Grenadines + San Marino: Saint-Marin + Santo Domingo: Saint-Domingue + Sardinia: Sardaigne + Saudi Arabia: Arabie saoudite + Scandinavia: Scandinavie + Scotland: 'Écosse' + Sea of Galilee: 'Lac de Tibériade' + Sea of Japan: Mer du Japon + Sea of Okhotsk: 'Mer d''Okhotsk' + Semi-autonomous region of Tanzania.: 'Région autonome de Tanzanie.' + Senegal: 'Sénégal' + 'Senegal (green/yellow/red, green star)': 'Sénégal (vert/jaune/rouge, étoile verte)' + Seoul: 'Séoul' + Serbia: Serbie + Sicily: Sicile + Singapore: Singapour + Sint Maarten: 'Saint-Martin (royaume des Pays-Bas)' + Slovakia: Slovaquie + 'Slovakia (narrower, bigger coat of arms)': 'Slovaquie (plus étroit, blason plus grand)' + 'Slovakia (with coat of arms)': 'Slovaquie (avec blason)' + Slovenia: 'Slovénie' + Solomon Islands: 'Îles Salomon' + Somalia: Somalie + South Africa: Afrique du Sud + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'L''Afrique du Sud n''a pas de capitale officielle. Les branches du gouvernement sont réparties sur trois villes : Pretoria (exécutive), Le Cap (législative) et Bloemfontein (judiciaire).' + South America: 'Amérique du Sud' + South China Sea: 'Mer de Chine méridionale' + South Korea: 'Corée du Sud' + South Ossetia: 'Ossétie du Sud-Alanie' + South Sudan: Soudan du Sud + South Tarawa: Tarawa-Sud + Southern Ocean: 'Océan Austral' + Sovereign country: Nation souveraine + Spain: Espagne + Special Administrative Region of China.: 'Région administrative spéciale de Chine.' + Sri Jayawardenepura Kotte: Sri Jayawardenapura Kotte + 'St. George''s': Saint-Georges + 'St. John''s': 'Saint John''s' + State of the United States.: 'État des États-Unis.' + State recognised only by Turkey and claimed by Cyprus.: 'État reconnu uniquement par la Turquie et revendiqué par Chypre.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Région d''Océanie comprenant des milliers de petites îles dans les parties centrale et orientale de l''océan Pacifique.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Région d''Océanie comprenant des milliers de petites îles dans l''ouest de l''océan Pacifique.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Région d''Océanie qui inclut le Vanuatu, les Îles Salomon, les Fidji et la Papouasie-Nouvelle-Guinée.' + Sudan: Soudan + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Soudan (rouge/blanc/noir, flèche verte), République arabe sahraouie démocratique (avec étoile et croissant)' + Sukhumi: Soukhoumi + Sweden: 'Suède' + Switzerland: Suisse + 'Switzerland has no official capital; Bern is the de facto capital.': 'La Suisse n''a pas de capitale officielle ; Berne est la capitale de fait.' + Syria: Syrie + 'São Tomé and Príncipe': 'Sao Tomé-et-Principe' + Taiwan: 'Taïwan' + Tajikistan: Tadjikistan + Tanzania: Tanzanie + Tashkent: Tachkent + Tasman Sea: Mer de Tasman + Tbilisi: Tbilissi + Tehran: 'Téhéran' + Thailand: 'Thaïlande' + The Bahamas: Bahamas + The Gambia: Gambie + Thimphu: Thimphou + Timor Sea: Mer de Timor + Timor-Leste: Timor oriental + Transnistria: Transnistrie + Trinidad and Tobago: 'Trinité-et-Tobago' + Tunisia: Tunisie + Turkey: Turquie + Turkmenistan: 'Turkménistan' + Turks and Caicos Islands: 'Îles Turques-et-Caïques' + Uganda: Ouganda + Ulaanbaatar: Oulan-Bator + Unincorporated internal area of Norway.: 'Territoire sous souveraineté norvégienne.' + Unincorporated territory of the United States.: 'Territoire non incorporé des États-Unis.' + United Arab Emirates: 'Émirats arabes unis' + United Kingdom: Royaume-Uni + United States Virgin Islands: 'Îles Vierges des États-Unis' + United States of America: 'États-Unis' + Uzbekistan: 'Ouzbékistan' + Valletta: La Valette + Vatican City: Vatican + Vienna: Vienne + Vietnam: 'Viêt Nam' + Wales: Pays de Galles + Wallis and Futuna: Wallis-et-Futuna + Warsaw: Varsovie + Washington, D.C.: Washington + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Bien qu''Amsterdam soit la capitale officielle, La Haye est le siège du gouvernement et du parlement.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Bien que Dodoma soit la capitale officielle, Dar es Salaam est le siège du gouvernement.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Bien que Laâyoune soit la capitale déclarée, Tifariti est le siège du gouvernement.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Bien que Mbabane soit la capitale exécutive officielle, Lobamba est la capitale traditionnelle, spirituelle et législative.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Bien que Porto-Novo soit la capitale officielle, Cotonou est le siège du gouvernement.' + While Sucre is the constitutional capital, La Paz is the seat of government.: 'Bien que Sucre soit la capitale constitutionnelle, La Paz est le siège du gouvernement.' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Bien que Yamoussoukro soit la capitale officielle, Abidjan est le siège du gouvernement.' + White Sea: Mer Blanche + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Région du monde englobant le continent australien et la plupart des îles de l''océan Pacifique.' + Yellow Sea: Mer Jaune + Yemen: 'Yémen' + Yerevan: Erevan + Zambia: Zambie + 'Åland Islands': 'Åland' + additions: + notes.note.celebes-sea.fields.field.country-info: Aussi connu comme la mer de Sulawesi. + variables: + label.capital: + Capital: Capitale + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Indice : {{Capital hint}}' + label.flag: + Flag: Drapeau + label.location: + Location: Emplacement + note-type.name: + Ultimate Geography: 'Ultimate Geography [FR]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Drapeau similaire à : {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': cYWtxb5I.n + '5JJ2]i)p4': 'uE!R:a{FS{' + '5S(f7%O-$': 'kbJZsU]Tm~' + '?n!)c/h=Q': 'QykP%i/vKj' + 'AES],s:8=': 'i_0H+>}|<.' + 'B:x' + 'SlOEXfq#|': 'FIQ,YV4(u' + 'W5?6yQZD2': 'N$AR]vQR?L' + '_]DH+g9!$': 'Jt*GgcbWd>&Z' + 'dY96`C-f(_': 'e?6[KX^Js+' + 'd``B*6:eCx': 'dC[x.]4VGu' + 'dbP/Cj}sB+': 'rVJ^|c*{=!' + 'de=9c:,@j9': 'ET[Oa;OLaK' + 'dlU7VG|3Gd': 'mC$X9cRfF4' + 'd}R:qHj1,7': 'IG)L^LIKi]' + 'e!KSebP}pt': 'Fck/PT^@wO' + 'e+/O]%*qfk': 'p);vRzMs+W' + 'e.t$Vi5,>f': 'DS~l]pq.y<' + 'e42BpV,P}C': 'bTrCSD^W4m' + 'e5P*D#I{m{': 'E2g&j~V_+i' + 'e9NjBH' + 'eHJ?)TAdTo': 'k]|`O*|sYC' + 'eN#^kPGv*L': 'c.@-5vbYSr' + 'eTQ&:;ekF' + 'epvt3>HH?U': 'HY(==0nL/5' + 'euW0jm=wfR': 'HQ$_4*oGf3' + 'e{e8vi@^PY': 'jFv`AX2j9Z' + 'f#4-L[mp5*': 'lW;xJ;HJv/' + 'f*2@qiHh_1': 'xs+r,A:Ybh' + 'f*LO~>!0PN': 'xj/*Nw.FyN' + 'f21=-A~W;~': 'n65[nUZiZc' + 'f>c7x2E]Q6': 'IkP885[1L6' + 'fRSM{K+;[w': 'ymuYBM5)ux' + 'fT!w8R#>dg': 'vVH0': 'l?;mPU|Vu$' + 'gRVZ]qJ#>O': QplodT,RlZ + 'gYIoR`|AxW': 'bP:bhgKryC' + 'gePC:Xt_nW': 'Ay%EM@L0b~' + 'gp[h@]y13l': 'cH+7jy4I,m' + 'gsS{jR,]|Q': 'oi2j6zc,T]' + 'gx:;za!?C9': 'uQ-6g&t&/t' + 'gz9${p}er*': 'dTNjd[5|Ge' + 'g~%pF`(x{u': 'uhYJm4N/!$' + 'h&`>n98~V1': 'P[t`X50n$K' + 'h9Jr(H[=1~': 'JDSADMst&0' + 'h?a*UKfT_M': 'm`:zwx?UCO' + 'hE5j*e,Q?[': 'oCa[}6b]{J' + 'hNE9}>;Q&-': 'i+%_VY]7bt' + 'hXNTVF<:SH': 'hE&?:>xB0v' + 'hZ/V/D&rO`': 'OXH#q-u9XF' + 'h_9=mkZXAH': 'Ft$h3i.|l.' + hoHandbYAy: 'fRSFWQK>W(' + 'hrI_uiW>E]': 'H38F4o,>An' + 'h}lnSN_f$_': 'z>%tQDyw7D' + 'h~5xz+=ke~': 'Ive-9<)?/' + 'i)]+09JUR&': 'G]3ZD2G$ww' + 'i2]v|D3@:j': 'w1FM?@j-tk' + 'iB-8Br~onq': 'dNo9L*#}F?' + 'iIS#Nw]tr]': 'x7GO_2-WY$' + 'iR;Um(L/~c=': 'CW+/?p:?BO' + 'i^J8&$(1]|': 'CY~?X/vp+j' + 'ia8as9sCY|': 'uO%6i;{0PZ' + 'igiK,ff[eO': 'p*1Gvs%}#V' + 'ijP>aJBT!9': 'N@X:d': 'Ddu%%IIGo*' + 'j)i)pB*HJ,': 'xwNS`|r~1x' + 'j2IQ=f=w3@': 'I_Q-5hX|5;' + j7eEZkzsCZ: 'IJG[L,X;A3' + 'j9:K,~DQ2v': 'HsOJP(RQoY' + 'jg|}': 'BxD0*Nx8HN' + 'jCd]`-=k,:': 'INr2(vFB[p' + 'jDA-V?/hVj': 'g[tAvcrR3^' + jI9P-f6r3M: 'Kc0|/i[KY@' + 'jKIDq|}J&c': 'd*RV!D6_/z' + 'jKO,9y0M;#': 'ny_8#ZQA/?' + 'jN^Vc%9OQ5': 'O#$FD?t_j)' + 'jN|NAUP*h}': vwLrgavleL + 'jTNoKo}Bu+': 'AtMq%)YA4~' + 'jX=#G8wu#(': 'dy>%OTh=dS' + 'jYz[ibrr9m': 'H5FPagvP>_' + 'jcu!gLw/&r': 'Qw(O&Fnkul' + 'jrM28*HbyG': 'Lng7&qhm~G' + 'j|z@PMgdx,': 'umk_Ql/M`8' + 'k$1_yaF?9#': 'r]by!Z{bH3' + 'k/u1:B%DJH': 'uNyx=*R.$~' + 'k0{O[6l]bH': 'p(t;=_z4`=' + 'k1]FH+s8j@': 'ns:xfr`B.`' + 'k9E.p^Ki!j#v+': 'DN*^.Q~HcI' + 'l3Ly8PBxt(': 'A_Vjy~^@?}' + 'lAxH.CfJB@': 'mSo]}$5c7a*' + 'm*#%sE.`.;': 'o.l+o.aIv#' + 'm*PYE=#wr^': 'z#:5{OdBN]' + 'm2F|cUkKhv': 'G~@ufSz/a;' + 'm5j,b/y_CA/' + 'mk1VltJl<]': 'y8u|!Om27i' + 'mk20`,N=`=': 'MV[vr/8+{>' + 'mkgcU7[bA%': ']#3u-c%5X' + 'mq)8`hi{hb': 'co2;shq(5S' + 'mtd;>7T|g>': 'f;],oZor+L' + 'mz[>>AxIr4': 'O0NI(S,j2o' + 'm{noqE29%I': 'y;mRF(PX>0' + 'm{oM,jr>C#': 'i3#3D+l`w$' + 'n&>e`2FO/S': 'A%emXRt~a]' + 'n+t>2`:;P/': 'F*@Hxe1UYX' + 'n:7&>YT5A=': 'FFMu>hPw;u' + 'nIfRn/yz': 'rJf@3vMORX' + 'oa87&PB&Bm': 'w?},I|jwcN' + 'onMKID2d;D': 'cHz7UCnS&i' + 'o~}KVA:VbI': 'u9/j`LQwrv' + 'p#R$-mG/L': 'xAxtN`17x^' + 'p.yv>BQ470': 'IThTxqnu;|' + 'p:SPZ:e/*[': umTKoLZSfc + 'pDDrFhd%u': 'Cu].QU#a&l' + 'pYRw[=6;JI': 'yF,n2/p[P>' + 'p]t8:Tka7q': 'w?1B]|VvAn' + pkj3RPBUrF: 'FHqW?gqMKx' + 'pwwZu{7f~(': 'E9Y{~}fJ|q' + 'pz(G*g': 'QGDo{u)NuE' + 'q2#Yv[O0:|': 'p`7>hfkNfW' + 'q7do-wLx%E': 'HF8bKY!Wia' + 'q8][)Ri}=q': iyxxWaZ8Wb + 'q8fF$4,c6_': 'wAQL>iJ/-]' + 'q:XyGf#>Wg': 'ja9thWdG~?' + 'q=w[e/' + 'qx%>fB$d+$': 'b-3J|=Nm69' + 'r(h9&#wc~B': 'L}~TYYHI[l' + 'r/B|ra%L,.': 'CuDj}XI),:' + 'r5C646H*+*': 'gPWIY|dqgC' + 'r6BV%|..*5': kfOOt3pPdZ + 'raI-$$|wT': 'CJb&~c&tZv' + 'r>g<|,FWi%': 'Gh36nI3h)B' + 'r?E$4f-d0/': 'L~g[-Y5a.V' + 'rPekx@n9:L': 'bb1Lx^zyVu' + 'rSneU!3}bn': 'KfB$YBpp#=' + 'r]*Ioa%$bK': 'ltf#^eLgH=' + 's#:aIK$9^x': 'Cdm[YJ2i`t' + 's4E?ZF[E<>': 'rnHajUXn]|' + 's9-GL*@AXD': 'o6puwHE{j4' + 's<_WLSB3I/': vaSXyU,d,a + 's>f40rn,O]': 'f4q6u$5n-L' + 'sB7rZQsRf[SfCo' + 'sRw-ik:I$v': 'u:[~?k7{9`' + 'sSW/?WMy17': q1DvZ4Qkh, + 'sT5=H|@KU|': 'K6g6$oK|jN' + sXzvT.g31p: '`ePFuGBDX' + 'sY>@9w~i^p': 'r{g3sXk]Cm' + 'skOM(?n$GG': svVKQZTWFU + spKoFRM4if: 'Osy[q7a%UV' + 'ssbyA9}]QP': 'dl_kl*?R;X' + 'stRa/S(0$F': 'q@wPIm/;#)' + 'sxOMs+bE8@': 'sgq|,rEUT>' + 'sx|l+io`B@': 'HPtF]bu>P': 'qJP9,I/X;?' + 't2&ri|[Un:': 'Ek~&z$tb-I' + t3aZXtpNyi: 'Mmy:MH3p-5' + 't50y/[AEhF': 'CaSU3w{HJO' + 'tMMtBUpb$&': 'JS/Mu$zWXF' + 'tTnCoO/*tk': ':?*,_d`iM' + 't_h*fbN#:(': 'd_{5}`KOeV' + 't`cagaWApo': 'm$gS*RQqQ1' + 'tcneuK7v%`': 'w{[+/hi?P7' + tfCY4j6KEZ: 'oFFV~|R]U_' + 'tj&bK}%P!F': 'QG_m`Y6OnU' + 'twrhzJ[k:Z': 'qMOzrk<+(:' + 't|h3|;E4g{': 'hX`.aRn$&{' + 'u-`cspdp)D': 'B|v&Z,u=T' + 'u4^lH.xem5': 'm]_=p1zwik' + 'u;|vR#mVMW': 'L{DDezoz^' + 'uH*Q(mcRt5': 'C}D/)CbE1E' + 'uRo^w#p5Be': 'nlkynXE' + 'uc61ToT%gt': 'xc)G{&Ta22' + 'ui3>b-?a@m': 'J#u?@$XnR$' + upiX0g1quR: 'cM;m6U=Dg5' + 'uv1fGXHN@)': L5qdsn2.Vt + 'u|I}oElaH)': 'LG(/,IHQi@' + 'vE[T$`n9T)': 'CmEf8|#B?{' + 'vKx<0Qa!aP': 'P!2gMb,SH>' + 'vNiD6A.v#s': 'bsVR4>6gl0' + 'va/ky=f5pK': 'dnCkHdCc(#' + 'vf)G?[_?Pf': 'Q=UkoGw%47' + 'vgdj#X?8dB': 'n,~jhT?[]W' + 'vpOTK6tMr@': 'P[nc3.TLD`' + 'vppEA|$@~g': 'zX(|Q|9+|q' + 'vrn%j%6{nu': 'n^,sUM_!k_' + 'vwc3}.=Z#&': 'PcUr6zy#ql' + 'wa>{k!2cXc': 'Pz{%n/nX,I' + x2d4n4ADcP: kfniDM68Fr + 'xb&P*pH(Q': 'B,.i%oyEc%' + 'xqrJ^d[.(O': Mie3UMRyst + 'yX:tmrd5;]': 'j7FyQaW,n{' + 'z:*d{z(~V2': 'H1L?;^]mY]' + 'zSc]-^U=0=': 'f^0aH]F%!V' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: fd72d808-58d4-43ea-97db-24196747f24c diff --git a/fixtures/ultimate-geography/overlays/languages/it.yaml b/fixtures/ultimate-geography/overlays/languages/it.yaml new file mode 100644 index 0000000..727e025 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/it.yaml @@ -0,0 +1,711 @@ +id: overlay.translation.it +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': Esiste una disputa sul nome del mare tra i paesi confinanti, con la Corea del Sud che in particolare sostiene il nome di Mare orientale. + Abkhazia: Abcasia + Addis Ababa: Addis Abeba + Adriatic Sea: Mare Adriatico + Aegean Sea: Mar Egeo + Akrotiri and Dhekelia: Akrotiri e Dhekelia + Algiers: Algeri + Also known as Burma.: Conosciuta anche come Myanmar. + Also known as Cabo Verde.: '' + Also known as Czechia.: Conosciuta anche come Cechia. + Also known as East Timor.: Conosciuto anche come Timor-Leste. + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: Conosciuto anche come Golfo della Thailandia. + Also known as the Sea of Cortez.: Conosciuto anche come Mare di Cortez. + 'Also spelled as Sana''a.': '' + American Samoa: Samoa Americane + 'Andorra (narrower, coat of arms with motto)': 'Andorra (più stretta, stemma con motto)' + Antarctica: Antartide + Antigua and Barbuda: Antigua e Barbuda + Arabian Sea: Mar Arabico + Aral Sea: 'Lago d''Aral' + Arctic Ocean: Mar Glaciale Artico + Ashgabat: 'Aşgabat' + Athens: Atene + Atlantic Ocean: Oceano Atlantico + 'Australia (white stars, two more stars)': 'Australia (stelle bianche, due stelle in più)' + 'Austria (brighter red, wider white band)': 'Austria (rosso più chiaro, banda bianca più larga)' + Autonomous community of Spain.: 'Comunità autonoma della Spagna.' + Autonomous province of South Korea.: Provincia autonoma della Corea del Sud. + Autonomous region of Finland.: Regione autonoma della Finlandia. + Autonomous region of Italy.: Regione italiana a statuto speciale. + Autonomous region of Papua New Guinea.: Regione autonoma della Papua Nuova Guinea. + Autonomous region of Portugal.: Regione autonoma del Portogallo. + Azerbaijan: Azerbaigian + Azores: Azzorre + Bahrain: Bahrein + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrein (più stretta, meno bordi seghettati, rossa)' + Balkan Peninsula: Penisola balcanica + Baltic Sea: Mar Baltico + Banda Sea: Mar di Banda + Barents Sea: Mare di Barents + Bay of Bengal: Golfo del Bengala + Bay of Biscay: Golfo di Biscaglia + Beijing: Pechino + Belarus: Bielorussia + Belgium: Belgio + Belgrade: Belgrado + Bering Strait: Stretto di Bering + Berlin: Berlino + Bern: Berna + Bishkek: 'Biškek' + Black Sea: Mar Nero + 'Bogotá': 'Bogotà' + 'Bolivia (coat of arms instead of star)': 'Bolivia (stemma al posto della stella)' + Bosnia and Herzegovina: Bosnia ed Erzegovina + 'Brasília': Brasilia + Brazil: Brasile + British Virgin Islands: Isole Vergini britanniche + Brussels: Bruxelles + Bucharest: Bucarest + Cairo: Il Cairo + Cambodia: Cambogia + Cameroon: Camerun + 'Cameroon (green/red/yellow, yellow star)': 'Camerun (verde/rosso/giallo, stella gialla)' + Canary Islands: Isole Canarie + Cape Verde: Capo Verde + Caribbean Sea: Mare Caraibico + Caspian Sea: Mar Caspio + Cayman Islands: Isole Cayman + Celebes Sea: Mare di Celebes + Celtic Sea: Mare Celtico + Central African Republic: Repubblica Centrafricana + Cetinje is an honorary capital.: 'Cettigne è una capitale onoraria.' + Chad: Ciad + 'Chad (slightly darker blue)': 'Ciad (blu leggermente più scuro)' + Chile: Cile + China: Cina + City of San Marino: 'Città di San Marino' + Claimed and controlled: Rivendicata e controllata + Claimed but not controlled: Rivendicata ma non controllata + 'Colombia (no coat of arms)': 'Colombia (senza stemma)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Colombo è spesso considerata la capitale ma Sri Jayawardenepura Kotte, un sobborgo di Colombo, è la capitale ufficiale e legislativa.' + Comoros: Comore + Constituent country in the Kingdom of Denmark.: + notes.note.faroe-islands.fields.field.country-info: 'Nazione costitutiva del Regno di Danimarca; conosciute anche come Isole Faroe.' + notes.note.greenland.fields.field.country-info: Nazione costitutiva del Regno di Danimarca. + Constituent country of the Kingdom of the Netherlands.: Nazione costitutiva del Regno dei Paesi Bassi. + Constituent country of the United Kingdom.: Nazione costitutiva del Regno Unito. + Cook Islands: Isole Cook + Copenhagen: Copenaghen + Coral Sea: Mar dei Coralli + Croatia: Croazia + Crown dependency of the United Kingdom.: Dipendenza della Corona britannica. + 'Cuba (red triangle, blue stripes)': 'Cuba (triangolo rosso, strisce blu)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (due stelle nell''angolo in alto a sinistra)' + Cyprus: Cipro + Czech Republic: Repubblica Ceca + Damascus: Damasco + Dead Sea: Mar Morto + Democratic Republic of the Congo: Repubblica Democratica del Congo + Denmark: Danimarca + Denmark Strait: Stretto di Danimarca + Dhaka: Dacca + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Contesa; rivendicata da Israele; Ramallah è il centro amministrativo.' + 'Disputed; claimed by Palestine.': 'Contesa; rivendicata dalla Palestina.' + Djibouti: Gibuti + Dominican Republic: Repubblica Dominicana + Dublin: Dublino + Dushanbe: 'Dušanbe' + East China Sea: Mar Cinese Orientale + East Siberian Sea: Mar della Siberia Orientale + 'Ecuador (with coat of arms)': 'Ecuador (con lo stemma)' + Edinburgh: Edimburgo + Egypt: Egitto + 'Egypt (emblem instead of text), Yemen (no text)': 'Egitto (emblema al posto del testo), Yemen (senza testo)' + 'Egypt (with emblem), Iraq (with text)': 'Egitto (con l''emblema), Iraq (con il testo)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (stemma diverso, blu leggermente più scuro)' + England: Inghilterra + English Channel: La Manica + Equatorial Guinea: Guinea Equatoriale + Eswatini: eSwatini + Ethiopia: Etiopia + Europe: Europa + European Union: Unione europea + Falkland Islands: Isole Falkland + Faroe Islands: 'Fær Øer' + Federated States of Micronesia: Stati Federati di Micronesia + Fiji: Figi + Finland: Finlandia + Formerly Zaire.: In precedenza Zaire. + Formerly known as Macedonia.: Precedentemente conosciuta come Macedonia. + France: Francia + French Guiana: Guyana francese + French Polynesia: Polinesia francese + Germany: Germania + 'Ghana (star instead of coat of arms)': 'Ghana (stella al posto dello stemma)' + Gibraltar: Gibilterra + Greece: Grecia + Greenland: Groenlandia + Guadeloupe: Guadalupa + Guatemala City: 'Città del Guatemala' + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (verde e rosso invertiti, verde leggermente più scuro)' + Gulf of Alaska: 'Golfo dell''Alaska' + Gulf of California: Golfo di California + Gulf of Carpentaria: Golfo di Carpentaria + Gulf of Guinea: Golfo di Guinea + Gulf of Mexico: Golfo del Messico + Gulf of Thailand: Golfo del Siam + Hargeisa: Hargheisa + Havana: 'L''Avana' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Regione storica e culturale dell''Europa settentrionale che comprende gli Stati di Danimarca, Norvegia e Svezia e talvolta anche Finlandia e Islanda.' + Hudson Bay: Baia di Hudson + Hungary: Ungheria + Iceland: Islanda + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Islanda (sfondo blu, croce rossa e bianca), Norvegia (sfondo rosso, croce blu e bianca)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Islanda (sfondo blu, croce rossa), Fær Øer (sfondo bianco, croce rossa e blu)' + Independent state claimed by Georgia.: Stato indipendente rivendicato dalla Georgia. + Independent state claimed by Moldova.: Stato indipendente rivendicato dalla Moldavia. + Independent state claimed by Somalia.: Stato indipendente rivendicato dalla Somalia. + Indian Ocean: Oceano Indiano + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesia (bianco e rosso invertiti, rosso più chiaro), Monaco (bianco e rosso invertiti, più stretta)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesia (più larga, rosso più chiaro), Polonia (rosso e bianco invertiti, più larga)' + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Iraq (testo al posto dell''emblema), Yemen (senza emblema)' + Ireland: Irlanda + 'Ireland (orange and green flipped, wider)': 'Irlanda (arancione e verde invertiti, più larga)' + Island of Indonesia.: 'Isola dell''Indonesia.' + Isle of Man: Isola di Man + Israel: Israele + Italy: Italia + Ivory Coast: 'Costa d''Avorio' + 'Ivory Coast (green and orange flipped, narrower)': 'Costa d''Avorio (verde e arancione invertiti, più stretta)' + Jakarta: Giacarta + Jamaica: Giamaica + Japan: Giappone + Java: Giava + Jerusalem: Gerusalemme + Jordan: Giordania + Juba: Giuba + Kaliningrad Oblast: 'Oblast'' di Kaliningrad' + Kathmandu: Katmandu + Kazakhstan: Kazakistan + Khartoum: Khartum + Known as Nur-Sultan between 2019 and 2022: Conosciuta come Nur-Sultan tra il 2019 e il 2022 + Known as Swaziland until 2018.: Conosciuto come Swaziland fino al 2018. + Kuwait City: Al Kuwait + Kyiv: Kiev + Kyrgyzstan: Kirghizistan + Laayoune: 'El Aaiún' + Labrador Sea: Mare del Labrador + Latvia: Lettonia + 'Latvia (darker red, narrower white band)': 'Lettonia (rosso più scuro, banda bianca più stretta)' + Lebanon: Libano + Libya: Libia + Lisbon: Lisbona + Lithuania: Lituania + Ljubljana: Lubiana + London: Londra + Luxembourg: Lussemburgo + 'Luxembourg (lighter blue)': 'Lussemburgo (blu più chiaro)' + Luxembourg City: Lussemburgo + Macau: Macao + Madeira: Madera + Maldives: Maldive + 'Mali (red and green flipped, slightly brighter green)': 'Mali (rosso e verde invertiti, verde leggermente più chiaro)' + Marshall Islands: Isole Marshall + Martinique: Martinica + Mediterranean Sea: Mar Mediterraneo + Mexico: Messico + Mexico City: 'Città del Messico' + Mogadishu: Mogadiscio + Moldova: Moldavia + 'Moldova (wider, coat of arms with eagle)': 'Moldavia (più larga, stemma con un''aquila)' + Monaco: + notes.note.monaco.fields.field.capital: Comune di Monaco + notes.note.monaco.fields.field.country: Principato di Monaco + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (più stretta, rosso più scuro), Polonia (rosso e bianco invertiti, rosso più scuro)' + Morocco: Marocco + Moscow: Mosca + Mozambique: Mozambico + Muscat: Mascate + Myanmar: Birmania + 'Nauru (single star below yellow band)': 'Nauru (una sola stella sotto la banda gialla)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru non ha una capitale ufficiale; il Distretto di Yaren è la capitale de facto.' + Netherlands: Paesi Bassi + 'Netherlands (darker blue)': 'Paesi Bassi (blu più scuro)' + New Caledonia: Nuova Caledonia + New Delhi: Nuova Delhi + New Zealand: Nuova Zelanda + 'New Zealand (red stars, two fewer stars)': 'Nuova Zelanda (stelle rosse, due stelle in meno)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (stemma diverso, blu leggermente più chiaro)' + North America: America del Nord + North Korea: Corea del Nord + North Macedonia: Macedonia del Nord + North Nicosia: Nicosia Nord + North Sea: Mare del Nord + Northern Cyprus: Cipro del Nord + Northern Ireland: Irlanda del Nord + Northern Mariana Islands: Isole Marianne Settentrionali + Norway: Norvegia + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norvegia (sfondo rosso, croce blu), Fær Øer (sfondo bianco, croce rossa e blu)' + Norwegian Sea: Mare di Norvegia + Not a sovereign country: 'Non è uno Stato sovrano' + 'Nouméa': Numea + 'Oblast (administrative region) of the Russian Federation.': 'Oblast'' (regione amministrativa) della Federazione Russa.' + Official capital was moved from Bujumbura to Gitega in 2019.: 'La capitale ufficiale è stata spostata da Bujumbura a Gitega nel 2019.' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: 'La capitale ufficiale è stata spostata da Malabo a Ciudad de la Paz nel 2026.' + 'Officially Côte d''Ivoire.': 'Ufficialmente Côte d''Ivoire.' + Officially Luxembourg.: '' + Overseas department of France.: 'Dipartimento d''oltremare della Francia.' + Overseas territory of France.: 'Territorio d''oltremare della Francia.' + Overseas territory of the United Kingdom.: 'Territorio d''oltremare del Regno Unito.' + Pacific Ocean: Oceano Pacifico + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (nero/bianco/verde, triangolo rosso)' + 'Palestine (no symbol)': 'Palestina (nessun simbolo)' + Panama City: Panama + Papua New Guinea: Papua Nuova Guinea + Paris: Parigi + Partially recognised state claimed by China.: Stato parzialmente riconosciuto rivendicato dalla Cina. + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'Stato parzialmente riconosciuto rivendicato dal Marocco; conosciuta anche come Sahara Occidentale.' + Partially recognised state claimed by Serbia.: Stato parzialmente riconosciuto rivendicato dalla Serbia. + Persian Gulf: Golfo Persico + Peru: 'Perù' + Philippine Sea: Mare delle Filippine + Philippines: Filippine + Poland: Polonia + Polynesia: Polinesia + Portugal: Portogallo + Prague: Praga + Pretoria, Cape Town, Bloemfontein: 'Pretoria, Città del Capo, Bloemfontein' + Puerto Rico: Porto Rico + 'Puerto Rico (blue triangle, red stripes)': 'Porto Rico (triangolo blu, strisce rosse)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (più larga, più bordi seghettati, rosso granata)' + Red Sea: Mar Rosso + Region of France.: Regione francese. + Republic of the Congo: Repubblica del Congo + Riyadh: Riad + 'Romania (slightly lighter blue)': 'Romania (blu leggermente più chiaro)' + Rome: Roma + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Russia (senza stemma), Slovenia (più larga, stemma più piccolo)' + Rwanda: Ruanda + 'Réunion': La Riunione + Sahrawi Arab Democratic Republic: Repubblica Democratica Araba dei Sahrawi + Saint Kitts and Nevis: Saint Kitts e Nevis + Saint Martin: Saint-Martin + Saint Vincent and the Grenadines: Saint Vincent e Grenadine + Sanaa: 'Sana''a' + Santiago: Santiago del Cile + Sardinia: Sardegna + Saudi Arabia: Arabia Saudita + Scotland: Scozia + Sea of Galilee: Lago di Tiberiade + Sea of Japan: Mar del Giappone + Sea of Okhotsk: Mare di Ochotsk + Semi-autonomous region of Tanzania.: Regione semi-autonoma della Tanzania. + 'Senegal (green/yellow/red, green star)': 'Senegal (verde/giallo/rosso, stella verde)' + Seoul: Seul + Sicily: Sicilia + Slovakia: Slovacchia + 'Slovakia (narrower, bigger coat of arms)': 'Slovacchia (più stretta, stemma più grande)' + 'Slovakia (with coat of arms)': 'Slovacchia (con lo stemma)' + Solomon Islands: Isole Salomone + South Africa: Sudafrica + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Il Sudafrica non ha una capitale ufficiale: i poteri dello Stato sono suddivisi fra tre città: Pretoria (esecutivo), Città del Capo (legislativo) e Bloemfontein (giudiziario).' + South America: America meridionale + South China Sea: Mar Cinese Meridionale + South Korea: Corea del Sud + South Ossetia: Ossezia del Sud + South Sudan: Sudan del Sud + South Tarawa: Tarawa Sud + Southern Ocean: Oceano Antartico + Sovereign country: 'È uno Stato sovrano' + Spain: Spagna + Special Administrative Region of China.: Regione amministrativa speciale della Cina. + Sri Jayawardenepura Kotte: Sri Jayawardenapura Kotte + 'St. George''s': 'Saint George''s' + 'St. John''s': 'Saint John''s' + State of the United States.: Stato degli Stati Uniti. + State recognised only by Turkey and claimed by Cyprus.: Stato riconosciuto solamente dalla Turchia e rivendicato da Cipro. + Stockholm: Stoccolma + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Macroregione dell''Oceania che comprende migliaia di piccole isole nell''Oceano Pacifico centrale e meridionale.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Macroregione dell''Oceania che comprende migliaia di piccole isole nell''Oceano Pacifico occidentale.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Macroregione dell''Oceania che comprende Vanuatu, le Isole Salomone, le Figi e la Papua Nuova Guinea.' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudan (rosso/bianco/nero, triangolo verde), Repubblica Democratica Araba dei Sahrawi (con stella e mezzaluna)' + Sweden: Svezia + Switzerland: Svizzera + 'Switzerland has no official capital; Bern is the de facto capital.': 'La Svizzera non ha una capitale ufficiale; Berna è la capitale de facto.' + Syria: Siria + 'São Tomé and Príncipe': 'São Tomé e Príncipe' + Tajikistan: Tagikistan + Tasman Sea: Mar di Tasman + Tehran: Teheran + Thailand: Thailandia + The Bahamas: Bahamas + The Gambia: Gambia + Timor Sea: Mar di Timor + Timor-Leste: Timor Est + Trinidad and Tobago: Trinidad e Tobago + Tunis: Tunisi + Turkey: Turchia + Turks and Caicos Islands: Turks e Caicos + Ukraine: Ucraina + Ulaanbaatar: Ulan Bator + Unincorporated internal area of Norway.: Area interna non incorporata della Norvegia. + Unincorporated territory of the United States.: Territorio non incorporato degli Stati Uniti. + United Arab Emirates: Emirati Arabi Uniti + United Kingdom: Regno Unito + United States Virgin Islands: Isole Vergini americane + United States of America: 'Stati Uniti d''America' + Valletta: La Valletta + Vatican City: 'Città del Vaticano' + Wales: Galles + Wallis and Futuna: Wallis e Futuna + Warsaw: Varsavia + Washington, D.C.: Washington + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Nonostante Amsterdam sia la capitale ufficiale, L''Aia è la sede del governo e del parlamento.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Nonostante Dodoma sia la capitale ufficiale, Dar es Salaam è la sede del governo.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Nonostante Laâyoune, conosciuta anche come El Aaiún, sia la capitale proclamata, Tifariti è la sede del governo.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Nonostante Mbabane sia la capitale ufficiale ed esecutiva, Lobamba è la capitale tradizionale, spirituale e legislativa.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Nonostante Porto-Novo sia la capitale ufficiale, Cotonou è la sede del governo.' + While Sucre is the constitutional capital, La Paz is the seat of government.: 'Nonostante Sucre sia la capitale costituzionale, La Paz è la sede del governo.' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Nonostante Yamoussoukro sia la capitale ufficiale, Abidjan è la sede del governo.' + White Sea: Mar Bianco + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Regione del mondo che comprende il continente Australiano e la maggior parte delle isole dell''Oceano Pacifico.' + Yellow Sea: Mar Giallo + Yerevan: Erevan + Zagreb: Zagabria + 'Åland Islands': 'Isole Åland' + additions: + notes.note.arctic-ocean.fields.field.country-info: Conosciuto a livello internazionale come Oceano Artico. + variables: + label.capital: + Capital: Capitale + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Suggerimento: {{Capital hint}}' + label.flag: + Flag: Bandiera + label.location: + Location: Posizione + note-type.name: + Ultimate Geography: 'Ultimate Geography [IT]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Bandiera simile a: {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'pizs$6f^OI' + '5JJ2]i)p4': 's0^' + 'BJ,,^wMC%N': jEL_Gc0QOM + Bszsaz0Iuo: 'D.m%ueXh:9' + 'Cf2@;pN$}' + 'EWXNc:d^*' + 'H*c[ML+`s=': 'GPy,`vg]}<' + 'He!e=h?_2V': '}3cleuN,!' + 'J%,fuysl*&': 'p)2VM{]#8o' + 'JOT:U4ayh@': 'w3~iPHCWi@' + 'K*b}ui' + 'b^Try&me1A': 'D6%hiXB5%&' + 'b`5Q_Z=HQT': 'gxGHx4#O&s' + 'bd~Ght5tLR': sJ9gIjH3hX + 'bk2ByZIeU+': 'iNgR%2Eok/' + 'bnEK#)[;qT': 'q|%vIOl1;T' + 'c(,2{yXI#E': 'd+@.MPK' + 'cAlyc~G.y%': 'lMSAB/cOm*' + 'cEO^y)Md[G': 'h-`Y2EhgFQ' + 'cL?8!xP#Wj': 'z1POD,VID}' + 'cPO|Cru8n<': 'eqF,s$' + 'c^,~EC6Pb2': 'kecv+!in:(' + 'ck!6;xK$/^': 'lP>2[v``&g' + 'cv+Nq9:a>h!/' + 'e.t$Vi5,>f': 'KC^nKc-2%j' + 'e42BpV,P}C': 'KiGYV{Y2Rj' + 'e5P*D#I{m{': 'h(R&U@*wb}' + 'emB6@' + 'e?Xq9gy=@[': 'KA}n?y(.t6' + 'e?gIaPc$Ox': 'f+0nvKD]uC' + 'e?h=Xhs+K&': 'hh^x7B;l`6' + 'eALZe^HCHq': 'me^]a_a06D' + 'eHJ?)TAdTo': '{]0Gn}=?<' + 'eN#^kPGv*L': 'z3@H1&nZSg' + 'eTHH?U': 's#' + 'euW0jm=wfR': 'BB/C2B[$C5' + 'e{e8vi@^PY': 'lt:FGWJBSN' + 'f#4-L[mp5*': 'QoEJ+5vO&k' + 'f*2@qiHh_1': 'A6$z`mA%oA' + 'f*LO~>!0PN': 'DCq[ofnQ_C' + 'f21=-A~W;~': 'Q+px.mLe^.' + 'f>c7x2E]Q6': 'KW@PCgn4^d' + 'fRSM{K+;[w': 'H2dg': 'FdxY7GZlp$' + 'fV`&p81%d(': bLz53PbV0b + fkq7lhLkdH: 'KP*o@q}lR9' + 'fxAN),FAc$': 'cBH?yB-k-M' + 'g-]bb&[.E!': 'z)=(lc>D]!' + 'g9v~y/;=z(': 'gj:]O6VKC/' + 'g<[f89~U)A': 'GzIN2wS!UM' + 'gBCR?!O*;E': 'KBoJH0': 'NBAc+DUlR.' + 'gRVZ]qJ#>O': 'dGv_,!sUnF' + 'gYIoR`|AxW': 'B%fQaJUVdo' + 'gePC:Xt_nW': 'HuK?9aC_zy' + 'gp[h@]y13l': 'pQkua|dss!' + 'gsS{jR,]|Q': 'pU3#tTpq!l' + 'gx:;za!?C9': 'E[oI~&d)7T' + 'gz9${p}er*': 'MIvjS4ep`{' + 'g~%pF`(x{u': 'Ou<^C1eL+h' + 'h&`>n98~V1': 'q8/fZLX~{C' + 'h9Jr(H[=1~': 'O+JPzn{42?' + 'h?a*UKfT_M': 'fx]Qj`$n!<' + 'hE5j*e,Q?[': 'k^{fiJOiQK' + 'hNE9}>;Q&-': 'z+j0z0e&+>' + 'hXNTVF<:SH': mFunHjqPtu + 'hZ/V/D&rO`': 'L[wNzP!?1/' + 'h_9=mkZXAH': 'e,SBM4?I@u' + hoHandbYAy: 'M;TS86pSVW' + 'hrI_uiW>E]': 'dT$I;zW!R9' + 'h}lnSN_f$_': '/p,aOe!~L!' + 'iIS#Nw]tr]': 'x?#<3m(L/~c=': 'KDTitL&0A8' + 'i^J8&$(1]|': 'cXBHyX>[V$' + 'ia8as9sCY|': 'eA>XgY>alM' + 'igiK,ff[eO': 'eOOtP^l,3r' + 'ijP>aJBT!9': 'ySS-%<:X@%' + 'ilGtw#=asM': 's/XSB=3<_M' + 'j%*8gN)q>d': 'L0|q,v?fBC' + 'j)i)pB*HJ,': 'oi:nl?uhDf' + 'j2IQ=f=w3@': 'GD53>x$4(V' + j7eEZkzsCZ: 'i~F@r{J+u8' + 'j9:K,~DQ2v': 'q7>B#{/;(S' + 'jg|}': 'e9~liiy5)l' + 'jCd]`-=k,:': 'B8.@ls$HTO' + 'jDA-V?/hVj': 'OUlTl+:*GN' + jI9P-f6r3M: 'A}ER~W:Cla' + 'jKIDq|}J&c': 'zO#CeF9xFv' + 'jKO,9y0M;#': 'e[tzT?l#H0' + 'jN^Vc%9OQ5': 'OTBx@>`j]`' + 'jN|NAUP*h}': 't!i[6HG>P?' + 'jTNoKo}Bu+': 'Ay9!s9h9E(' + 'jX=#G8wu#(': 'xt)sOuFtn:' + 'jYz[ibrr9m': 'M`1yM!Pg1n' + 'jcu!gLw/&r': 'hf`]@|j!$I' + 'jrM28*HbyG': 'qVkX-]bkG' + 'j|z@PMgdx,': yt8CNByOZs + 'k$1_yaF?9#': 'z&vP452~E' + 'k/u1:B%DJH': '.nc9{|Rx|' + 'k0{O[6l]bH': 'ooH#i2V^L_' + 'k1]FH+s8j@': 'Q=Ng#q?dN`' + 'k9E.p^ZT>/O' + 'kQ:NIhJy~E': 'ICOm,PnZ)=' + 'kS)hDm%w}R': 'tZ&S#fk9|7' + 'kX]hN=_.c-': 'uxB==Gt:p4' + 'kZ52zV#[Iv': 'oRR~+%e`Hw' + 'k[)#L4.[v3': 'd?;n+GrH-G' + 'khB~(m^@Z5': 'FQ6}Cm%3$7' + 'kkbYQXw5b;': 'Q>X$_K%>.W' + 'koI(}OyJ:@': 'R2-j`41bE' + 'kopwzzB``A': 'vR)/{dF_/0' + 'k}4/oj#$~s': 'yd77K)R=;f' + 'k}A9]O:0xw': 'E;ZJ)^[Z5>W' + 'l*j*EE:!Y?': 'E#Q1{m2H6g' + 'l+>Ki!j#v+': rox57e9L,5 + 'l3Ly8PBxt(': 'wJqs(`?jY+' + 'lAxH.CfJB@': 'Qn=_I47LA%' + 'lH^2D]w;[H': 'Jt!e.x.0Gt' + 'lU@C3v4kdjJx,' + 'leh^fmrt*#': Eq6mfOsni + 'lhWI;$JC9(': 'kr~RoN}rC.' + libliptkB4: 'kHP%(:~L$T8P<9' + 'lpbD0St&OU': 'dd8@*P;!_=' + 'lq`T^ZBF2Y': 'oLrC[o>Ja;' + 'l}[BG9.nQS': 'nt{?s*~;(+' + 'm)WUtJS`H&': 'MD`pyb>HGd' + 'm*#%sE.`.;': 'IN2Vuk#F;>' + 'm*PYE=#wr^': 'dloL=[(^e?' + 'm2F|cUkKhv': 't[lzZGY3.3' + 'm5j,b/yO|' + mbGXc0y3Pe: 'ExARs/N{}X' + 'mbz=7n[0v9': 'l9wW=#1zk%' + 'mcc&0Kq^xE': 'w}zN{&/Ro;' + 'mi+kf0LTW.': 'K|jjS6T)93' + 'mk1VltJl<]': 'GQ/vj@?u/X' + 'mk20`,N=`=': 'KqNpX' + 'mq)8`hi{hb': 'PBogQaAw&>' + 'mtd;>7T|g>': 'QUFXkU|R}l' + 'mz[>>AxIr4': 'f/j>XFf+#$' + 'm{noqE29%I': 'Ji5yp3yF&;' + 'm{oM,jr>C#': 'H84|~MXE6}' + 'n&>e`2FO/S': 's$MWIX]7N%' + 'n+t>2`:;P/': 'OhS%m6wjrH' + 'n:7&>YT5A=': 'iUutTx[~f0' + 'nIfRn' + 'niJUFZF$Y:': 'i<%1$y^@*#' + 'nsS59c`VL,': 'j4J&;WUUw1.>g$w]' + 'o3VS!KWb/@': 'cH~ahGG4r(' + 'o9{x/yz': 'I^>KA)+sWg' + 'oa87&PB&Bm': 'F/Isk.P.[(' + 'onMKID2d;D': 'O_?zkb_dc:' + 'o~}KVA:VbI': 'Q^*!}l47T' + 'p#R$-mG/L': 'A[UOwi4j~%' + 'p.yv>BQ470': 'LkXz{lFlv{' + 'p:SPZ:e/*[': 'KTW_5-~ZIq' + 'pDDrFhd%u': 'r?X!/#D/zg' + 'pYRw[=6;JI': 'm{,@;#IB7t' + 'p]t8:Tka7q': 'GV6Rn!dmr!' + pkj3RPBUrF: 'rQN0=^O+GS' + 'pwwZu{7f~(': 'bA(w~2O9Xg' + 'pz(G*g': 'y7bRprmOE`' + 'q2#Yv[O0:|': 'dx;Wg': 'PO6ZA^*;M]' + 'q=w[r$se/Z0G' + 'qFuGdDx^Yv': 'f=`AJn~T:/' + 'qVi;t%/`F|': 'ds^tTE_]dX' + 'q[S4`,F0D=': 'LIu0Jpt[SQ' + 'q[}7hk(+=e': 'f,}O1xREjN' + 'qiv/4tOlf=': 'n<[|af68t>' + qvITjNxkLN: 'wJ}.y$;-Li' + 'qx%>fB$d+$': 'jb8Eh65j#1' + 'r(h9&#wc~B': 'g}SfA07quk' + 'r/B|ra%L,.': qhzCnw7SeK + 'r5C646H*+*': 'd=ke{fof`2' + 'r6BV%|..*5': 'k`%nD6rx=:XNFmp' + 'r>aI-$$|wT': 'r6rNn|YxO@' + 'r>g<|,FWi%': 'ra9YU$GwJi' + 'r?E$4f-d0/': d2lqq9EhWX + 'rPekx@n9:L': 'hU(8=s;Cch' + 'rSneU!3}bn': 'L5fYa(HY?&' + 'r]*Ioa%$bK': 'Q%7E!2lJ`R' + 's#:aIK$9^x': 'oI!OKr8': 'I///E!tK}`' + 's9-GL*@AXD': 'PMI(q1BU:[' + 's<_WLSB3I/': 'vpptc>@ky4' + 's>f40rn,O]': 'HN`?Pad@9w~i^p': 'L_j4?3vmv+' + 'skOM(?n$GG': 'o04NUTyQV$' + spKoFRM4if: 'qs*eI8U&Zf' + 'ssbyA9}]QP': 'Cd~]=K9h[S' + 'stRa/S(0$F': JHZDiQMPfb + 'sxOMs+bE8@': 'OKxBG0?#dm' + 'sx|l+io`B@': 'xF]bu>P': 'L^_?P(1YL[' + 't2&ri|[Un:': 'tfPkw@0;~]' + t3aZXtpNyi: 'ivB,61}=>D' + 't50y/[AEhF': 'KS=C;d>$qo' + 'tMMtBUpb$&': hqjCaPaeEZ + 'tTnCoO/*tk': 'IY+SvWsts^' + 't_h*fbN#:(': 'h!A$6,]|rX' + 't`cagaWApo': 'qsp+/:!:!W' + 'tcneuK7v%`': 'C4}ih73A=d' + tfCY4j6KEZ: 'u+Y]9-g]K~' + 'tj&bK}%P!F': 't6b-?a@m': 'kLhI3EPC)M' + upiX0g1quR: 'vPw=^$(CkP' + 'uv1fGXHN@)': 'kRnWGJyI$s' + 'u|I}oElaH)': 'jS!kn/5S}x' + 'vE[T$`n9T)': 'J$z:rG4h]?' + 'vKx<0Qa!aP': 'p:m=&i}/($' + 'vNiD6A.v#s': 'uD_r`#FV:D' + 'va/ky=f5pK': 'v=h^~bGQ1l' + 'vf)G?[_?Pf': 'Bety@6RI`M' + 'vgdj#X?8dB': 'C<4zOv=yp)' + 'vpOTK6tMr@': 'rq12?AX9S2' + 'vppEA|$@~g': 'o$aLe8WbC%' + 'vrn%j%6{nu': 'B(z0k%05eT' + 'vwc3}.=Z#&': 'Kd6N5U=|)z' + 'wa>{k!2cXc': 'jNpSzO@(B8' + x2d4n4ADcP: 'Cm$CS&[*Rg' + 'xb&P*pH(Q': 'Fs[o{go+4R' + 'xqrJ^d[.(O': 'cX>BQ?Y;e_' + 'yX:tmrd5;]': 'oylp@hqALI' + 'z:*d{z(~V2': 'gch|[3#I:X' + 'zSc]-^U=0=': 'Hq>2Y[(Y]*' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: cd65a274-7341-4e72-8de5-5d3d83ea4537 diff --git a/fixtures/ultimate-geography/overlays/languages/nb.yaml b/fixtures/ultimate-geography/overlays/languages/nb.yaml new file mode 100644 index 0000000..1b6c147 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/nb.yaml @@ -0,0 +1,705 @@ +id: overlay.translation.nb +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': '' + Abkhazia: Abkhasia + Addis Ababa: Addis Abeba + Adriatic Sea: Adriaterhavet + Aegean Sea: Egeerhavet + Africa: Afrika + Akrotiri and Dhekelia: Akrotiri og Dekelia + Algeria: Algerie + Algiers: Alger + Also known as Burma.: 'Også kalt Burma.' + Also known as Cabo Verde.: Offisielt Republikken Cabo Verde. + Also known as Czechia.: '' + Also known as East Timor.: '' + Also known as Kiev.: 'Også kjent som Kyiv.' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: 'Også kalt Siambukta.' + Also known as the Sea of Cortez.: '' + 'Also spelled as Sana''a.': '' + American Samoa: Amerikansk Samoa + 'Andorra (narrower, coat of arms with motto)': 'Andorra (smalere, våpen med valgspråk)' + Antarctica: Antarktis + Antigua and Barbuda: Antigua og Barbuda + Arabian Sea: Det arabiske hav + Aral Sea: 'Aralsjøen' + Arctic Ocean: Nordishavet + Ashgabat: Asjkhabad + Athens: Aten + Atlantic Ocean: Atlanterhavet + 'Australia (white stars, two more stars)': 'Australia (hvite stjerner og to ekstra stjerner)' + Austria: 'Østerrike' + 'Austria (brighter red, wider white band)': 'Østerrike (lysere rød, brede hvit stripe)' + Autonomous community of Spain.: 'Selvstyrt område i Spania.' + Autonomous province of South Korea.: 'Selvstyrt provins i Sør-Korea.' + Autonomous region of Finland.: 'Selvstyrt og delimilitarisert øygruppe og landskap i Finland.' + Autonomous region of Italy.: Delvis selvstyrt region i Italia. + Autonomous region of Papua New Guinea.: Selvstyrt region i Papua Ny-Guinea. + Autonomous region of Portugal.: Selvstyrt region i Portugal. + Azerbaijan: Aserbajdsjan + Azores: Asorene + Baghdad: Bagdad + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrain (smalere, færre tagger, rødt)' + Balkan Peninsula: Balkan + Baltic Sea: 'Østersjøen' + Banda Sea: 'Bandasjøen' + Barents Sea: Barentshavet + Bay of Bengal: Bengalbukta + Bay of Biscay: Biscayabukta + Belgium: Belgia + Belgrade: Beograd + Bering Strait: Beringstredet + Bishkek: Bisjkek + Black Sea: Svartehavet + 'Bolivia (coat of arms instead of star)': 'Bolivia (våpenskjold i stedet for stjerne)' + Bosnia and Herzegovina: Bosnia-Hercegovina + 'Brasília': Brasilia + Brazil: Brasil + British Virgin Islands: 'De britiske Jomfruøyer' + Brussels: Brussel + Bucharest: Bucuresti + Cairo: Kairo + Cambodia: Kambodsja + Cameroon: Kamerun + 'Cameroon (green/red/yellow, yellow star)': 'Kamerun (grønn/rød/gul, gul stjerne)' + Canary Islands: 'Kanariøyene' + Cape Verde: Kapp Verde + Caribbean Sea: Det karibiske hav + Caspian Sea: Det kaspiske hav + Cayman Islands: 'Caymanøyene' + Celebes Sea: 'Celebessjøen' + Celtic Sea: Det keltiske hav + Central African Republic: Den sentralafrikanske republikk + Cetinje is an honorary capital.: Administrativ hovedstad. Cetinje er historisk hovedstad og presidentens tilholdssted. + Chad: Tsjad + 'Chad (slightly darker blue)': 'Tsjad (noe mørkere blå)' + China: Kina + City of San Marino: San Marino by + Claimed and controlled: Krevd og kontrollert + Claimed but not controlled: Krevd, men ikke kontrollert + 'Colombia (no coat of arms)': 'Colombia (uten våpenskjold)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Regjeringen har sete i Colombo. Sri Jayawardenepura Kotte (kalt Kotte) er en forstad til Colombo der nasjonalforsamlingen har sete. Kotte er altså lovgivende hovedstad.' + Comoros: Komorene + Constituent country in the Kingdom of Denmark.: 'Land som utgjør en del av kongeriket Danmark.' + Constituent country of the Kingdom of the Netherlands.: + notes.note.aruba.fields.field.country-info: '«Land» som sammen med Nederland, Curaçao og Sint Maarten utgjør føderasjonen Kongeriket Nederlandene.' + notes.note.cura-ao.fields.field.country-info: '«Land» som sammen med Nederland, Aruba og Sint Maarten utgjør føderasjonen Kongeriket Nederlandene.' + notes.note.sint-maarten.fields.field.country-info: '«Land» som sammen med Nederland, Curaçao og Aruba utgjør føderasjonen Kongeriket Nederlandene.' + Constituent country of the United Kingdom.: 'Land som utgjør en del av Storbritannia.' + Cook Islands: 'Cookøyene' + Copenhagen: 'København' + Coral Sea: Korallhavet + Corsica: Korsika + Croatia: Kroatia + Crown dependency of the United Kingdom.: + notes.note.guernsey.fields.field.country-info: 'Kanaløy underlagt britisk kronbesittelse.' + notes.note.isle-of-man.fields.field.country-info: 'Også kalt Isle of Man. Kanaløy underlagt britisk kronbesittelse. Hverken del av Storbritannia eller EU.' + notes.note.jersey.fields.field.country-info: 'Kanaløy underlagt britisk kronbesittelse.' + 'Cuba (red triangle, blue stripes)': 'Cuba (rød trekant, blå striper)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (to stjerner i øverste venstre hjørne)' + Cyprus: Kypros + Czech Republic: Tsjekkia + Damascus: Damaskus + Dead Sea: 'Dødehavet' + Democratic Republic of the Congo: Kongo + Denmark: Danmark + Denmark Strait: Danmarkstredet + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Omstridt; hevdet av Israel; Ramallah er administrasjonssenteret.' + 'Disputed; claimed by Palestine.': 'Omstridt; hevdet av Palestina.' + Dominican Republic: Den dominikanske republikk + Dushanbe: Dusjanbe + East China Sea: 'Øst-Kina-havet' + East Siberian Sea: 'Øst-Sibirhavet' + 'Ecuador (with coat of arms)': 'Ecuador (med våpenskjold)' + 'Egypt (emblem instead of text), Yemen (no text)': 'Jemen (blank midstripe), Egypt (emblem)' + 'Egypt (with emblem), Iraq (with text)': 'Egypt (emblem), Irak (tekst)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (annerledes våpenskjold, noe mørkere blå)' + English Channel: Den engelske kanal + Equatorial Guinea: Ekvatorial-Guinea + Estonia: Estland + Ethiopia: Etiopia + Europe: Europa + European Union: Den europeiske union + Falkland Islands: 'Falklandsøyene' + Faroe Islands: 'Færøyene' + Federated States of Micronesia: 'Mikronesiaføderasjonen' + Formerly Zaire.: 'Offisielt Den demokratiske republikken Kongo. Tidligere Zaïre.' + Formerly known as Macedonia.: Tidligere Makedonia. + France: Frankrike + French Guiana: Fransk Guyana + French Polynesia: Fransk Polynesia + Germany: Tyskland + 'Ghana (star instead of coat of arms)': 'Ghana (stjerne i stedet for våpenskjold)' + Greece: Hellas + Greenland: 'Grønland' + Guatemala City: Guatemala by + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (byttet plass på grønt og rødt, noe mørkere grønnfarge)' + Gulf of Alaska: Alaskabukta + Gulf of California: Californiabukta + Gulf of Carpentaria: Carpentariabukta + Gulf of Guinea: Guineabukta + Gulf of Mexico: Mexicogolfen + Gulf of Thailand: Thailandbukta + Havana: Havanna + Helsinki: Helsingfors + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: Skandinavia betegner Sverige, Danmark og Norge. Norden inkluderer nabolandene Finland og Island. + Hong Kong: Hongkong + Hudson Bay: Hudsonbukta + Hungary: Ungarn + Iceland: Island + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Island (blå bakgrunn, rødt og hvitt kors), Norge (rød bakgrunn, blått og hvitt kors)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Island (blå bakgrunn, rødt kors), Færøyene (hvit bakgrunn, rødt og blått kors)' + Independent state claimed by Georgia.: Utbryterstat i Georgia. + Independent state claimed by Moldova.: 'Region i Moldova som er en selverklært, men ikke internasjonalt anerkjent, stat.' + Independent state claimed by Somalia.: Utbryterrepublikk i Somalia. Fungerer som selvstendig stat, men ikke anerkjent av andre. + Indian Ocean: Det indiske hav + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesia (rødt og hvitt byttet, lysere rød), Monaco (rødt og hvitt byttet, smalere)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesia (bredere, lysere rød), Polen (rødt og hvitt byttet, bredere)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Jemen (blank midstripe), Irak (tekst)' + Ireland: Irland + 'Ireland (orange and green flipped, wider)': 'Ireland (byttet plass på oransje og grønt, bredere)' + Island of Indonesia.: 'Øy i Indonesia.' + Isle of Man: Man + Italy: Italia + Ivory Coast: Elfenbenskysten + 'Ivory Coast (green and orange flipped, narrower)': 'Elfenbenskysten (grønn og oransj har byttet plass, smalere)' + Kaliningrad Oblast: Kaliningrad oblast + Kathmandu: Katmandu + Kazakhstan: Kasakhstan + Known as Nur-Sultan between 2019 and 2022: Kjent som Nursultan mellom 2019 og 2022 + Known as Swaziland until 2018.: 'Før 2018 kjent som Swaziland.' + Kuwait City: Kuwait by + Kyiv: Kiev + Kyrgyzstan: Kirgisistan + Laayoune: 'El Aaiún' + Labrador Sea: 'Labradorsjøen' + 'Latvia (darker red, narrower white band)': 'Latvia (mørkere rød, smalere hvitt bånd)' + Lebanon: Libanon + Lisbon: Lisboa + Lithuania: Litauen + 'Luxembourg (lighter blue)': 'Luxembourg (lysere blå)' + Luxembourg City: Luxembourg + Macau: Macao + Madagascar: Madagaskar + Maldives: Maldivene + 'Mali (red and green flipped, slightly brighter green)': 'Mali (byttet plass på rødt og grønt; noe lysere grønn)' + Marshall Islands: 'Marshalløyene' + Mediterranean Sea: Middelhavet + Mexico City: Mexico by + Micronesia: Mikronesia + 'Moldova (wider, coat of arms with eagle)': 'Moldova (bredere, våpenskjold med ørn)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (smalere, mørkere rød), Polen (rødt og hvitt byttet, mørkere rød)' + Morocco: Marokko + Moscow: Moskva + Mozambique: Mosambik + 'N''Djamena': Ndjamena + 'Nauru (single star below yellow band)': 'Nauru (en enkelt stjerne under et gult bånd)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru har ingen offisiell hovedstad; Yaren er regjeringssete.' + Netherlands: Nederland + 'Netherlands (darker blue)': 'Nederland (mørkere blå)' + New Caledonia: Ny-Caledonia + 'New Zealand (red stars, two fewer stars)': 'New Zealand (røde stjerner)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (annerledes våpenskjold, noe lysere blå)' + Nicosia: Nikosia + North America: Nord-Amerika + North Korea: Nord-Korea + North Macedonia: Nord-Makedonia + North Nicosia: Nord-Nikosia + North Sea: 'Nordsjøen' + Northern Cyprus: Nord-Kypros + Northern Ireland: Nord-Irland + Northern Mariana Islands: Nord-Marianene + Norway: Norge + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norge (rød bakgrunn, blått kors), Færøyene (hvit bakgrunn, rødt og blått kors)' + Norwegian Sea: Norskehavet + Not a sovereign country: Ikke selvstendig land + 'Nukuʻalofa': Nukualofa + 'Oblast (administrative region) of the Russian Federation.': 'Oblast (administrativt distrikt) i Russland.' + Oceania: Oseania + Official capital was moved from Bujumbura to Gitega in 2019.: 'Hovedstad flyttet i februar 2019. Bujumbura er fortsatt regjeringssete og økonomisk hovedstad.' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: Hovedstad flyttet til Ciudad de la Paz fra Malabo i 2026. + 'Officially Côte d''Ivoire.': '' + Officially Luxembourg.: '' + Overseas department of France.: + notes.note.french-guiana.fields.field.country-info: 'Fransk oversjøisk departement og del av EU.' + notes.note.guadeloupe.fields.field.country-info: 'Fransk oversjøisk departement.' + notes.note.martinique.fields.field.country-info: 'Fransk oversjøisk departement.' + notes.note.mayotte.fields.field.country-info: 'Fransk oversjøisk territorium.' + notes.note.r-union.fields.field.country-info: 'Fransk oversjøisk departement.' + Overseas territory of France.: + notes.note.french-polynesia.fields.field.country-info: 'Fransk oversjøisk land.' + notes.note.new-caledonia.fields.field.country-info: 'Fransk oversjøisk territorium i det sørvestlige Stillehavet.' + notes.note.saint-martin.fields.field.country-info: 'Fransk oversjøisk land.' + notes.note.wallis-and-futuna.fields.field.country-info: 'Fransk oversjøisk land.' + Overseas territory of the United Kingdom.: + notes.note.akrotiri-and-dhekelia.fields.field.country-info: 'Britisk oversjøisk territorium.' + notes.note.anguilla.fields.field.country-info: 'Selvstyrt britisk oversjøisk territorium i Karibia.' + notes.note.bermuda.fields.field.country-info: 'Selvstyrt britisk oversjøisk territorium.' + notes.note.british-virgin-islands.fields.field.country-info: 'Selvstyrt britisk oversjøisk territorium i Karibia.' + notes.note.cayman-islands.fields.field.country-info: 'Selvstyrt britisk oversjøisk territorium i Karibia.' + notes.note.falkland-islands.fields.field.country-info: 'Britisk øygruppe.' + notes.note.gibraltar.fields.field.country-info: Britisk kronkoloni. + notes.note.turks-and-caicos-islands.fields.field.country-info: 'Selvstyrt britisk oversjøisk territorium i Karibia.' + Pacific Ocean: Stillehavet + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (svart/hvit/grønn, rød trekant)' + 'Palestine (no symbol)': 'Palestina (uten symbol)' + Panama City: Panama by + Papua New Guinea: Papua Ny-Guinea + Partially recognised state claimed by China.: 'Offisielt Republikken Kina. Folkerepublikken Kina gjør også krav på områdene.' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'Delvis anerkjent stat som Marokko gjør krav på.' + Partially recognised state claimed by Serbia.: 'Delvis anerkjent stat som Serbia gjør krav på.' + Persian Gulf: Persiabukta + Philippine Sea: Filippinerhavet + Philippines: Filippinene + Poland: Polen + Port Vila: Vila + Prague: Praha + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (blå trekant, røde striper)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (bredere, flere tagger og jordbrun farge)' + Red Sea: 'Rødehavet' + Region of France.: Fransk region. + Republic of the Congo: Kongo-Brazzaville + 'Romania (slightly lighter blue)': 'Romania (noe lysere blå)' + Rome: Roma + Russia: Russland + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Russland (uten våpenskjold), Slovenia (bredere, mindre våpenskjold)' + Sahrawi Arab Democratic Republic: Vest-Sahara + Saint Kitts and Nevis: Saint Kitts og Nevis + Saint Martin: Saint-Martin + Saint Vincent and the Grenadines: Saint Vincent og Grenadinene + Sanaa: Sana + Santiago: Santiago de Chile + Saudi Arabia: Saudi-Arabia + Scandinavia: Skandinavia og Norden + Scotland: Skottland + Sea of Galilee: 'Gennesaretsjøen' + Sea of Japan: Japanhavet + Sea of Okhotsk: Okhotskhavet + Semi-autonomous region of Tanzania.: Delvis selvstendig stat i Tanzania. + 'Senegal (green/yellow/red, green star)': 'Senegal (grønn/gul/rød, grønn stjerne)' + Seychelles: Seychellene + Sicily: Sicilia + 'Slovakia (narrower, bigger coat of arms)': 'Slovakia (smalere, større våpenskjold)' + 'Slovakia (with coat of arms)': 'Slovakia (med våpenskjold)' + Solomon Islands: 'Salomonøyene' + South Africa: 'Sør-Afrika' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Sør-Afrika har ingen fastsatt hovedstad; makten er fordelt på tre byer: Pretoria (utøvende), Cape Town (lovgivende) og Bloemfontein (dømmende).' + South America: 'Sør-Amerika' + South China Sea: 'Sør-Kina-havet' + South Korea: 'Sør-Korea' + South Ossetia: 'Sør-Ossetia' + South Sudan: 'Sør-Sudan' + Southern Ocean: 'Sørishavet' + Sovereign country: Selvstendig land + Spain: Spania + Special Administrative Region of China.: + notes.note.hong-kong.fields.field.country-info: 'Også kjent som Hong Kong. Spesiell administrativ region i Kina.' + notes.note.macau.fields.field.country-info: 'Også kalt Macau. Spesiell administrativ region i Kina.' + Sri Jayawardenepura Kotte: Colombo + 'St. John''s': 'Saint John’s' + State of the United States.: USAs delstat. + State recognised only by Turkey and claimed by Cyprus.: 'Selverklært øyrepublikk på Kypros som bare Tyrkia anerkjenner.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Område i det sentrale og sydlige Stillehavet som inneholder tusener av små øyer.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Område i det vestlige Stillehavet som inneholder tusenvis av små øyer.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Område i Stillehavet. Inneholder Vanuatu, Salomonøyene, Fiji og Papua Ny-Guinea.' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudan (rød/hvit/svart, grønn trekant), Vest-Sahara (med stjerne og halvmåne)' + Suriname: Surinam + Sweden: Sverige + Switzerland: Sveits + 'Switzerland has no official capital; Bern is the de facto capital.': Sveits har ingen offisiell hovedstad. Bern er regjeringssete. + 'São Tomé and Príncipe': 'São Tomé og Príncipe' + Tajikistan: Tadsjikistan + Tashkent: Tasjkent + Tasman Sea: Tasmanhavet + Tehran: Teheran + The Bahamas: Bahamas + The Gambia: Gambia + Thimphu: Thimpu + Timor Sea: 'Timorsjøen' + Timor-Leste: 'Øst-Timor' + Trinidad and Tobago: Trinidad og Tobago + Turkey: Tyrkia + Turks and Caicos Islands: 'Turks- og Caicosøyene' + Ukraine: Ukraina + Unincorporated internal area of Norway.: 'Norsk øygruppe.' + Unincorporated territory of the United States.: Uinnlemmet territorium i USA. + United Arab Emirates: De forente arabiske emirater + United Kingdom: Storbritannia + United States Virgin Islands: 'De amerikanske Jomfruøyer' + United States of America: USA + Uzbekistan: Usbekistan + Vatican City: Vatikanstaten + Vienna: Wien + Wallis and Futuna: Wallis og Futuna + Warsaw: Warszawa + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: Nasjonalforsamling og regjering i Haag. + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: Dodoma er offisiell hovedstad, mens Dar es Salaam er regjeringssete. + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': '' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Mbabane offisiell, utøvende hovedstad, mens Lobamba er tradisjonell, åndelig og lovgivende hovedstad.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: Porto-Novo er offisiell hovedstad, mens Cotonou er de facto regjeringssete. + While Sucre is the constitutional capital, La Paz is the seat of government.: Sucre er offisiell hovedstat, mens La Paz er regjeringssete. + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: Yamoussoukro er offisiell hovedstad, mens Abidjan er de facto regjeringssete. + White Sea: 'Kvitsjøen' + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Verdensdel som omfatter det australske kontinentet og de fleste øyene i Stillehavet.' + Yellow Sea: Gulehavet + Yemen: Jemen + Yerevan: Jerevan + 'Åland Islands': 'Åland' + additions: + notes.note.arabian-sea.fields.field.country-info: 'Også kalt Arabiahavet.' + notes.note.balkan-peninsula.fields.field.country-info: 'Også kalt Balkanhalvøya.' + notes.note.baltic-sea.fields.field.country-info: 'Også kalt Det baltiske hav.' + notes.note.belarus.fields.field.country-info: 'Tidligere kjent som Hviterussland. Utenriksdepartementet begynte å bruke navnet Belarus 29. mai 2022.' + notes.note.china.fields.field.country-info: Offisielt Folkerepublikken Kina. + notes.note.finland.fields.field.capital-info: 'Det finske navnet er Helsinki, men Utenriksdepartementet i Norge anbefaler å bruke byens svenske navn.' + notes.note.greenland.fields.field.capital-info: 'På dansk Godthåb.' + notes.note.indian-ocean.fields.field.country-info: 'Også kalt Indiahavet.' + notes.note.kiribati.fields.field.capital-info: 'Mange regner Bairiki som Kiribatis hovedstad (også Utenriksdepartementet i Norge) fordi både parlamentet og presidenten en gang hadde sete der. Siden år 2000 har parlamentet og departementer blitt spredd utover Syd-Tarawa.' + notes.note.mongolia.fields.field.capital-info: 'Også kjent som Ulan Bator.' + notes.note.myanmar.fields.field.capital-info: 'Hovedstad flyttet til Naypyidaw (Nay Pyi Taw) fra Yangon (tidl. Rangoon) i 2005.' + notes.note.persian-gulf.fields.field.country-info: 'Også kalt Den persiske bukt, Persiagolfen, Den persiske gulf og Den arabiske bukt.' + notes.note.republic-of-the-congo.fields.field.country-info: Offisielt Republikken Kongo. + notes.note.sea-of-galilee.fields.field.country-info: 'Også kalt Galileasjøen, Tiberiassjøen eller Ginossarsjøen.' + notes.note.turkmenistan.fields.field.capital-info: 'Også kjent som Asjgabat.' + notes.note.united-kingdom.fields.field.country-info: 'Også kalt Det forente kongeriket Storbritannia og Nord-Irland.' + notes.note.united-states-of-america.fields.field.country-info: 'Også kjent som De forente stater eller Sambandsstatene.' + notes.note.white-sea.fields.field.country-info: 'Også kalt Hvitehavet.' + variables: + label.capital: + Capital: Hovedstad + label.flag: + Flag: Flagg + label.location: + Location: Beliggenhet + note-type.name: + Ultimate Geography: 'Ultimate Geography [NB]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Flagg som ligner på {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'G8%+Eg:Na<' + '5JJ2]i)p4': 'E([1*B$wFU' + '5S(f7%O-$': 'Dh89q.%Zv<' + '?n!)c/h=Q': 'e7w}$Wx7yb' + 'AES],s:8=': 'd55~I-Znw' + 'O~pXkAbd3': 'FCE{oF+@St' + 'QSq%~rFaP-': 'yLhZzEnGK&' + 'Qlib4)q2Hk': 'Cay~/DU6[Y' + 'SlOEXfq#|': 'F[7BR94HX^' + 'W5?6yQZD2': 'u#*HO9?|?]' + '_]DH+g9!$': 'jH=<{Pcl+D' + 'b@M[XTc?}5': 'k,Tmj~Pw.i' + 'bD?R}i8_[c': 'J[Z3=;n.`R' + 'bW)ch:vs1P': 'tt:.S?c/]j' + 'b[(2`4t&?J': 'FMj}H;knqq' + 'b^Try&me1A': 'Dd.ED,HOi&' + 'b`5Q_Z=HQT': 'Gf4fmYBN)X' + 'bd~Ght5tLR': 'DO-=AqS[Lk' + 'bk2ByZIeU+': 'n`P0w@?q1u' + 'bnEK#)[;qT': r8MKfCfg2E + 'c(,2{yXI#E': '>1ErI|!*U' + 'c,5(mUGzYT': 'G#LLy[.iS2' + 'cAlyc~G.y%': 'kQssZ~b%|u' + 'cEO^y)Md[G': 'IHD#^9>(oF' + 'cL?8!xP#Wj': 'm>7j+&89xI' + 'cPO|Cru8n<': 'rymWNfd[Q[' + 'cV4idO^r8W': 'G+c6-zDX5@' + 'cYKvF#ptF7': 'b<&&OM3fYy' + 'c^,~EC6Pb2': 'nJdfBSsZq=' + 'ck!6;xK$/^': 'E2:8?ZCW`=' + 'cv+Nq9:aV@}^.' + 'de=9c:,@j9': 'AJ/nnIR/[0' + 'dlU7VG|3Gd': 'i?3Cq]Y=6@' + 'd}R:qHj1,7': 'v/?czq&41s' + 'e!KSebP}pt': 't`w^xuFUVp' + 'e+/O]%*qfk': 'iR~,tG{Val' + 'e.t$Vi5,>f': 'i3+,TcHF,N' + 'e42BpV,P}C': 'D+L%7xa1|K' + 'e5P*D#I{m{': 'ml7O4E=o<>' + 'egv' + 'e?h=Xhs+K&': 'P}MIWKA{Xz' + 'eALZe^HCHq': 'vy^=B$6h^Y' + 'eHJ?)TAdTo': 'w[pneW3d#g' + 'eN#^kPGv*L': 'Bc^_k/M|{V' + 'eTHH?U': lQbPlI/gwj + 'euW0jm=wfR': 'H9A(m7f1)9' + 'e{e8vi@^PY': 'c^iV73&0V(' + 'f#4-L[mp5*': 'M}OReg^Eem' + 'f*2@qiHh_1': Fe.owPPLXj + 'f*LO~>!0PN': 'I{.D^{85=#' + 'f21=-A~W;~': 'neb-=f&6r^' + 'f>c7x2E]Q6': 'dn}H9%A|!j' + 'fRSM{K+;[w': 'z7wQ8s_Si[' + 'fT!w8R#>dg': 'zv;#VKpiyA' + 'fV`&p81%d(': 'hH0': 'msi&z%jb~L' + 'gRVZ]qJ#>O': 'GkU[4wRj6;' + 'gYIoR`|AxW': 'bKW#GK' + 'gx:;za!?C9': 'K>Dfm{ad5K' + 'gz9${p}er*': 'l$1j[S4Z_w' + 'g~%pF`(x{u': 'PQ6$bI??y6' + 'h&`>n98~V1': 'G5wA};7d`m' + 'h9Jr(H[=1~': KYSHLMcczM + 'h?a*UKfT_M': 'BqW}>:ZSdW' + 'hE5j*e,Q?[': 'Bq5H0+i]XW' + 'hNE9}>;Q&-': 'D_u6CIg+wo' + 'hXNTVF<:SH': 'fxvMT{|_3M' + 'hZ/V/D&rO`': 'l!=SCi?>l?' + 'h_9=mkZXAH': 'cJ%(LRs3)q' + hoHandbYAy: 'B4sHDjU:Pl' + 'hrI_uiW>E]': 'p]3f+m(L/~c=': 'jKTnJ!T6lc' + 'i^J8&$(1]|': 'q%;R^5+|lP' + 'ia8as9sCY|': 'vxq=QA:/`z' + 'igiK,ff[eO': 'l|kNJq}f(%' + 'ijP>aJBT!9': 'dV#R#|}uZH' + 'ilGtw#=asM': 'fWbYL=>/W&' + 'j%*8gN)q>d': 'N(,;S#&N9e' + 'j)i)pB*HJ,': 'l[H_#9Xj.5' + 'j2IQ=f=w3@': 'C18`2)zTm(' + j7eEZkzsCZ: 'rQ)Q-EabYy' + 'j9:K,~DQ2v': 'HbTQZU8t9?' + 'jg|}': 'cnUAu,2#H.@>i' + 'jN^Vc%9OQ5': 'orHoXjp' + 'k/u1:B%DJH': 'G$NEO(W.(v' + 'k0{O[6l]bH': 'xL^i{$Dd?}' + 'k1]FH+s8j@': 'k4o56u=}jw' + 'k9E.p^H' + 'khB~(m^@Z5': 'M,j]%f~qLY' + 'kkbYQXw5b;': 'w`{+uGys`~' + 'koI(}OyJ:@': 'zH)c,C{V#j' + 'kopwzzB``A': 'l,n6{OL+@0' + 'k}4/oj#$~s': 'efA[V?^$DO' + 'k}A9]O:0xw': 'n{xEq+_:c{' + 'l$2O|9w`F~': dmsIj2JJlB + 'l$zM~ihPFE': 'eX=4L.MZ/)' + 'lI)Ah/E': 'zv#Fyq9;?1' + 'l*j*EE:!Y?': 'n%Pw}}Ki!j#v+': 'fLs!R5^k7e' + 'l3Ly8PBxt(': 'm<$SD/W~so' + 'lAxH.CfJB@': 'DNXY<9~9OH' + 'lH^2D]w;[H': 'qpx1j<_-l5' + 'lU@C37T|g>': 'ya?+!BD>AxIr4': 'g3SU#SH>>e' + 'm{noqE29%I': 'oK)C745Je~' + 'm{oM,jr>C#': 'zJ%%Hz#s9&' + 'n&>e`2FO/S': 'svzWf$x!8;' + 'n+t>2`:;P/': 'dSwjDNnc7[' + 'n:7&>YT5A=': 'A|BG)32=10' + 'nIfRn(_#b' + 'o9{x/yz': 'J3)Ty17a?Q' + 'oa87&PB&Bm': 'Gc>|URJcg|' + 'onMKID2d;D': 'L1&JVH|{R,' + 'o~}KVA:VbI': qqJpIWfqFB + 'p#R$-mG/L': 'C+sW!v-+p]' + 'p.yv>BQ470': 'P*4KZ6]?rf' + 'p:SPZ:e/*[': 'dryK>>pO;}' + 'pDDrFhd%u': 'Q]0R7JuJ6g' + 'pYRw[=6;JI': 'bo<0OKHNdG' + 'p]t8:Tka7q': OawZ7gbEjw + pkj3RPBUrF: 'Cw^_3<[mGM' + 'pwwZu{7f~(': 'q>RaVbPB3|' + 'pz(G*g': 'b~m$wS)FZZ' + 'q2#Yv[O0:|': 'Kx5=:O[u*^' + 'q7do-wLx%E': 'c$J,,={OZd' + 'q8][)Ri}=q': 'hqnhi=ysa:' + 'q8fF$4,c6_': 'b#r[:tRj+!' + 'q:XyGf#>Wg': 'do3`,9Ol!H' + 'q=w[fB$d+$': 'z|A6R3H)3B' + 'r(h9&#wc~B': nnM01DlaVX + 'r/B|ra%L,.': 'FGrbm!9:Cd' + 'r5C646H*+*': 'QKm&4[Vr$t' + 'r6BV%|..*5': J4zOBxF6Lw + 'raI-$$|wT': 'po@@>#g44W' + 'r>g<|,FWi%': 'O86#Kfnj]4' + 'r?E$4f-d0/': 'c}m%T+N!Gz' + 'rPekx@n9:L': 'ueNqs:h=(z' + 'rSneU!3}bn': 's>VRzs_ra_' + 'r]*Ioa%$bK': 't@-V_iMKbK' + 's#:aIK$9^x': 'Gs*[8}bP33' + 's4E?ZF[E<>': 'b|Gr(/*-kx' + 's9-GL*@AXD': 'o+)poCG*[u' + 's<_WLSB3I/': 'oLdeL6a~[X' + 's>f40rn,O]': 'HtFq_[S@sg' + 'sB7rZQsRN~' + sM0GPt1HS1: 'wt&@Z9Ke*y' + 'sRw-ik:I$v': 'NH:[}beT-z' + 'sSW/?WMy17': 'B5/<2!_D3h' + 'sT5=H|@KU|': 'pnd}~gw/:?' + sXzvT.g31p: 'b_#ie@t!%Q' + 'sY>@9w~i^p': 'ILj+.~~7_X' + 'skOM(?n$GG': 'ELkg0G|C?W' + spKoFRM4if: QyNb5MKVnQ + 'ssbyA9}]QP': 'e&L!j2[3Fp' + 'stRa/S(0$F': 'fFI@z-#q(E' + 'sxOMs+bE8@': 'cep8BP*Eja' + 'sx|l+io`B@': 'L&YSIviD+L' + 's|K&2S,UP|': 'f)MSa#ZHdL' + 't$y1C2R-Av': 'r%;LR/o1J0' + 't+v9b--,qs': 's^sd#Qsi,{' + 't-0>F]bu>P': x1I5QDSZdm + 't2&ri|[Un:': 'Hg&j8uJWA$' + t3aZXtpNyi: 'C00t,nt-~8' + 't50y/[AEhF': 'la.c2n+A7e' + 'tMMtBUpb$&': 'ox5y>?yriv' + 'tTnCoO/*tk': 'd.lZ`^MKjl' + 't_h*fbN#:(': 'y[:PO%mkg(' + 't`cagaWApo': 'w9bE`pR=z#' + 'tcneuK7v%`': gBJKTCx_/6 + tfCY4j6KEZ: 'k*{up;o{|2' + 'tj&bK}%P!F': 'Ql.8m|u}H!' + 'twrhzJ[k:Z': 'fy^;okm_1k' + 't|h3|;E4g{': 'NSVN;H$s)m' + 'u-`cspdp)D': Ce096-Qwie + 'u4^lH.xem5': 'yLv#s7(0/U' + 'u;|vR#mVMW': 's9/>8RT~Sr' + 'uH*Q(mcRt5': 'w0Je-<6ym_' + 'uRo^w#p5Be': 'j~OT/nTkAt' + 'uc61ToT%gt': z,yR__YX6x + 'ui3>b-?a@m': 'M7m8p!;nP0' + upiX0g1quR: 'G4K|pWoffU' + 'uv1fGXHN@)': 'l(DiRV,GPj' + 'u|I}oElaH)': 'i$+!}pvHZ(' + 'vE[T$`n9T)': 'PFcyiTRKq=' + 'vKx<0Qa!aP': 'E*M$FU#Mf5' + 'vNiD6A.v#s': 'p~,(pas|?P' + 'va/ky=f5pK': 'CTw3Yd}Sx4' + 'vf)G?[_?Pf': 'wOOaycR~z&' + 'vgdj#X?8dB': qD4Ir3_WvI + 'vpOTK6tMr@': 'y8o-zQb(8j' + 'vppEA|$@~g': 'k}cPQuF&TS' + 'vrn%j%6{nu': 't1~2K{sjcs' + 'vwc3}.=Z#&': 'qhlz{k!2cXc': 'kP@udSHU#/' + x2d4n4ADcP: 'B)~SL?|&BX' + 'xb&P*pH(Q': 'OB:w%H&-Al' + 'xqrJ^d[.(O': 'cE|I9C6tDG' + 'yX:tmrd5;]': 'x)A]S|GN_G' + 'z:*d{z(~V2': vJUDdBqkkp + 'zSc]-^U=0=': 'td+]W)8y3S' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: 8ccdc1f6-b042-4d55-8fcd-9e08b5b71dc7 diff --git a/fixtures/ultimate-geography/overlays/languages/nl.yaml b/fixtures/ultimate-geography/overlays/languages/nl.yaml new file mode 100644 index 0000000..ae69062 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/nl.yaml @@ -0,0 +1,721 @@ +id: overlay.translation.nl +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': Er bestaat een dispuut over de naam tussen de aangrenzende landen. In het Koreaans wordt de zee Oostzee genoemd. + Abkhazia: 'Abchazië' + Addis Ababa: Addis Abeba + Adriatic Sea: Adriatische Zee + Aegean Sea: 'Egeïsche Zee' + Africa: Afrika + Akrotiri and Dhekelia: Akrotiri en Dhekelia + Albania: 'Albanië' + Algeria: Algerije + Also known as Burma.: Ook bekend als Birma. + Also known as Cabo Verde.: '' + Also known as Czechia.: '' + Also known as East Timor.: Ook bekend als Timor-Leste. + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: '' + Also known as the Sea of Cortez.: 'Ook wel de Zee van Cortés genoemd.' + 'Also spelled as Sana''a.': '' + American Samoa: Amerikaans Samoa + 'Andorra (narrower, coat of arms with motto)': 'Andorra (smaller, wapen met het motto)' + Antigua and Barbuda: Antigua en Barbuda + Arabian Sea: Arabische Zee + Aral Sea: Aralmeer + Arctic Ocean: Arctische Oceaan + Argentina: 'Argentinië' + Armenia: 'Armenië' + Ashgabat: Asjchabad + Asia: 'Azië' + Athens: Athene + Atlantic Ocean: Atlantische Oceaan + Australia: 'Australië' + 'Australia (white stars, two more stars)': 'Australië (witte sterren, twee sterren meer)' + Austria: Oostenrijk + 'Austria (brighter red, wider white band)': 'Oostenrijk (helderder rood, bredere witte band)' + Autonomous community of Spain.: Autonome gemeenschap in Spanje. + Autonomous province of South Korea.: Autonome provincie van Zuid-Korea. + Autonomous region of Finland.: Autonome regio van Finland. + Autonomous region of Italy.: 'Autonome regio van Italië.' + Autonomous region of Papua New Guinea.: Autonome regio van Papoea-Nieuw-Guinea. + Autonomous region of Portugal.: Autonome regio van Portugal. + Azerbaijan: Azerbeidzjan + Azores: Azoren + Baghdad: Bagdad + Bahrain: Bahrein + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrein (smaller, minder gekartelde randen, rood)' + Baku: Bakoe + Balkan Peninsula: Balkan + Baltic Sea: Oostzee + Banda Sea: Bandazee + Barents Sea: Barentszzee + Bay of Bengal: Golf van Bengalen + Bay of Biscay: Golf van Biskaje + Beijing: Peking + Beirut: Beiroet + Belarus: Wit-Rusland + Belgium: 'België' + Belgrade: Belgrado + Bering Strait: Beringstraat + Berlin: Berlijn + Bishkek: Bisjkek + Black Sea: Zwarte Zee + 'Bogotá': Bogota + 'Bolivia (coat of arms instead of star)': 'Bolivia (wapen in plaats van ster)' + Bosnia and Herzegovina: 'Bosnië en Herzegovina' + 'Brasília': Brasilia + Brazil: 'Brazilië' + British Virgin Islands: Britse Maagdeneilanden + Brussels: Brussel + Bucharest: Boekarest + Budapest: Boedapest + Bulgaria: Bulgarije + Cairo: 'Caïro' + Cambodia: Cambodja + Cameroon: Kameroen + 'Cameroon (green/red/yellow, yellow star)': 'Kameroen (groen/rood/geel, gele ster)' + Canary Islands: Canarische Eilanden + Cape Verde: 'Kaapverdië' + Caribbean Sea: Caribische Zee + Caspian Sea: Kaspische Zee + Cayman Islands: Kaaimaneilanden + Celebes Sea: Celebeszee + Celtic Sea: Keltische Zee + Central African Republic: Centraal-Afrikaanse Republiek + Cetinje is an honorary capital.: Voormalige hoofdstad is Cetinje. + Chad: Tsjaad + 'Chad (slightly darker blue)': 'Tsjaad (donkerder blauw)' + Chile: Chili + City of San Marino: San Marino + Claimed and controlled: Geclaimd en gecontroleerd + Claimed but not controlled: Geclaimd maar niet gecontroleerd + 'Colombia (no coat of arms)': 'Colombia (geen wapen)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Colombo wordt vaak de hoofdstad genoemd, maar Sri Jayawardenapura Kotte, een buitenwijk van Colombo, is de officiële, wetgevende hoofdstad.' + Comoros: Comoren + Constituent country in the Kingdom of Denmark.: Autonoom gebied binnen het Koninkrijk Denemarken. + Constituent country of the Kingdom of the Netherlands.: Autonoom gebied binnen het Koninkrijk der Nederlanden. + Constituent country of the United Kingdom.: Autonoom gebied binnen het Verenigd Koninkrijk. + Cook Islands: Cookeilanden + Copenhagen: Kopenhagen + Coral Sea: Koraalzee + Croatia: 'Kroatië' + Crown dependency of the United Kingdom.: Brits Kroonbezit. + 'Cuba (red triangle, blue stripes)': 'Cuba (rode driehoek, blauwe strepen)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (twee sterren in de linkerbovenhoek)' + Czech Republic: 'Tsjechië' + Dead Sea: Dode Zee + Democratic Republic of the Congo: Democratische Republiek Congo + Denmark: Denemarken + Denmark Strait: Straat Denemarken + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Betwiste; opgeëist door Israël; Ramallah is het administratieve centrum.' + 'Disputed; claimed by Palestine.': 'Betwiste; opgeëist door Palestina.' + Dominican Republic: Dominicaanse Republiek + Dushanbe: Doesjanbe + East China Sea: Oost-Chinese Zee + East Siberian Sea: Oost-Siberische Zee + 'Ecuador (with coat of arms)': 'Ecuador (met wapen)' + Egypt: Egypte + 'Egypt (emblem instead of text), Yemen (no text)': 'Egypte (embleem in plaats van tekst), Jemen (geen tekst)' + 'Egypt (with emblem), Iraq (with text)': 'Egypte (met embleem), Irak (met tekst)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (ander wapen, donkerder blauw)' + England: Engeland + English Channel: Het Kanaal + Equatorial Guinea: Equatoriaal-Guinea + Estonia: Estland + Ethiopia: 'Ethiopië' + Europe: Europa + European Union: Europese Unie + Falkland Islands: Falklandeilanden + Faroe Islands: 'Faeröer' + Federated States of Micronesia: 'Federale Staten van Micronesië' + Formerly Zaire.: 'Voorheen Zaïre.' + Formerly known as Macedonia.: 'Voorheen bekend als Macedonië.' + France: Frankrijk + French Guiana: Frans-Guyana + French Polynesia: 'Frans-Polynesië' + Georgia: 'Georgië' + Germany: Duitsland + 'Ghana (star instead of coat of arms)': 'Ghana (ster in plaats van wapen)' + Greece: Griekenland + Greenland: Groenland + Guatemala City: Guatemala-Stad + Guinea: Guinee + 'Guinea (green and red flipped, slightly darker green)': 'Guinee (groen en rood omgekeerd, donkerder groen)' + Guinea-Bissau: Guinee-Bissau + Gulf of Alaska: Golf van Alaska + Gulf of California: 'Golf van Californië' + Gulf of Carpentaria: Golf van Carpentaria + Gulf of Guinea: Golf van Guinee + Gulf of Mexico: Golf van Mexico + Gulf of Thailand: Golf van Thailand + Haiti: 'Haïti' + Hawaii: 'Hawaï' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: Historisch en cultureel gebied in Noord Europa, hetwelk de landen Denemarken, Noorwegen, Zweden en soms Finland en IJsland omvat. + Hong Kong: Hongkong + Hudson Bay: Hudsonbaai + Hungary: Hongarije + Iceland: IJsland + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'IJsland (blauwe achtergrond, rood met wit kruis), Noorwegen (rode achtergrond, blauw met wit kruis)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'IJsland (blauwe achtergrond, rood met wit kruis), Faeröer (witte achtergrond, rood/blauw kruis)' + Independent state claimed by Georgia.: 'Onafhankelijke staat opgeëist door Georgië.' + Independent state claimed by Moldova.: 'Onafhankelijke staat opgeëist door Moldavië.' + Independent state claimed by Somalia.: 'Onafhankelijke staat opgeëist door Somalië.' + Indian Ocean: Indische Oceaan + Indonesia: 'Indonesië' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesië (breder, helderder rood), Monaco (wit en rood omgekeerd, smaller)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesië (breder, helderder rood), Polen (rood en wit omgekeerd, breder)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (tekst in plaats van embleem), Jemen (geen embleem)' + Ireland: Ierland + 'Ireland (orange and green flipped, wider)': 'Ierland (oranje en groen omgekeerd, breder)' + Island of Indonesia.: 'Eiland van Indonesië.' + Isle of Man: Man + Israel: 'Israël' + Italy: 'Italië' + Ivory Coast: Ivoorkust + 'Ivory Coast (green and orange flipped, narrower)': 'Ivoorkust (groen en oranje omgekeerd, smaller)' + Jerusalem: Jeruzalem + Jordan: 'Jordanië' + Juba: Djoeba + Kaliningrad Oblast: Oblast Kaliningrad + Kazakhstan: Kazachstan + Kenya: Kenia + Khartoum: Khartoem + Known as Nur-Sultan between 2019 and 2022: Gekend als Nur-Sultan tussen 2019 en 2022 + Known as Swaziland until 2018.: Bekend als Swaziland tot 2018. + Kuwait: Koeweit + Kuwait City: Koeweit + Kyiv: Kiev + Kyrgyzstan: 'Kirgizië' + Laayoune: al-Ajoen + Labrador Sea: Labradorzee + Latvia: Letland + 'Latvia (darker red, narrower white band)': 'Letland (donkerder rood, smallere witte band)' + Lebanon: Libanon + Libya: 'Libië' + Lisbon: Lissabon + Lithuania: Litouwen + London: Londen + Luxembourg: Luxemburg + 'Luxembourg (lighter blue)': 'Luxemburg (lichter blauw)' + Luxembourg City: Luxemburg + Madagascar: Madagaskar + Malaysia: 'Maleisië' + Maldives: Malediven + 'Mali (red and green flipped, slightly brighter green)': 'Mali (rood en groen omgekeerd, helderder groen)' + Manila: Manilla + Marshall Islands: Marshalleilanden + Mauritania: 'Mauritanië' + Mediterranean Sea: Middellandse Zee + Melanesia: 'Melanesië' + Mexico City: Mexico-Stad + Micronesia: 'Micronesië' + Moldova: 'Moldavië' + 'Moldova (wider, coat of arms with eagle)': 'Moldavië (breder, wapen met adelaar)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (smaller, donkerder rood), Polen (rood en wit omgekeerd, donkerder rood)' + Mongolia: 'Mongolië' + Morocco: Marokko + Moscow: Moskou + Muscat: Masqat + 'N''Djamena': Ndjamena + Namibia: 'Namibië' + 'Nauru (single star below yellow band)': 'Nauru (enkele ster onder de gele band)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru heeft geen officiële hoofdstad; het Yaren-district is de de facto hoofdstad.' + Netherlands: Nederland + 'Netherlands (darker blue)': 'Nederland (donkerder blauw)' + New Caledonia: 'Nieuw-Caledonië' + New Zealand: Nieuw-Zeeland + 'New Zealand (red stars, two fewer stars)': 'Nieuw-Zeeland (rode sterren, twee sterren minder)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (ander wapen, lichter blauw)' + North America: Noord-Amerika + North Korea: Noord-Korea + North Macedonia: 'Noord-Macedonië' + North Nicosia: Noord-Nicosia + North Sea: Noordzee + Northern Cyprus: Noord-Cyprus + Northern Ireland: Noord-Ierland + Northern Mariana Islands: Noordelijke Marianen + Norway: Noorwegen + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Noorwegen (rode achtergrond, blauw kruis), Faeröer (witte achtergrond, rood/blauw kruis)' + Norwegian Sea: Noorse Zee + Not a sovereign country: Geen soevereine staat + 'Oblast (administrative region) of the Russian Federation.': 'Oblast (administratieve regio) van de Russische Federatie.' + Oceania: 'Oceanië' + Official capital was moved from Bujumbura to Gitega in 2019.: Bujumbura was tot 2019 de hoofdstad. + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: Malabo was tot 2026 de hoofdstad. + 'Officially Côte d''Ivoire.': '' + Officially Luxembourg.: '' + Overseas department of France.: Overzees departement van Frankrijk. + Overseas territory of France.: Overzees land van Frankrijk. + Overseas territory of the United Kingdom.: Overzees land van het Verenigd Koninkrijk. + Pacific Ocean: Stille Oceaan + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (zwart/wit/groen, rode pijl)' + 'Palestine (no symbol)': 'Palestina (geen symbool)' + Panama City: Panama-stad + Papua New Guinea: Papoea-Nieuw-Guinea + Paris: Parijs + Partially recognised state claimed by China.: 'Deels erkende staat, opgeëist door China.' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'Deels erkende staat, opgeëist door Marokko.' + Partially recognised state claimed by Serbia.: 'Deels erkende staat, opgeëist door Servië.' + Persian Gulf: Perzische Golf + Philippine Sea: Filipijnenzee + Philippines: Filipijnen + Poland: Polen + Polynesia: 'Polynesië' + Prague: Praag + Pretoria, Cape Town, Bloemfontein: Pretoria, Kaapstad, Bloemfontein + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (blauwe driehoek, rode strepen)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (breder, meer gekartelde randen, kastanjebruin)' + Red Sea: Rode Zee + Region of France.: Franse regio. + Republic of the Congo: Congo + 'Reykjavík': Reykjavik + Riyadh: Riyad + Romania: 'Roemenië' + 'Romania (slightly lighter blue)': 'Roemenië (lichter blauw)' + Russia: Rusland + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Rusland (geen wapen), Slovenië (breder, kleiner wapen)' + Sahrawi Arab Democratic Republic: Sahrawi Arabische Democratische Republiek + Saint Kitts and Nevis: Saint Kitts en Nevis + Saint Martin: 'Sint-Maarten (Franse Antillen)' + Saint Vincent and the Grenadines: Saint Vincent en de Grenadines + Sardinia: 'Sardinië' + Saudi Arabia: 'Saoedi-Arabië' + Scandinavia: Scandinavisch Schiereiland + Scotland: Schotland + Sea of Galilee: Meer van Tiberias + Sea of Japan: Japanse Zee + Sea of Okhotsk: Zee van Ochotsk + Semi-autonomous region of Tanzania.: Half-autonome regio van Tanzania. + 'Senegal (green/yellow/red, green star)': 'Senegal (groen/geel/rood, groene ster)' + Seoul: Seoel + Serbia: 'Servië' + Seychelles: Seychellen + Sicily: 'Sicilië' + Sint Maarten: 'Sint Maarten (Koninkrijk der Nederlanden)' + Slovakia: Slowakije + 'Slovakia (narrower, bigger coat of arms)': 'Slowakije (smaller, groter wapen)' + 'Slovakia (with coat of arms)': 'Slowakije (met wapen)' + Slovenia: 'Slovenië' + Solomon Islands: Salomonseilanden + Somalia: 'Somalië' + South Africa: Zuid-Afrika + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Zuid-Afrika is een federale republiek met drie hoofdsteden: Pretoria (bestuurlijke macht), Kaapstad (wetgevende macht) en Bloemfontein (rechterlijke macht).' + South America: Zuid-Amerika + South China Sea: Zuid-Chinese Zee + South Korea: Zuid-Korea + South Ossetia: 'Zuid-Ossetië' + South Sudan: Zuid-Soedan + South Tarawa: Zuid-Tarawa + Southern Ocean: Zuidelijke Oceaan + Sovereign country: Soevereine staat + Spain: Spanje + Special Administrative Region of China.: Speciale bestuurlijke regio van de volksrepubliek China. + Sri Jayawardenepura Kotte: Sri Jayewardenapura Kotte + 'St. George''s': 'Saint George''s' + 'St. John''s': 'Saint John''s' + State of the United States.: Staat van de Verenigde Staten. + State recognised only by Turkey and claimed by Cyprus.: 'Staat enkel erkend door Turkije en opgeëist door Cyprus.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Subregio van Oceanië met duizenden kleine eilanden in de centrale en zuidelijke Stille Oceaan.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Subregio van Oceanië met duizenden kleine eilanden in de westerse Stille Oceaan.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Subregio van Oceanië, waaronder Vanuatu, de Salomonseilanden, Fiji en Papoea-Nieuw-Guinea.' + Sudan: Soedan + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Soedan (rood/wit/zwart, groene pijl), Sahrawi Arabische Democratische Republiek (met wassende maan en ster)' + Sukhumi: Soechoemi + Svalbard: Spitsbergen + Sweden: Zweden + Switzerland: Zwitserland + 'Switzerland has no official capital; Bern is the de facto capital.': 'Zwitserland heeft geen officiële hoofdstad maar Bern vervult deze rol in de praktijk en wordt aangeduid als bondsstad.' + Syria: 'Syrië' + 'São Tomé': 'Sao Tomé' + 'São Tomé and Príncipe': 'Sao Tomé en Principe' + Tajikistan: Tadzjikistan + Tashkent: Tasjkent + Tasman Sea: Tasmanzee + Tehran: Teheran + The Bahamas: 'Bahama''s' + The Gambia: Gambia + Timor Sea: Timorzee + Timor-Leste: Oost-Timor + Tokyo: Tokio + Transnistria: 'Transnistrië' + Trinidad and Tobago: Trinidad en Tobago + Tskhinvali: Tschinvali + Tunisia: 'Tunesië' + Turkey: Turkije + Turks and Caicos Islands: Turks- en Caicoseilanden + Uganda: Oeganda + Ukraine: 'Oekraïne' + Unincorporated internal area of Norway.: Intern gebied zonder rechtspersoonlijkheid van Noorwegen. + Unincorporated territory of the United States.: Gebied zonder rechtspersoonlijkheid van de Verenigde Staten. + United Arab Emirates: Verenigde Arabische Emiraten + United Kingdom: Verenigd Koninkrijk + United States Virgin Islands: Amerikaanse Maagdeneilanden + United States of America: Verenigde Staten + Uzbekistan: Oezbekistan + Vatican City: Vaticaanstad + Vienna: Wenen + Wallis and Futuna: Wallis en Futuna + Warsaw: Warschau + Washington, D.C.: Washington D.C. + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Amsterdam is de officiële hoofdstad, hoewel het parlement en de regering in Den Haag zetelen.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Ondanks Dodoma de officiële hoofdstad is, is Dar es Salaam de facto de zetel van de regering.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': Ondanks Laayoune, ook wel bekend als al-Ajoen, de verklaarde hoofdstad is, is Tifariti de feitelijke zetel van de regering. + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: Mbabane is de bestuurlijke hoofdstad en Lobamba de traditionele koninklijke hoofdstad en wettelijke zetel. + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Porto-Novo is de officiële hoofdstad, maar de economische hoofdstad en grootste stad van Benin is Cotonou.' + While Sucre is the constitutional capital, La Paz is the seat of government.: Ondanks Sucre de constitutionele hoofdstad is, is La Paz de zetel van de regering. + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: Abidjan was tot 1983 de hoofdstad, de meeste overheidsgebouwen en ambassades bevinden zich er nog steeds. + White Sea: Witte Zee + World region covering the Australian continent and most of the islands in the Pacific Ocean.: Wereldregio die het Australische continent en de meeste eilanden in de Stille Oceaan beslaat. + Yellow Sea: Gele Zee + Yemen: Jemen + Yerevan: Jerevan + 'Åland Islands': 'Åland' + additions: + notes.note.baltic-sea.fields.field.country-info: Ook wel Baltische Zee. + notes.note.belarus.fields.field.country-info: Ook bekend als Belarus. + notes.note.china.fields.field.capital-info: Ook als Beijing bekend. + notes.note.federated-states-of-micronesia.fields.field.country-info: 'Ook wel Micronesië of Micronesia genoemd.' + notes.note.maldives.fields.field.country-info: Ook wel Maldiven genoemd. + notes.note.pacific-ocean.fields.field.country-info: Ook wel Grote Oceaan of Pacifische Oceaan genoemd. + notes.note.sea-of-galilee.fields.field.country-info: Ook wel Meer van Kinneret, Meer van Galilea of Meer van Genezareth genoemd. + variables: + label.capital: + Capital: Hoofdstad + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Aanwijzing: {{Capital hint}}' + label.flag: + Flag: Vlag + label.location: + Location: Ligging + note-type.name: + Ultimate Geography: 'Ultimate Geography [NL]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Vlag vergelijkbaar met {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': tBCiVBcuG_ + '5JJ2]i)p4': yXSMWI1KUx + '5S(f7%O-$': 's!x^ui`nhG' + '?n!)c/h=Q': 'IuT|OM*Nw~' + 'AES],s:8=': 'gGm>+9`k&' + 'B:xP}@&|' + 'bW)ch:vs1P': 'ioJ7(frmya' + 'b[(2`4t&?J': 'fN1A,quIbA3.' + 'd}R:qHj1,7': 'q3=L7rObtd' + 'e!KSebP}pt': 'fE^)_>&{*d' + 'e+/O]%*qfk': 'K`AixN?4fO' + 'e.t$Vi5,>f': '.9`Zb?|4]' + 'e42BpV,P}C': 'MXc[U*Dc|y' + 'e5P*D#I{m{': 'j!9GChs@cQ' + 'eHH?U': 'p}2F6mZ.QA' + 'euW0jm=wfR': 'g:S=I8r:O>' + 'e{e8vi@^PY': 'q;6Wv3u)tx' + 'f#4-L[mp5*': 'i$q|D&dUr3' + 'f*2@qiHh_1': 'o2AxTxOe+;' + 'f*LO~>!0PN': 'K7t:ah)Nlf' + 'f21=-A~W;~': 'P+Jx`aqYih' + 'f>c7x2E]Q6': 'QYr^22xCo3' + 'fRSM{K+;[w': 'bq]dHo&Tn~' + 'fT!w8R#>dg': 'fezBXMIU#+' + 'fV`&p81%d(': 'b&EqNhY[/p' + fkq7lhLkdH: 'w7op[fQgTo' + 'fxAN),FAc$': 'Ef]sAhH-1=' + 'g-]bb&[.E!': 'fQMbKoTu%<' + 'g9v~y/;=z(': 'p?N:RJ`9Qb' + 'g<[f89~U)A': 'E-hC9K}m$~' + 'gBCR?!O*;E': 'JM7]2EJ[qX' + 'gB|K:+r?1V': 'w_*e?Ms1]_' + 'gDsLPj(9S#': 'A1z!_C=d(c' + 'gFOojQO(MH': 'zumu&z#g.>' + 'gG6SWfM>H0': 'ygzZMO52<5' + 'gRVZ]qJ#>O': 'I+N4^}Lq%;' + 'gYIoR`|AxW': 'q>9E7+;4K.' + 'gePC:Xt_nW': 'sp;kVFWNG~' + 'gp[h@]y13l': 'uc9G:E._YR' + 'gsS{jR,]|Q': 'G;pW`r5kYU' + 'gx:;za!?C9': '/zj4yGq-~' + 'gz9${p}er*': 'Hp1dL9r:*P' + 'g~%pF`(x{u': 'm@%Rs{o2LO' + 'h&`>n98~V1': 'Ob.MR2u/-&' + 'h9Jr(H[=1~': 'O0h/x6zav@' + 'h?a*UKfT_M': rIn3klxCO3 + 'hE5j*e,Q?[': 'O|]ivh~%v{' + 'hNE9}>;Q&-': 'plhRl:Tz/O' + 'hXNTVF<:SH': 'QL%7i$x/c,' + 'hZ/V/D&rO`': 'z@fLLI>[@B' + 'h_9=mkZXAH': 'C_6GTKD=fH' + hoHandbYAy: 'Ca[)+1M+q?' + 'hrI_uiW>E]': 'Q+z>Go{V83' + 'h}lnSN_f$_': 'CpG.*qzHU5' + 'h~5xz+=ke~': 'yM2(NER.TK' + 'i)]+09JUR&': 'rBXL[4*?g5' + 'i2]v|D3@:j': byp9L/7/,y + 'iB-8Br~onq': '*/b5m[q8j' + 'iIS#Nw]tr]': 'epu:F7KRn!' + 'iR;Um(L/~c=': 'l57t^$S&*u' + 'i^J8&$(1]|': 'Neqxx;S4OH' + 'ia8as9sCY|': 'K[L!R^!9Z,' + 'igiK,ff[eO': 'uK!1aq#,Ii' + 'ijP>aJBT!9': vNlrE-EWLf + 'ilGtw#=asM': 'e(k0BGyL4h' + 'j%*8gN)q>d': 'sM=)Xv_Xa-' + 'j)i)pB*HJ,': 'n8ieJA{+lP' + 'j2IQ=f=w3@': 'e8|;;S>xQ.' + j7eEZkzsCZ: 'trUg0y&dqG' + 'j9:K,~DQ2v': 'NIKs(pApAY' + 'jg|}': 'jxR[;SimkU' + 'jCd]`-=k,:': 'iOD+[;vOc1' + 'jDA-V?/hVj': 'Bo5dwc%z#M' + jI9P-f6r3M: 'c(O@!xlX|c' + 'jKIDq|}J&c': 'CH0T!),h9{' + 'jKO,9y0M;#': 'tUW+`.SQo9' + 'jN^Vc%9OQ5': 'f($keyMpus' + 'jN|NAUP*h}': 'F5Vc$,%[aL/r)' + 'kZ52zV#[Iv': 'e=OVM]`Q{U' + 'k[)#L4.[v3': 'dG[-P1]Ly-' + 'khB~(m^@Z5': 'K1#2~5/6~Z' + 'kkbYQXw5b;': 'e90l[|lH!I' + 'koI(}OyJ:@': 'lR`ZpmCTOP' + 'kopwzzB``A': 'v#>8]5Eg[x' + 'k}4/oj#$~s': 'K~OD+`QJa(' + 'k}A9]O:0xw': 'rd_(OgnX-P' + 'l$2O|9w`F~': 'c7Zh2xn$t_' + 'l$zM~ihPFE': 'Cx2EH*D>}Z' + 'lI)Ah/E': 'mxqXkoW;I]' + 'l*j*EE:!Y?': 's$RMwIQGZb' + 'l+>Ki!j#v+': 'dm/G5**HY~' + 'l3Ly8PBxt(': 'rqH=>+|p}D' + 'lAxH.CfJB@': 'cl`b5TBXX(' + 'leh^fmrt*#': 'B2:MgtMw1@' + 'lhWI;$JC9(': 'kQ8x(01<}' + libliptkB4: 'rs3Uv}D,)h' + 'lmRujD!(A7': 'CTD4|lJ}]5' + 'ln^3p)j3b.': 'jmR])+zhJo' + 'lp@e##m.!+': 'LoxeR.`jw@' + 'lpbD0St&OU': 'L&Wh0Oh!9*' + 'lq`T^ZBF2Y': 'K0`3{Z#>H&' + 'l}[BG9.nQS': 'm$4os@Y#(Y' + 'm)WUtJS`H&': 'lQZ,yi}j,|' + 'm*#%sE.`.;': 'z&9OMJ`KZclA6' + 'mq)8`hi{hb': 'p{:,t++CFI' + 'mtd;>7T|g>': 'l::->dQxMA' + 'mz[>>AxIr4': 'cU)|/vPi6j' + 'm{noqE29%I': 'mB&*r_Fpq%' + 'm{oM,jr>C#': 'Ne`2FO/S': 'sUW(K@)rpu' + 'n+t>2`:;P/': O8/WkWCPZP + 'n:7&>YT5A=': 'q-&Hq~16&{' + 'nIfRn(r' + 'nsS59c`VL,': 'qL:(ZnK[{b' + 'nuU8A_$i2M': e1k,rPrJXi + 'n{{o2K1:dw': 'jov_[O@hK6' + 'o#&RMA_REo': 'OwHb{Mz|$h' + 'o3VS!KWb/@': xLWoq0vCna + 'o9{x/yz': 'AfMgn:9E6@' + 'oa87&PB&Bm': bPqp-AGH3Y + 'onMKID2d;D': 'sGn#POf<9-' + 'o~}KVA:VbI': 'J)9J,MA_jm' + 'p#R$-mG/L': 'sgfCxE,%}I' + 'p.yv>BQ470': 'eCQL}h5w8Y' + 'p:SPZ:e/*[': 'B{FDr5`ABh' + 'pDDrFhd%u': 'GmH*JA[[k`' + 'pYRw[=6;JI': 'zSI<3YA/@<' + 'p]t8:Tka7q': 'oI5~{W$B0+' + pkj3RPBUrF: 'Oy&*?g': 'mUci[@*LWg': 'oA1,v5>^){' + 'q=w[]sc0f?(|' + 'q[S4`,F0D=': MjYBzKMsYj + 'q[}7hk(+=e': n1HR1,WDIm + 'qiv/4tOlf=': 'M?CI|U:7E' + qvITjNxkLN: 'A16VT}*wpu' + 'qx%>fB$d+$': 'n58Xs' + 'r/B|ra%L,.': 'q,5sUDZJ@|' + 'r5C646H*+*': 'HL//^#,,5X' + 'r6BV%|..*5': 'hs$=I;b%H{' + 'raI-$$|wT': 'p((H!2~3O7' + 'r>g<|,FWi%': 'A7d`$VleNv' + 'r?E$4f-d0/': 'Abp+=0%|oz' + 'rPekx@n9:L': 'xm[hZ@]0D2' + 'rSneU!3}bn': 'u@^{[]68&M' + 'r]*Ioa%$bK': 'zlk#[1bx]e' + 's#:aIK$9^x': 'A$m)oTJ1BW' + 's4E?ZF[E<>': 'hf40rn,O]': 'i1Ug;$!s.p' + 'sB7rZQsR@9w~i^p': 'E/MT}kv2tI' + 'skOM(?n$GG': 'AXB' + 'sxOMs+bE8@': 'qj/S4:J~Wh' + 'sx|l+io`B@': 'd8zQ]l>xmo' + 's|K&2S,UP|': 'B8UUt(OH@c' + 't$y1C2R-Av': 'pediB(:-]T' + 't+v9b--,qs': 'cXB4#4>X;-' + 't-0>F]bu>P': 'F&@Rc;dl]z' + 't2&ri|[Un:': 'Q+I:vHQr7%' + t3aZXtpNyi: 'c+d=6X;DR:' + 't50y/[AEhF': 'oO/>jPN^a5' + 'tMMtBUpb$&': 'Qm?>e]Q]ag' + 'tTnCoO/*tk': jpDvwSb-OL + 't_h*fbN#:(': 'sHKG^]G1Hi' + 't`cagaWApo': 'EjFv^:qmnZ' + 'tcneuK7v%`': 'D@,Q^H)}Ln' + tfCY4j6KEZ: 'sze_Yzh;5$' + 'tj&bK}%P!F': 'bit-NvK^B3' + 'twrhzJ[k:Z': 'pV`:gi7=r2' + 't|h3|;E4g{': 's5?gh*QP;:' + 'u-`cspdp)D': 'Iva{=NlZof' + 'u4^lH.xem5': 'M5*2c!ENJ`' + 'u;|vR#mVMW': 'F[Xrq2xy>E' + 'uH*Q(mcRt5': 'ebcMj4pw`P' + 'uRo^w#p5Be': 'p#WecI-Q`c' + 'uc61ToT%gt': 'J[n$C~*-<~' + 'ui3>b-?a@m': 'OQK^$9xbg,' + upiX0g1quR: 'Mmj=@>?Sx{' + 'uv1fGXHN@)': 'vu]t,+fglW' + 'u|I}oElaH)': 'N-%-M=n!dh' + 'vE[T$`n9T)': 'r=ab^[Q' + 'vNiD6A.v#s': 'Bapr]RwJ#?' + 'va/ky=f5pK': 'nm*l%S8r+{' + 'vf)G?[_?Pf': 'i`?s5yONk<' + 'vgdj#X?8dB': 'o0AQQ^2)G6' + 'vpOTK6tMr@': 'gw%[JT~eOZ' + 'vppEA|$@~g': 'u2G!Pc,B?g' + 'vrn%j%6{nu': 'l$|!y^BHT^' + 'vwc3}.=Z#&': 'M=3y(}U;7v' + 'wa>{k!2cXc': 'o3&;VW(Hf,' + x2d4n4ADcP: 'cH2i]a9RGb' + 'xb&P*pH(Q': 'HedPVg8N$)H' + 'b`5Q_Z=HQT': 'eYTxN=I7~*' + 'bd~Ght5tLR': 'oG4Xq?fD~!' + 'bk2ByZIeU+': 'APd:*Sw~Jo' + 'bnEK#)[;qT': 'DP;c*Gso.!' + 'c(,2{yXI#E': 'm9:dQqYhJ-' + 'c,5(mUGzYT': 'rr).TrA1*?' + 'cAlyc~G.y%': cgFhukF/h- + 'cEO^y)Md[G': 'j?gTI%edF-' + 'cL?8!xP#Wj': 'i^0~4vv^)z' + 'cPO|Cru8n<': 'G9`LF2O_m`' + 'cV4idO^r8W': 'g7Q$z&jd|J' + 'cYKvF#ptF7': 'K04)[9u{;U' + 'c^,~EC6Pb2': 'MXy1;P2k/U' + 'ck!6;xK$/^': 'wij7pNM2{4' + 'cv+Nq9:aSA5<' + 'd-q4J~CNW_': 'En5vg+xV2M' + 'd3c`&/1Y^D': 'GMVFQ3%=_`' + 'd5m8%D6,;u': 'tb.s`6ajY!' + 'dC:e5G4J/p': 'B;L,H^Gdc?' + 'dUZ^=SE5BG': 'v40(;6p*}' + 'dWA49p}F{k': Jcw/VM6mWA + 'dY96`C-f(_': 'oC1a!U&Wzg' + 'd``B*6:eCx': 'B$dB1.]P{>' + 'dbP/Cj}sB+': 'd.uD&(B8mn' + 'de=9c:,@j9': 'Kc/ra`3(37' + 'dlU7VG|3Gd': 'ga{WsQ_.xK' + 'd}R:qHj1,7': 'm1fd8FZkD)' + 'e!KSebP}pt': 'sqTj^jwG6J' + 'e+/O]%*qfk': Eiu-RV/Iic + 'e.t$Vi5,>f': 'Is7;|To~I0' + 'e42BpV,P}C': 'vVp3u$}6,}' + 'e5P*D#I{m{': '44Z:NG|/2' + 'e' + 'eALZe^HCHq': 'uf:+,ayBHx' + 'eHJ?)TAdTo': 'rB7G?Z1O`u' + 'eN#^kPGv*L': 'n=-`bGotOp' + 'eTHH?U': 's)j|{A1@v,' + 'euW0jm=wfR': 'noP8iDid~*' + 'e{e8vi@^PY': 'R>nCNt0yu' + 'f#4-L[mp5*': 'vO82@=AYb4' + 'f*2@qiHh_1': 'EV^6i(gTp`' + 'f*LO~>!0PN': 'to@xOWmFJ.' + 'f21=-A~W;~': 'oXmy@&NBhz' + 'f>c7x2E]Q6': 'cBteEeBrv@' + 'fRSM{K+;[w': 'rp9WMFvoC`' + 'fT!w8R#>dg': 'cn;X?=;$x$' + 'fV`&p81%d(': 'Ipwb%/>_NH' + fkq7lhLkdH: 'wENu7P2Tf>' + 'fxAN),FAc$': 'k<2o{CZc@I' + 'g-]bb&[.E!': '}r6Y-pS^`' + 'g9v~y/;=z(': 'gv%|cnW_iW' + 'g<[f89~U)A': 'Gjm,Z+1Xq<' + 'gBCR?!O*;E': kerXOzu2o7 + 'gB|K:+r?1V': 'IhM_;-(g;*' + 'gDsLPj(9S#': 'OqXRZlbw}(' + 'gFOojQO(MH': 'cq(y`}6&AT' + 'gG6SWfM>H0': i0rhYlrB0u + 'gRVZ]qJ#>O': 'KBG*PF:x]!' + 'gYIoR`|AxW': 'Dx=^!Ay8w)' + 'gePC:Xt_nW': 'qN4y}IRxo9' + 'gp[h@]y13l': vaqxA_9kBR + 'gsS{jR,]|Q': 'erWr`a8I.' + 'gx:;za!?C9': 'yTe`BA-t|0' + 'gz9${p}er*': 'e=09`Q:lUY' + 'g~%pF`(x{u': 'OIc;jg+Y}!' + 'h&`>n98~V1': 'Eo@49=+F}8' + 'h9Jr(H[=1~': 'c~d(lr9Fvb' + 'h?a*UKfT_M': 'v4}AH&TlZ^' + 'hE5j*e,Q?[': 'Me4;.M^B3E' + 'hNE9}>;Q&-': KB32Lzzff7 + 'hXNTVF<:SH': 'DwiS>P`d`K' + 'hZ/V/D&rO`': 'e~0n<#f5bd' + 'h_9=mkZXAH': 'oK+nX%5Azo' + hoHandbYAy: 'gFErY.d_};' + 'hrI_uiW>E]': MafF-rLgDx + 'h}lnSN_f$_': 'PK^Qz|@`4!' + 'h~5xz+=ke~': 'jr&*:[}a%D' + 'i)]+09JUR&': 'D4kW+fbRGj' + 'i2]v|D3@:j': 'j{8I6i:uI^' + 'iB-8Br~onq': 'Qofk00d#]|' + 'iIS#Nw]tr]': P3zYwkzeeI + 'iR;Um(L/~c=': 'CK)-k5{^o/' + 'i^J8&$(1]|': 'GpKt5mjHz`' + 'ia8as9sCY|': 'rz:n2?FQ,S' + 'igiK,ff[eO': 'o93{:oXnx&' + 'ijP>aJBT!9': 'nsttHvgDm>' + 'ilGtw#=asM': 'u/n1Ph{G86' + 'j%*8gN)q>d': HNwlMP3HsK + 'j)i)pB*HJ,': 'P0$X6wa6aL' + 'j2IQ=f=w3@': 'hlD&(T)S+:' + j7eEZkzsCZ: 'jQ(g9H1Yt{' + 'j9:K,~DQ2v': 'I1nWmc<' + 'j=L,YBvsBk': PX.1yc,,.R + 'jBd@A?>g|}': 'e_c+~H}Uz@' + 'jCd]`-=k,:': 'nsSu>A!qF^' + 'jDA-V?/hVj': 'C,T`nz1)sq' + jI9P-f6r3M: 'Ho0:llLz?,' + 'jKIDq|}J&c': 'd|JbnAP.Wy' + 'jKO,9y0M;#': 'QseIpMO;9u' + 'jN^Vc%9OQ5': 'r2|aFq?Aaf' + 'jN|NAUP*h}': 'N:NFPUaE)d' + 'jTNoKo}Bu+': 'bO(L55c^DT' + 'jX=#G8wu#(': 'D6j;d-P4IS' + 'jYz[ibrr9m': 'qUay%|m3A)' + 'jcu!gLw/&r': 'EsgIVed0P)' + 'jrM28*HbyG': 'tln?%x~7F~' + 'j|z@PMgdx,': 'K)!dRmp|}@' + 'k$1_yaF?9#': 'J.W)SYM8sK' + 'k/u1:B%DJH': 'Kk%+6g2I$N' + 'k0{O[6l]bH': 'u(p9/S}e{:' + 'k1]FH+s8j@': 'nD4?vsWvX8' + 'k9E.p^>:KH9MRE' + 'kE!iKHLN_g': 'r,rl(W~R!#' + 'kQ:NIhJy~E': 'uQQ~q`h$RB' + 'kS)hDm%w}R': 'Lg7/ATNE^0' + 'kX]hN=_.c-': 'J]tb-@Emki' + 'kZ52zV#[Iv': 'bV~T=Qu3l{' + 'k[)#L4.[v3': 'o_Q;]Pr)2K' + 'khB~(m^@Z5': 'P?.F8Nyf*>' + 'kkbYQXw5b;': 'sZQ[ZR,vG^' + 'koI(}OyJ:@': 'jktcLTC(E;' + 'kopwzzB``A': 'HT6x8SDj(:' + 'k}4/oj#$~s': 'u2k7XVl,6]' + 'k}A9]O:0xw': 'grrlv;lc|U' + 'l$2O|9w`F~': 'P+B8Wp;7Ki!j#v+': 'mmXJoK#R`6' + 'l3Ly8PBxt(': fc5RwyMAlq + 'lAxH.CfJB@': '}p:<>`uNI' + 'lH^2D]w;[H': 'yAA!g~.{$T' + 'lU@C3`Uk5#}c' + 'mi+kf0LTW.': 'oq+z3Invfu' + 'mk1VltJl<]': 'x,_A^{yR`-' + 'mk20`,N=`=': '2]!$k739K' + 'mkgcU7[bA%': 'k`S4q(;Y;@' + 'mq)8`hi{hb': 'bfp}dMQSWW' + 'mtd;>7T|g>': 'A$@0/RKWZt' + 'mz[>>AxIr4': 'h8#>aN=*pf' + 'm{noqE29%I': 'EmbtmCi$[&' + 'm{oM,jr>C#': 'psgj>Xdg^g' + 'n&>e`2FO/S': 'P!Q,2o]Xv`' + 'n+t>2`:;P/': 'n+bE8**,!P' + 'n:7&>YT5A=': 'n-)8Zg=(tt' + 'nIfRn/yz': 'v>bBTG<5Gh' + 'oa87&PB&Bm': 'NsA5?JrP~1' + 'onMKID2d;D': 'o=u5,~YY$n' + 'o~}KVA:VbI': 'ojB*fV3Uwu' + 'p#R$-mG/L': 'Q.;i59|#bh' + 'p.yv>BQ470': 'Ff`Q)s:oK:' + 'p:SPZ:e/*[': 'sif]k_+X`R' + 'pDDrFhd%u': 'FoGORw~;@y' + 'pYRw[=6;JI': 'MGk<]E]6Ax' + 'p]t8:Tka7q': 'll0rItP2i#' + pkj3RPBUrF: 'hZJjw>^KS1' + 'pwwZu{7f~(': 'GkUycywYs:' + 'pz(G*9eHNM' + 'p}Qa|!7PpR': 'lXJyrwE+g': 'PLCJ5=X}y%' + 'q2#Yv[O0:|': 'PV>94O`ec&' + 'q7do-wLx%E': 'LrH4?MS:.Y' + 'q8][)Ri}=q': 'lASVnaclh*' + 'q8fF$4,c6_': 'e54Fy{u]u+' + 'q:XyGf#>Wg': yhZg46q,-C + 'q=w[j~SL1' + 'q[S4`,F0D=': z9oZHcCKE6 + 'q[}7hk(+=e': 'K{nI7fB$d+$': 'HK:>.+2ex6' + 'r(h9&#wc~B': 'ejDP,^qvKr' + 'r/B|ra%L,.': 'LH}a8g>8j0' + 'r5C646H*+*': 'ujW*5ockuC' + 'r6BV%|..*5': 'k,(otxIVcI' + 'raI-$$|wT': 'LL=PU%A:N#' + 'r>g<|,FWi%': 'N2MVXSvID:' + 'r?E$4f-d0/': 'bIs.bxZR0(' + 'rPekx@n9:L': 'GgGoW+/m9v' + 'rSneU!3}bn': 'OAj=#W15kV' + 'r]*Ioa%$bK': 'M#.,MF3zu#' + 's#:aIK$9^x': 'vnS!8Q]FlM' + 's4E?ZF[E<>': 'G-yD_gaG`b' + 's9-GL*@AXD': 'tjvz_4.G7@' + 's<_WLSB3I/': 'tSLAu,1V>Q' + 's>f40rn,O]': 'Lva!X_tl`2' + 'sB7rZQsRRJP,/*W' + sM0GPt1HS1: iQm-U0VW2S + 'sRw-ik:I$v': 'd`_!}E{gW[' + 'sSW/?WMy17': 'hh/_8cG~di' + 'sT5=H|@KU|': 'N(rD|+f@[k' + sXzvT.g31p: 'tMIwged`Yl' + 'sY>@9w~i^p': 'r}O8m^>5CD' + 'skOM(?n$GG': 'Mu]P2-BY~V' + spKoFRM4if: 's+!2n<F]bu>P': KuoySbahQ- + 't2&ri|[Un:': Byx01EmNqQ + t3aZXtpNyi: 'l2*Cj7{@n2' + 't50y/[AEhF': 'o?c_P]7cRa' + 'tMMtBUpb$&': 'guaI|||b-?a@m': 'HF&vW)dD9Y' + upiX0g1quR: 'e@[V/X}*-w' + 'uv1fGXHN@)': 'QwC`JOy)f)' + 'u|I}oElaH)': 'uQ-90}yhTf' + 'vE[T$`n9T)': 'o(ubw#KC7V' + 'vKx<0Qa!aP': 'n|X/>eJYEp' + 'vNiD6A.v#s': 'd:Z2Wp!K#J' + 'va/ky=f5pK': 'i3Cz?V6y+J' + 'vf)G?[_?Pf': 'Nd05D{&,6r' + 'vgdj#X?8dB': 'yW)Se2ZZ0+' + 'vpOTK6tMr@': 'vc/xDNSP!Q' + 'vppEA|$@~g': 'H2VIy>Ka56' + 'vrn%j%6{nu': 'NY@cC.#2,D' + 'vwc3}.=Z#&': 'u79LUet]wo' + 'wa>{k!2cXc': 'JE12$)9ER@' + x2d4n4ADcP: 'F&7yB5tW/w' + 'xb&P*pH(Q': 'Q*1]Cvu?#a' + 'xqrJ^d[.(O': 'EezTS:.:Ou' + 'yX:tmrd5;]': 'L4WiVp>Un|' + 'z:*d{z(~V2': 'kBb;v^=11(' + 'zSc]-^U=0=': 'O?:STIyrxP' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: 84786477-e28a-490e-958d-7b35946d7b11 diff --git a/fixtures/ultimate-geography/overlays/languages/pt.yaml b/fixtures/ultimate-geography/overlays/languages/pt.yaml new file mode 100644 index 0000000..9663605 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/pt.yaml @@ -0,0 +1,872 @@ +id: overlay.translation.pt +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Existe uma disputa de nomenclatura entre os países limítrofes do mar, com a Coreia do Sul apoiando notavelmente o nome Mar do Leste.' + Abkhazia: 'Abecásia' + Abu Dhabi: Abu Dabi + Accra: Acra + Addis Ababa: Adis Abeba + Adriatic Sea: 'Mar Adriático' + Aegean Sea: Mar Egeu + Afghanistan: 'Afeganistão' + Africa: 'África' + Akrotiri and Dhekelia: 'Acrotíri e Deceleia' + Alaska: Alasca + Albania: 'Albânia' + Algeria: 'Argélia' + Algiers: Argel + Also known as Burma.: '' + Also known as Cabo Verde.: '' + Also known as Czechia.: '' + Also known as East Timor.: '' + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: '' + Also known as the Sea of Cortez.: 'Também conhecido como Mar de Cortez.' + 'Also spelled as Sana''a.': '' + American Samoa: Samoa Americana + Amman: 'Amã' + Amsterdam: 'Amsterdã (BR), Amsterdão (PT)' + 'Andorra (narrower, coat of arms with motto)': 'Andorra (mais estreita, brasão com lema)' + Andorra la Vella: Andorra-a-Velha + Anguilla: Anguila + Ankara: Ancara + Antarctica: 'Antártica' + Antigua and Barbuda: 'Antígua e Barbuda' + Arabian Sea: 'Mar Arábico' + Aral Sea: Mar de Aral + Arctic Ocean: 'Oceano Ártico' + Armenia: 'Armênia (BR), Arménia (PT)' + Ashgabat: Asgabate + Asia: 'Ásia' + 'Asunción': 'Assunção' + Athens: Atenas + Atlantic Ocean: 'Oceano Atlântico' + Australia: 'Austrália' + 'Australia (white stars, two more stars)': 'Austrália (estrelas brancas, duas estrelas a mais)' + Austria: 'Áustria' + 'Austria (brighter red, wider white band)': 'Áustria (vermelho mais brilhante, faixa branca mais larga)' + Autonomous community of Spain.: 'Comunidade autônoma da Espanha.' + Autonomous province of South Korea.: 'Província autônoma da Coreia do Sul.' + Autonomous region of Finland.: 'Região autônoma da Finlândia.' + Autonomous region of Italy.: 'Região autônoma da Itália.' + Autonomous region of Papua New Guinea.: 'Região autônoma de Papua-Nova Guiné.' + Autonomous region of Portugal.: + notes.note.azores.fields.field.country-info: 'Região autônoma de Portugal.' + notes.note.madeira.fields.field.country-info: 'Região Autônoma de Portugal.' + Azerbaijan: 'Azerbaijão' + Azores: 'Açores' + Baghdad: 'Bagdá (BR), Bagdade (PT)' + Bahrain: 'Barém' + 'Bahrain (narrower, fewer serrated edges, red)': 'Barém (mais estreita, menos bordas serrilhadas, vermelha)' + Baku: Bacu + Balkan Peninsula: 'Bálcãs' + Baltic Sea: 'Mar Báltico' + Bamako: Bamaco + Banda Sea: Mar de Banda + Bandar Seri Begawan: 'Bandar Seri Begauã' + Bangkok: 'Bancoque (BR), Banguecoque (PT)' + Bangladesh: Bangladexe + Barents Sea: Mar de Barents + Bay of Bengal: 'Baía de Bengala' + Bay of Biscay: Golfo da Biscaia + Beijing: Pequim + Beirut: Beirute + Belarus: 'Bielorrúsia' + Belfast: Belfaste + Belgium: 'Bélgica' + Belgrade: Belgrado + Belmopan: 'Belmopã' + Benin: Benim + Bering Strait: Estreito de Bering + Berlin: Berlim + Bermuda: Bermudas + Bern: Berna + Bhutan: 'Butão' + Bishkek: Bisqueque + Black Sea: Mar Negro + Bolivia: 'Bolívia' + 'Bolivia (coat of arms instead of star)': ' Bolívia (brasão de armas em vez de estrela)' + Bosnia and Herzegovina: 'Bósnia e Herzegovina' + Botswana: Botsuana + Brazil: Brasil + Brazzaville: Brazavile + British Virgin Islands: 'Ilhas Virgens Britânicas' + Brussels: Bruxelas + Bucharest: Bucareste + Budapest: Budapeste + Bulgaria: 'Bulgária' + Burkina Faso: Burquina Fasso + Cambodia: Camboja + Cameroon: 'Camarões' + 'Cameroon (green/red/yellow, yellow star)': 'Camarões (verde/vermelho/amarelo, estrela amarela)' + Canada: 'Canadá' + Canary Islands: 'Ilhas Canárias' + Canberra: Camberra + Cape Verde: Cabo Verde + Cardiff: Cardife + Caribbean Sea: 'Mar das Caraíbas' + Caspian Sea: 'Mar Cáspio' + Cayman Islands: 'Ilhas Caimã' + Celebes Sea: Mar de Celebes + Celtic Sea: Mar Celta + Central African Republic: 'República Centro-Africana' + Cetinje is an honorary capital.: 'Cetinje é uma capital honorária.' + Chad: Chade + 'Chad (slightly darker blue)': 'Chade (azul ligeiramente mais escuro)' + Charlotte Amalie: 'Carlota Amália' + 'Chișinău': Quixinau + City of San Marino: 'São Marinho' + Ciudad de la Paz: Cidade da Paz + Claimed and controlled: Reivindicado e controlado + Claimed but not controlled: 'Reivindicada, mas não controlada' + Colombia: 'Colômbia' + 'Colombia (no coat of arms)': 'Colômbia (sem brasão)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Colombo é muitas vezes referido como a capital, mas Seri Jaiavardenapura-Cota, um subúrbio de Colombo, é a capital legislativa oficial.' + Comoros: Comores + Conakry: Conacri + Constituent country in the Kingdom of Denmark.: 'País constituinte do Reino da Dinamarca.' + Constituent country of the Kingdom of the Netherlands.: 'País constituinte do Reino dos Países Baixos.' + Constituent country of the United Kingdom.: 'País constituinte do Reino Unido.' + Cook Islands: Ilhas Cook + Copenhagen: 'Copenhague (BR), Copenhaga (PT)' + Coral Sea: Mar de Coral + Corsica: 'Córsega' + Croatia: 'Croácia' + Crown dependency of the United Kingdom.: + notes.note.guernsey.fields.field.country-info: 'Dependência da coroa do Reino Unido.' + notes.note.isle-of-man.fields.field.country-info: Ilha de Man + notes.note.jersey.fields.field.country-info: 'Dependência da coroa do Reino Unido.' + 'Cuba (red triangle, blue stripes)': 'Cuba (triângulo vermelho, listras azuis)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (duas estrelas no canto superior esquerdo)' + Cyprus: Chipre + Czech Republic: 'Tchéquia (BR), Chéquia (PT)' + Dakar: Dacar + Damascus: Damasco + Dead Sea: Mar Morto + Democratic Republic of the Congo: 'República Democrática do Congo' + Denmark: Dinamarca + Denmark Strait: Estreito da Dinamarca + Dhaka: Daca + Dili: 'Díli' + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Disputada; reivindicada por Israel; Ramala é o centro administrativo.' + 'Disputed; claimed by Palestine.': 'Disputado; reivindicado pela Palestina.' + Djibouti: 'Djibuti (BR), Jibuti (PT)' + Doha: Doa + Dominican Republic: 'República Dominicana' + Dublin: Dublim + Dushanbe: 'Duxambé' + East China Sea: Mar da China Oriental + East Siberian Sea: Mar Siberiano Oriental + Ecuador: Equador + 'Ecuador (with coat of arms)': 'Equador (com brasão)' + Edinburgh: Edimburgo + Egypt: 'Egito (BR), Egipto (PT)' + 'Egypt (emblem instead of text), Yemen (no text)': 'Egito (emblema em vez de texto) e Iêmen (sem texto)' + 'Egypt (with emblem), Iraq (with text)': 'Egito (com emblema), e Iraque (com texto)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (brasão diferente, azul ligeiramente mais escuro)' + England: Inglaterra + English Channel: Canal da Mancha + Equatorial Guinea: 'Guiné Equatorial' + Eritrea: Eritreia + Estonia: 'Estônia (BR), Estónia (PT)' + Eswatini: 'Essuatíni' + Ethiopia: 'Etiópia' + Europe: Europa + European Union: 'União Europeia' + Falkland Islands: Ilhas Malvinas + Faroe Islands: 'Ilhas Faroé' + Federated States of Micronesia: 'Estados Federados da Micronésia' + Finland: 'Finlândia' + Formerly Zaire.: '' + Formerly known as Macedonia.: 'Anteriormente conhecida como Macedônia.' + France: 'França' + French Guiana: Guiana Francesa + French Polynesia: 'Polinésia Francesa' + Gabon: 'Gabão' + Georgia: 'Geórgia' + Germany: Alemanha + Ghana: Gana + 'Ghana (star instead of coat of arms)': 'Gana (estrela em vez de brasão)' + Gitega: Guitega + Greece: 'Grécia' + Greenland: 'Groenlândia' + Grenada: Granada + Guadeloupe: Guadalupe + Guatemala City: Cidade da Guatemala + Guernsey: Guernesei + Guinea: 'Guiné' + 'Guinea (green and red flipped, slightly darker green)': 'Guiné (verde e vermelho invertidos, verde ligeiramente mais escuro)' + Guinea-Bissau: 'Guiné-Bissau' + Gulf of Alaska: Golfo do Alasca + Gulf of California: 'Golfo da Califórnia' + Gulf of Carpentaria: 'Golfo de Carpentária' + Gulf of Guinea: 'Golfo da Guiné' + Gulf of Mexico: 'Golfo do México' + Gulf of Thailand: 'Golfo da Tailândia' + Guyana: Guiana + 'Hagåtña': Aganha + Hanoi: 'Hanói' + Hawaii: 'Havaí' + Helsinki: 'Helsinque (BR), Helsínquia (PT)' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Região histórica e cultural do norte da Europa, que inclui os países da Dinamarca, Noruega e Suécia, e às vezes Finlândia e Islândia.' + Hudson Bay: 'Baía de Hudson' + Hungary: Hungria + Iceland: 'Islândia' + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Islândia (fundo azul, cruz vermelha e branca) e Noruega (fundo vermelho, cruz azul e branca)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Islândia (fundo azul, cruz vermelha) e Ilhas Faroé (fundo branco, cruz vermelha e azul)' + Independent state claimed by Georgia.: 'Estado independente reivindicado pela Geórgia.' + Independent state claimed by Moldova.: 'Estado independente reivindicado pela Moldávia.' + Independent state claimed by Somalia.: 'Estado independente reivindicado pela Somália.' + India: 'Índia' + Indian Ocean: 'Oceano Índico' + Indonesia: 'Indonésia' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonésia (branco e vermelho invertidos, vermelho mais brilhante) e Mônaco (branco e vermelho invertidos, mais estreita)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonésia (mais larga, vermelho mais brilhante) e Polônia (mais larga, vermelho e branco invertidos)' + Iran: 'Irã (BR), Irão (PT)' + Iraq: Iraque + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Iraque (texto em vez de emblema) e Iêmen (sem emblema)' + Ireland: Irlanda + 'Ireland (orange and green flipped, wider)': 'Irlanda (laranja e verde invertidos, mais larga)' + Islamabad: Islamabade + Island of Indonesia.: 'Ilha da Indonésia.' + Isle of Man: Ilha de Man + Italy: 'Itália' + Ivory Coast: Costa do Marfim + 'Ivory Coast (green and orange flipped, narrower)': 'Costa do Marfim (verde e laranja invertidos, mais estreita)' + Jakarta: Jacarta + Japan: 'Japão' + Jersey: 'Jérsia' + Jerusalem: 'Jerusalém' + Jordan: 'Jordânia' + Kabul: Cabul + Kaliningrad Oblast: Caliningrado + Kampala: Campala + Kathmandu: Catmandu + Kazakhstan: 'Cazaquistão' + Kenya: 'Quênia (BR), Quénia (PT)' + Khartoum: Cartum + Kigali: Quigali + Kinshasa: Quinxassa + Kiribati: Quiribati + Known as Nur-Sultan between 2019 and 2022: 'Conhecida como Nur-Sultã entre 2019 e 2022' + Known as Swaziland until 2018.: 'Conhecido como Suazilândia até 2018.' + Kuala Lumpur: Quala Lumpur + Kuwait City: Kuwait + Kyiv: Quieve + Kyrgyzstan: 'Quirguistão' + Laayoune: Laiune + Labrador Sea: Mar de Labrador + Latvia: 'Letônia (BR), Letónia (PT)' + 'Latvia (darker red, narrower white band)': 'Letônia (vermelho mais escuro, faixa branca mais estreita)' + Lebanon: 'Líbano' + Lesotho: Lesoto + Liberia: 'Libéria' + Libreville: Librevile + Libya: 'Líbia' + Lilongwe: 'Lilongué' + Lisbon: Lisboa + Lithuania: 'Lituânia' + Ljubljana: Liubliana + London: Londres + Lusaka: Lusaca + Luxembourg: Luxemburgo + 'Luxembourg (lighter blue)': 'do Luxemburgo (azul mais claro)' + Luxembourg City: Luxemburgo + Madrid: 'Madri (BR), Madrid (PT)' + Malawi: 'Maláui' + Malaysia: 'Malásia' + Maldives: Maldivas + 'Mali (red and green flipped, slightly brighter green)': 'Mali (vermelho e verde invertidos, verde ligeiramente mais brilhante)' + Managua: 'Manágua' + Mariehamn: Marianhamina + Marshall Islands: Ilhas Marshall + Martinique: Martinica + Mauritania: 'Mauritânia' + Mauritius: 'Maurício (BR), Maurícia (PT)' + Mayotte: Maiote + Mbabane: Mebabane + Mediterranean Sea: 'Mar Mediterrâneo' + Melanesia: 'Melanésia' + Mexico: 'México' + Mexico City: 'Cidade do México' + Micronesia: 'Micronésia' + Minsk: Minsque + Mogadishu: 'Mogadíscio' + Moldova: 'Moldávia' + 'Moldova (wider, coat of arms with eagle)': 'Moldávia (mais larga, brasão com águia)' + Monaco: 'Mônaco (BR), Mónaco (PT)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Mônaco (mais estreita, vermelho mais escuro) e Polônia (vermelho e branco invertidos, vermelho mais escuro)' + Mongolia: 'Mongólia' + Monrovia: 'Monróvia' + Montevideo: 'Montevidéu (BR), Montevideu (PT)' + Morocco: Marrocos + Moscow: 'Moscou (BR), Moscovo (PT)' + Mozambique: 'Moçambique' + Muscat: Mascate + Myanmar: Mianmar + 'N''Djamena': Jamena + Nairobi: 'Nairóbi' + Namibia: 'Namíbia' + 'Nauru (single star below yellow band)': 'Nauru (estrela única abaixo da faixa amarela)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru não tem capital oficial; o distrito de Iarém é apenas considerada a capital.' + Naypyidaw: 'Nepiedó' + Netherlands: 'Países Baixos' + 'Netherlands (darker blue)': 'Países Baixos (azul mais escuro)' + New Caledonia: 'Nova Caledônia' + New Delhi: Nova Deli + New Zealand: 'Nova Zelândia' + 'New Zealand (red stars, two fewer stars)': 'Nova Zelândia (estrelas vermelhas, duas estrelas a menos)' + Niamey: Niamei + Nicaragua: 'Nicarágua' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicarágua (brasão diferente, azul ligeiramente mais claro)' + Nicosia: 'Nicósia' + Niger: 'Níger' + Nigeria: 'Nigéria' + Niue: 'Niuê' + North America: 'América do Norte' + North Korea: Coreia do Norte + North Macedonia: 'Macedônia do Norte (BR), Macedónia do Norte (PT)' + North Nicosia: 'Nicósia do Norte' + North Sea: Mar do Norte + Northern Cyprus: Chipre do Norte + Northern Ireland: Irlanda do Norte + Northern Mariana Islands: Ilhas Marianas do Norte + Norway: Noruega + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Noruega (fundo vermelho, cruz azul) e Ilhas Faroé (fundo branco, cruz vermelha e azul)' + Norwegian Sea: Mar da Noruega + Not a sovereign country: 'Não é um Estado soberano' + Nouakchott: Nuaquexote + 'Nouméa': 'Numeá' + 'Nukuʻalofa': Nucualofa + Nuuk: Nuque + 'Oblast (administrative region) of the Russian Federation.': 'Oblast (região administrativa) da Federação Russa.' + Official capital was moved from Bujumbura to Gitega in 2019.: A capital oficial foi transferida de Bujumbura para Guitega em 2019. + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: A capital oficial foi transferida de Malabo para Cidade da Paz em 2026. + 'Officially Côte d''Ivoire.': 'Oficialmente Côte d''Ivoire.' + Officially Luxembourg.: '' + Oman: 'Omã (BR), Omão (PT)' + Oranjestad: Oranjestade + Ottawa: Otava + Ouagadougou: Uagadugu + Overseas department of France.: 'Departamento ultramarino da França.' + Overseas territory of France.: + notes.note.french-polynesia.fields.field.country-info: 'Território ultramarino da França.' + notes.note.new-caledonia.fields.field.country-info: 'Território ultramarino da França.' + notes.note.saint-martin.fields.field.country-info: 'País constituinte do Reino dos Países Baixos.' + notes.note.wallis-and-futuna.fields.field.country-info: 'Território ultramarino da França.' + Overseas territory of the United Kingdom.: 'Território ultramarino do Reino Unido.' + Pacific Ocean: 'Oceano Pacífico' + Pakistan: 'Paquistão' + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (preto/branco/verde, seta vermelha)' + 'Palestine (no symbol)': 'Palestina (sem símbolo)' + Palikir: Paliquir + Panama: 'Panamá' + Panama City: 'Cidade do Panamá' + Papeete: Papete + Papua New Guinea: 'Papua-Nova Guiné' + Paraguay: Paraguai + Partially recognised state claimed by China.: Estado parcialmente reconhecido reivindicado pela China. + Partially recognised state claimed by Morocco. Also known as Western Sahara.: Estado parcialmente reconhecido reivindicado por Marrocos. + Partially recognised state claimed by Serbia.: 'Estado parcialmente reconhecido reivindicado pela Sérvia.' + Persian Gulf: 'Golfo Pérsico' + Philippine Sea: Mar das Filipinas + Philippines: Filipinas + Phnom Penh: Penome Pene + Poland: 'Polônia (BR), Polónia (PT)' + Polynesia: 'Polinésia' + Port Louis: 'Porto Luís' + Port Moresby: Porto Moresby + Port Vila: Porto Vila + Port of Spain: Porto da Espanha + Port-au-Prince: 'Porto Príncipe' + Porto-Novo: Porto Novo + Prague: Praga + Pretoria, Cape Town, Bloemfontein: 'Pretória, Cidade do Cabo, Blumefontaina' + Puerto Rico: Porto Rico + 'Puerto Rico (blue triangle, red stripes)': 'Porto Rico (triângulo azul, listras vermelhas)' + Pyongyang: Pionguiangue + Qatar: Catar + 'Qatar (wider, more serrated edges, maroon)': 'Catar (mais larga, bordas serrilhadas, marrom)' + Rabat: Rabate + Red Sea: Mar Vermelho + Region of France.: 'Região da França.' + Republic of the Congo: Congo + 'Reykjavík': Reiquiavique + Riyadh: Riade + Romania: 'Romênia (BR), Roménia (PT)' + 'Romania (slightly lighter blue)': 'Romênia (azul ligeiramente mais claro)' + Rome: Roma + Russia: 'Rússia' + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Rússia (sem brasão) e Eslovênia (brasão mais largo e menor)' + Rwanda: Ruanda + 'Réunion': 'Reunião' + Sahrawi Arab Democratic Republic: 'República Árabe Saaraui Democrática (RASD)' + Saint Kitts and Nevis: 'São Cristóvão e Névis' + Saint Lucia: 'Santa Lúcia' + Saint Martin: 'São Martinho (França)' + Saint Vincent and the Grenadines: 'São Vicente e Granadinas' + 'San José': 'São José' + San Juan: 'São João' + San Marino: 'São Marinho' + San Salvador: 'São Salvador' + Sanaa: 'Saná' + Santo Domingo: 'São Domingos' + Sardinia: Sardenha + Saudi Arabia: 'Arábia Saudita' + Scandinavia: 'Escandinávia' + Scotland: 'Escócia' + Sea of Galilee: Mar da Galileia + Sea of Japan: 'Mar do Japão' + Sea of Okhotsk: Mar de Okhotsk + Semi-autonomous region of Tanzania.: 'Região semi-autônoma da Tanzânia.' + 'Senegal (green/yellow/red, green star)': 'Senegal (verde/amarelo/vermelho, estrela verde)' + Seoul: Seul + Serbia: 'Sérvia' + Seychelles: Seicheles + Sicily: 'Sicília' + Sierra Leone: Serra Leoa + Singapore: Singapura + Sint Maarten: 'São Martinho (Países Baixos)' + Skopje: 'Escópia' + Slovakia: 'Eslováquia' + 'Slovakia (narrower, bigger coat of arms)': 'Eslováquia (mais estreita, brasão maior)' + 'Slovakia (with coat of arms)': 'Eslováquia (com brasão)' + Slovenia: 'Eslovênia (BR), Eslovénia (PT)' + Sofia: 'Sófia' + Solomon Islands: 'Ilhas Salomão' + Somalia: 'Somália' + Somaliland: 'Somalilândia' + South Africa: 'África do Sul' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'A África do Sul não tem capital legalmente definida: os ramos do governo estão divididos em três cidades: Pretória (executivo), Cidade do Cabo (legislativo) e Blumefontaina (judicial).' + South America: 'América do Sul' + South China Sea: Mar da China Meridional + South Korea: Coreia do Sul + South Ossetia: 'Ossétia do Sul' + South Sudan: 'Sudão do Sul' + South Tarawa: Taraua do Sul + Southern Ocean: 'Oceano Antártico' + Sovereign country: Estado soberano + Spain: Espanha + Special Administrative Region of China.: 'Região Administrativa Especial da China.' + Sri Jayawardenepura Kotte: Seri Jaiavardenapura-Cota + 'St. George''s': 'São Jorge' + 'St. John''s': 'São João' + State of the United States.: Estado dos Estados Unidos. + State recognised only by Turkey and claimed by Cyprus.: Estado reconhecido apenas pela Turquia e reivindicado por Chipre. + Stockholm: Estocolmo + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Sub-região da Oceania que compreende milhares de pequenas ilhas no Oceano Pacífico central e meridional.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Sub-região da Oceania que compreende milhares de pequenas ilhas no oeste do Oceano Pacífico.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Sub-região da Oceania que inclui Vanuatu, Ilhas Salomão, Fiji e Papua-Nova Guiné.' + Sudan: 'Sudão' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudão (vermelho/branco/preto, seta verde) e República Árabe Saaraui Democrática (com estrela e crescente)' + Sukhumi: Sucumi + Svalbard: Esvalbarda + Sweden: 'Suécia' + Switzerland: 'Suíca' + 'Switzerland has no official capital; Bern is the de facto capital.': 'A Suíça não tem capital oficial; Berna é apenas considerada a capital.' + Syria: 'Síria' + 'São Tomé and Príncipe': 'São Tomé e Príncipe' + Taipei: 'Taipé' + Tajikistan: 'Tajiquistão' + Tallinn: Talim + Tanzania: 'Tanzânia' + Tashkent: Tasquente + Tasman Sea: Mar de Tasman + Tbilisi: Tiblissi + Tehran: 'Teerão' + Thailand: 'Tailândia' + The Bahamas: Bahamas + The Gambia: 'Gâmbia' + Thimphu: Timbu + Timor Sea: Mar de Timor + Tiraspol: 'Tiráspol' + Tokyo: 'Tóquio' + Transnistria: 'Transnístria' + Trinidad and Tobago: Trindade e Tobago + Tripoli: 'Trípoli' + Tskhinvali: 'Tsequinváli' + Tunis: Tunes + Tunisia: 'Tunísia' + Turkey: Turquia + Turkmenistan: 'Turcomenistão' + Turks and Caicos Islands: Turcas e Caicos + Ukraine: 'Ucrânia' + Ulaanbaatar: 'Ulã Bator' + Unincorporated internal area of Norway.: 'Área interna não incorporada da Noruega.' + Unincorporated territory of the United States.: 'Território não incorporado dos Estados Unidos.' + United Arab Emirates: 'Emirados Árabes Unidos' + United Kingdom: Reino Unido + United States Virgin Islands: Ilhas Virgens Americanas + United States of America: Estados Unidos + Uruguay: Uruguai + Uzbekistan: 'Uzbequistão' + Valletta: Valeta + Vatican City: Vaticano + Victoria: 'Vitória' + Vienna: Viena + Vientiane: Vienciana + Vietnam: 'Vietnã (BR), Vietname (PT)' + Wales: 'País de Gales' + Wallis and Futuna: Wallis e Futuna + Warsaw: 'Varsóvia' + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Embora Amsterdã seja a capital oficial, Haia é a sede dos ramos executivo e legislativo.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Embora Dodoma seja a capital oficial, Dar es Salã é a sede do governo.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Embora Laiune, também conhecida como El Aiune, seja a capital declarada, Tifariti é a sede do governo.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Embora Mebabane seja a capital oficial e executiva, Lobamba é a capital tradicional, espiritual e legislativa.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Embora Porto Novo seja capital oficial, Cotonu é a sede do governo.' + While Sucre is the constitutional capital, La Paz is the seat of government.: 'Embora Sucre seja a capital constitucional, La Paz é a sede do governo.' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Embora Iamussucro seja a capital oficial, Abidjã é a sede do governo.' + White Sea: Mar Branco + Windhoek: Vinduque + World region covering the Australian continent and most of the islands in the Pacific Ocean.: '' + Yamoussoukro: Iamussucro + 'Yaoundé': 'Iaundé' + Yaren: 'Iarém' + Yellow Sea: Mar Amarelo + Yemen: 'Iêmen (BR), Iémen (PT)' + Yerevan: 'Erevã' + Zagreb: Zagrebe + Zambia: 'Zâmbia' + Zimbabwe: 'Zimbábue' + 'Åland Islands': Alanda + additions: + notes.note.antigua-and-barbuda.fields.field.capital-hint: Estado soberano + notes.note.netherlands.fields.field.country-info: Conhecidos informalmente como Holanda. + notes.note.puerto-rico.fields.field.capital-hint: 'Não é um Estado soberano' + variables: + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Dica: {{Capital hint}}' + label.flag: + Flag: Bandeira + label.location: + Location: 'Localização' + note-type.name: + Ultimate Geography: 'Ultimate Geography [PT]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Bandeira parecida com: {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'tEG*ja>]Nv' + '5JJ2]i)p4': 'C]:=;Tc=R' + 'G/^_##fcPN': 'z!(JMxmH=r' + 'Gn;q2Y)Xe4': 'v]Gu}_Sgmm' + 'H*c[ML+`s=': 'H|DRE(4*?I' + 'He!e=h?_2V': 'L~fP+M7' + LPF4AfaHKR: 'b#XvD_w?e<' + 'Lol+XIvY21': 'L0+1:;EPg/' + 'M$bc0[jYf0': 'k[}Db7_UdK' + 'O~pXkAbd3': 'w@=dG}+guF' + 'QSq%~rFaP-': 'FNcuka+yo(' + 'Qlib4)q2Hk': 'Q0g%)%UY`W' + 'SlOEXfq#|': 'B|Vp[->]3b' + 'W5?6yQZD2': bm_rpQxEpV + '_]DH+g9!$': 'OJ4}BH2QfK' + 'b@M[XTc?}5': 'ef`]u!|V{`' + 'bD?R}i8_[c': 'zCAd}@3:9U' + 'bW)ch:vs1P': 'O0hlf9Z-!z' + 'b[(2`4t&?J': 'leBrcK+h^' + 'b^Try&me1A': M4ikwvZDb7 + 'b`5Q_Z=HQT': 'odvY;N=cs5' + 'bd~Ght5tLR': 'fV^xZWq#U8' + 'bk2ByZIeU+': 'v%N%Db' + 'c^,~EC6Pb2': 'Gjci[`.B}=' + 'ck!6;xK$/^': 'p4FOjAZZk@' + 'cv+Nq9:aXV1}xL2' + 'dUZ^=SE5BG': pZ,nCA,.0R + 'dWA49p}F{k': 'c+r?+))vw9' + 'dY96`C-f(_': 'wDA~K0^p(*' + 'd``B*6:eCx': 'i@oGe!DhQ3' + 'dbP/Cj}sB+': 'JZR=,>T#/~' + 'de=9c:,@j9': 'e_W9-v{whs' + 'dlU7VG|3Gd': 'Oz!#K-Ae}V' + 'd}R:qHj1,7': 'iS2|sUiSh`' + 'e!KSebP}pt': 'C@O711uUdL' + 'e+/O]%*qfk': 'O>f7b9D1hF' + 'e.t$Vi5,>f': 'c#3w!!.a^C' + 'e42BpV,P}C': 'jJMevp,*!q' + 'e5P*D#I{m{': 'fXz}(XSfFj' + 'eHH?U': 'E_&3V&K[)_' + 'euW0jm=wfR': 'h+>APLLb!{' + 'e{e8vi@^PY': 'I1w1@S?{u2' + 'f#4-L[mp5*': 'sktKlO<#lL' + 'f*2@qiHh_1': 'Bmj!9P&|{=' + 'f*LO~>!0PN': 'kG0ycx1-&@' + 'f21=-A~W;~': 'F9/|i`ALwZ' + 'f>c7x2E]Q6': 'w:J$-ahRdF' + 'fRSM{K+;[w': 'c_|S!EHTyr' + 'fT!w8R#>dg': 'M.$!dle5NI' + 'fV`&p81%d(': 'i,9aCetA#N' + fkq7lhLkdH: 'lwH=^9v6=A' + 'fxAN),FAc$': 'hD@WEVZn_<' + 'g-]bb&[.E!': 'sf|,28pc/9' + 'g9v~y/;=z(': 'o{L!(ulPuC' + 'g<[f89~U)A': 'Db5g|/x_nA' + 'gBCR?!O*;E': 'bS{]Ow*V`E' + 'gB|K:+r?1V': EsE.-qmRj + 'gDsLPj(9S#': 'I7oz98q8(-' + 'gFOojQO(MH': 'w=(azb)1FN' + 'gG6SWfM>H0': 'rJI8->gW%l' + 'gRVZ]qJ#>O': 'N*r4!:+/}l' + 'gYIoR`|AxW': 'ctod6{!$KH' + 'gePC:Xt_nW': 'GUDe$&2Ul)' + 'gp[h@]y13l': 'GmXe5(x+Sd' + 'gsS{jR,]|Q': 'hK{#z+aR76' + 'gx:;za!?C9': 'KOyffK#a5H' + 'gz9${p}er*': 'h{SZE<_@pF' + 'g~%pF`(x{u': 'hl9LK`>En1' + 'h&`>n98~V1': 'lj(hdT=ie~' + 'h9Jr(H[=1~': 'msxAK,LCh{' + 'h?a*UKfT_M': 'j?GNhOWK$8' + 'hE5j*e,Q?[': 'Hdh?z^QMqR' + 'hNE9}>;Q&-': 'KgOKBn.!r{' + 'hXNTVF<:SH': 'Dc^-[NKeM9' + 'hZ/V/D&rO`': 'wXo*CTq02' + 'hrI_uiW>E]': 'e;|GubFA|k' + 'h}lnSN_f$_': 'NGbgN#o/Ie' + 'h~5xz+=ke~': 'x1O4C)?pj1' + 'i)]+09JUR&': 'm~e0bQg80^' + 'i2]v|D3@:j': 'w@c5memCCS' + 'iB-8Br~onq': 'HJN6(i^z~8' + 'iIS#Nw]tr]': 'i]HndhS#jS' + 'iR;Um(L/~c=': 'Qm#z-X2)Eh' + 'i^J8&$(1]|': 'phTnzN7*fo' + 'ia8as9sCY|': 'ryXF~Goixn' + 'igiK,ff[eO': 'AySdJa*$Gr' + 'ijP>aJBT!9': 'tf~~,M|yc&' + 'ilGtw#=asM': 'MGy&<;.7Fv' + 'j%*8gN)q>d': 'Q-U(os@S&S' + 'j)i)pB*HJ,': 'lBV|L_Y=Rb' + 'j2IQ=f=w3@': 'H^&r5vlQP5' + j7eEZkzsCZ: 'Pvf0f_)tm@' + 'j9:K,~DQ2v': 'DuTF$#n0`&' + 'jg|}': 'rt/cNV?%Os' + 'jCd]`-=k,:': 'qb(2zU!2v_' + 'jDA-V?/hVj': 'sSCPjs@7rI' + jI9P-f6r3M: 'vP%fqrhm.3' + 'jKIDq|}J&c': 'D9X*P<0(5R' + 'jKO,9y0M;#': 'EIju~|9Wsy' + 'jN^Vc%9OQ5': 'ld@6qzuh4:' + 'jN|NAUP*h}': 'Bs.>u/)4+/' + 'jTNoKo}Bu+': 'iM3lUK!|>*' + 'jX=#G8wu#(': 'u3Kaf5B|v`' + 'jYz[ibrr9m': JQ.625BEIH + 'jcu!gLw/&r': 'wk!g#_2-WE' + 'jrM28*HbyG': 't%glMxI7,6' + 'j|z@PMgdx,': 'Ooz-xs3u9<' + 'k$1_yaF?9#': 'hdIq-F%tt3' + 'k/u1:B%DJH': 'db,aa$e4w1' + 'k0{O[6l]bH': 'KsSd*uaf=t' + 'k1]FH+s8j@': 'tI[-w:S[nz' + 'k9E.p^iv~[H' + 'k[)#L4.[v3': 'gaM:=v}hwy' + 'khB~(m^@Z5': 'AofLiY]`Ti' + 'kkbYQXw5b;': 'DK#M)PH#C[' + 'koI(}OyJ:@': 'L`GYI/ekhp' + 'kopwzzB``A': 'oxXKJj(4f:' + 'k}4/oj#$~s': 'hq<./^6hM?' + 'k}A9]O:0xw': 'f*6$32AnHC' + 'l$2O|9w`F~': 'B|UQP+^>3F' + 'l$zM~ihPFE': 'LtQnEnv]mQ' + 'lI)Ah/E': 'w?EtZ3tDY9' + 'l*j*EE:!Y?': 'ndPX6tuc3[' + 'l+>Ki!j#v+': 'Q9>YoEa|XH' + 'l3Ly8PBxt(': 'd2v?T?w[3T' + 'lAxH.CfJB@': 'yOTi(~YAk6' + 'lH^2D]w;[H': p1SyIlllJ + 'lU@C3z' + 'm?}%V6s8]*': 'zclXW*ihxo' + mJH4kwPF.-: 'IJ3K*pLd9J' + 'm[96fa+HDS': 'ly9aPK_@5B' + mbGXc0y3Pe: 'ly*3s`V(Xf' + 'mbz=7n[0v9': 'O:=Be7T|g>': 'k$9ns[=Fo{' + 'mz[>>AxIr4': 'cIm?2955ae' + 'm{noqE29%I': 'su=H/7(%]k' + 'm{oM,jr>C#': 'Kry$&wZmW`' + 'n&>e`2FO/S': 'B[J}Z4r84@' + 'n+t>2`:;P/': 'BhD{XddD~W' + 'n:7&>YT5A=': 'i6aNJwa%b_' + 'nIfRn/yz': 'dH*XdVw~QD' + 'oa87&PB&Bm': 'e(pOqB.N-d' + 'onMKID2d;D': 'w/S^-U}^gl' + 'o~}KVA:VbI': p.bqdPDaHE + 'p#R$-mG/L': 'HhAtBQ470': 'Gj;ANGAUma' + 'p:SPZ:e/*[': 'b,`C.1o0K]' + 'pDDrFhd%u': 'Q{)8B-]wUq' + 'pYRw[=6;JI': 'v)_Oh`bF,*' + 'p]t8:Tka7q': ',yh{iX8m{' + pkj3RPBUrF: 's@13Ith.ZH' + 'pwwZu{7f~(': 'd}3y)SgyoJ' + 'pz(G*g': 'C]:V&kby#o' + 'q2#Yv[O0:|': 'JIBCWg': 'Lrgy`zP-}Q' + 'q=w[fB$d+$': 'N]c2]O;WnL' + 'r(h9&#wc~B': 'A}C>b%U#S}' + 'r/B|ra%L,.': 'ODBApmZ]n:' + 'r5C646H*+*': 'nzv6%{z8e8' + 'r6BV%|..*5': 'Q%iy;<@JH&' + 'raI-$$|wT': 'I|&C8!2g<|,FWi%': 'BvP+F:QCTH' + 'r?E$4f-d0/': 'Lkpnrm+}N*' + 'rPekx@n9:L': 't]9y*|+tZ' + 'rSneU!3}bn': 'eprA@[$2!T' + 'r]*Ioa%$bK': 'CMz9QB%2X^' + 's#:aIK$9^x': 'L+`m,K,TjN' + 's4E?ZF[E<>': 'M@Uja@7DwC' + 's9-GL*@AXD': 'oV{vjm9,oV' + 's<_WLSB3I/': 'b#,3j{C;Ii' + 's>f40rn,O]': 't1Nxa2/6]g' + 'sB7rZQsR@9w~i^p': 'f*5blT|2d0' + 'skOM(?n$GG': 'zph}.HR/d/' + spKoFRM4if: 'B`AJ:-Smw.' + 'ssbyA9}]QP': 'B|dJ6T]U$e' + 'stRa/S(0$F': 'gm+.@W/3UE' + 'sxOMs+bE8@': 'pF]bu>P': 'w|**t4}*5:' + 't2&ri|[Un:': 't|n7?F8IO|' + t3aZXtpNyi: 'voNLUF#rIP' + 't50y/[AEhF': 'H>U*FzuU!q' + 'tMMtBUpb$&': 'G%yn8F/|RL' + 'tTnCoO/*tk': 'KIg(;uu).*' + 't_h*fbN#:(': 'I[EaKvyq9C' + 't`cagaWApo': 'y#6U?b$7aD' + 'tcneuK7v%`': 'hsYz#zh=Oy' + tfCY4j6KEZ: 'FZZeug$TYn' + 'tj&bK}%P!F': 'IGD[r5m}DP' + 'twrhzJ[k:Z': 'pa6]%Am.&V' + 't|h3|;E4g{': 's1i:%+hNpC' + 'u-`cspdp)D': 'c!=R$K79Ci' + 'u4^lH.xem5': 'cSW!~j9W8E' + 'u;|vR#mVMW': 'qw`r9pt!l-' + 'uH*Q(mcRt5': 'DEMGqHWrE%' + 'uRo^w#p5Be': 'Nj!o5F[Y={' + 'uc61ToT%gt': 'L>wqh,KqZ.' + 'ui3>b-?a@m': 'yC[`HZT>8~' + upiX0g1quR: PiHl6BLN4X + 'uv1fGXHN@)': 'tU-h*1K,YQ3`' + 'va/ky=f5pK': 'yS!)#wTR3,' + 'vf)G?[_?Pf': 'nhJb958%1' + 'vgdj#X?8dB': 'uDFLbP[Qng' + 'vpOTK6tMr@': r79P4ThUyr + 'vppEA|$@~g': 'M2F[Y-o77g' + 'vrn%j%6{nu': 'K]w/Sxim{^' + 'vwc3}.=Z#&': 'fLt!@-+AbO' + 'wa>{k!2cXc': 'PdPaEws@.2' + x2d4n4ADcP: 'm4fmq/A-=@' + 'xb&P*pH(Q': 'fAFgA,TS$w' + 'xqrJ^d[.(O': 'O]6^$Y;9N' + 'yX:tmrd5;]': 'k.5}wu' + 'EWXNcl' + 'SlOEXfq#|': 'Aa(5k`u_H.' + 'W5?6yQZD2': 'N}PXRbob?8' + '_]DH+g9!$': 'JS=q2$2L3]' + 'b@M[XTc?}5': 'L?:>A,WJLB' + 'bD?R}i8_[c': 'k.2O4>+A!V' + 'bW)ch:vs1P': 'vAR>ZSEGi/' + 'b[(2`4t&?J': 'w^9U1RGI}b' + 'b^Try&me1A': 'zR_=AM#e1.' + 'b`5Q_Z=HQT': N.on85Va4D + 'bd~Ght5tLR': 'hb~_{qK>D=' + 'bk2ByZIeU+': 'DNb~sC&J5%' + 'bnEK#)[;qT': 'A)h(R4;N+m' + 'c(,2{yXI#E': 'ERCyEa-V]o' + 'c,5(mUGzYT': 'N&H{fk[c3L' + 'cAlyc~G.y%': 'dQpb|V+vI#' + 'cEO^y)Md[G': 'ym)YXCoojW' + 'cL?8!xP#Wj': 'K{N|AkKxc*' + 'cPO|Cru8n<': 'b9(smVRG!j' + 'cV4idO^r8W': 'emf@Kw7U_W' + 'cYKvF#ptF7': 'A,M2k`~Wd[' + 'c^,~EC6Pb2': 'd`zDA9y~gZ' + 'ck!6;xK$/^': 'i2(em&ob8T' + 'cv+Nq9:a63YEjQ' + 'e!KSebP}pt': 'k}F}r?|uyT' + 'e+/O]%*qfk': 'Jm&ky}FZz?' + 'e.t$Vi5,>f': 'pcq,]2w8O[' + 'e42BpV,P}C': 'w_RRz=p' + 'e5P*D#I{m{': pMLZoOPY5w + 'eHH?U': 'Btv`iTH0LW' + 'euW0jm=wfR': 'CeFx+%T#{9' + 'e{e8vi@^PY': 'il|4oTIh7t' + 'f#4-L[mp5*': 'F9=}W4o(m(' + 'f*2@qiHh_1': 'O6aT:6ldHZ' + 'f*LO~>!0PN': 't2BlE6w@U0' + 'f21=-A~W;~': 'Q-1OO^)n>C' + 'f>c7x2E]Q6': 'wz:QAN8%&+' + 'fRSM{K+;[w': de.tDgRhFi + 'fT!w8R#>dg': 'EMPCtOx;Rj' + 'fV`&p81%d(': 'JqECz@.%4i' + fkq7lhLkdH: 'y!Xu5N$EBT' + 'fxAN),FAc$': Et,iKxkLUP + 'g-]bb&[.E!': pQlr/JYlFs + 'g9v~y/;=z(': 'p*/Yx`XElV' + 'g<[f89~U)A': 'H4;V}Gjc$p' + 'gBCR?!O*;E': 'H+Rb-3u-sn' + 'gB|K:+r?1V': 'gVIrkH[g_z' + 'gDsLPj(9S#': 'u~.H0': 'yXRb0bi*D.' + 'gRVZ]qJ#>O': 'f-#kZAo/ON' + 'gYIoR`|AxW': 'xD;}}nO&|5' + 'gePC:Xt_nW': 'jOH{z(39>-i<' + 'gx:;za!?C9': 'j[3-~lc=uu' + 'gz9${p}er*': 'PiubQg^Ss5' + 'g~%pF`(x{u': 'yJuwsoHp0*' + 'h&`>n98~V1': 'xddNP~dyT_' + 'h9Jr(H[=1~': 'Q]#{ocTF.T' + 'h?a*UKfT_M': 'in7S|U&pW]' + 'hE5j*e,Q?[': 'r[B4}qKEKl' + 'hNE9}>;Q&-': 'ry#$]bo`k}' + 'hXNTVF<:SH': pR,WF6cJZz + 'hZ/V/D&rO`': 'ewx9/IV5o%' + 'h_9=mkZXAH': 'w,s~/*1#Rg' + hoHandbYAy: 'cD]Q9Pd0ZO' + 'hrI_uiW>E]': 'A#PXVu3{OG' + 'h}lnSN_f$_': 'J4U9*&@eJO' + 'h~5xz+=ke~': bms0vPoZ2i + 'i)]+09JUR&': 'g?oII<6[ZM' + 'i2]v|D3@:j': 'GvL1Tm(L/~c=': 'uB1!(|)h6G' + 'i^J8&$(1]|': 'JumHq-w(@9' + 'ia8as9sCY|': 'pwG/f8TA%+' + 'igiK,ff[eO': 'G>bi?;%r/h' + 'ijP>aJBT!9': 'cdC>T>VY4t' + 'ilGtw#=asM': 'jI?2qf!yDb' + 'j%*8gN)q>d': 'LN}*YP~{-L' + 'j)i)pB*HJ,': 'M_^g|}': 'I`2u4=)$rI' + 'jCd]`-=k,:': 'EEpm1lUrG]' + 'jDA-V?/hVj': 'fn`TSL4H+K' + jI9P-f6r3M: 'F,ZHHr/u@K' + 'jKIDq|}J&c': dzu,L2RTO6 + 'jKO,9y0M;#': 'G:w,Y[(aW1' + 'jN^Vc%9OQ5': 'CP<0cpy;,I' + 'jN|NAUP*h}': 'hE`:7lrR4P' + 'jTNoKo}Bu+': 'pJE)w+ZJK%' + 'jX=#G8wu#(': 'Dy8wkcY4!S' + 'jYz[ibrr9m': 'G:i:X1/~' + 'k}A9]O:0xw': 'f6}E@C#_3N' + 'l$2O|9w`F~': 'B}gk}F/NJD' + 'l$zM~ihPFE': 'kOtGr;P3}k' + 'lI)Ah/E': 'pHNtJdHo[e' + 'l*j*EE:!Y?': 'y-F:kD#HUc' + 'l+>Ki!j#v+': gWiiNVbwTm + 'l3Ly8PBxt(': 'FeM1Z:8]S-' + 'lAxH.CfJB@': 'xxk(Bq-Z`<' + 'lH^2D]w;[H': 'qjeAhS!PI:' + 'lU@C3DB_&l`' + 'mi+kf0LTW.': 'q?uiYsL{dZ' + 'mk1VltJl<]': 'E9ORP)qdh9' + 'mk20`,N=`=': 'Fvsev/q[3n' + 'mkgcU7[bA%': 'b5~CCSrPP.' + 'mq)8`hi{hb': 'o.R(%XRfF<' + 'mtd;>7T|g>': 'eTUA#N*+_%' + 'mz[>>AxIr4': '?R^lQY*D*' + 'm{noqE29%I': 'O>:Y)[/m@5' + 'm{oM,jr>C#': 'd;7/rK:;@r' + 'n&>e`2FO/S': 'E_Y_gka2`:;P/': 'yT?~lnZ:gX' + 'n:7&>YT5A=': 'I!PNDUSvR|' + 'nIfRn/yz': 'M~5K,Lq-LX' + 'oa87&PB&Bm': 'dl?k9`s(|K' + 'onMKID2d;D': 'gCZT`M|9xu' + 'o~}KVA:VbI': 'AFNn,$>?6v' + 'p#R$-mG/L': 'hME8pvJ-]J' + 'p.yv>BQ470': A4mx0mYQ.6 + 'p:SPZ:e/*[': 'K9nrzyH&DrFhd%u': 'NgILg': 'M_Aq-@$=Ed' + 'q2#Yv[O0:|': 'oQe#*&QQB' + 'q7do-wLx%E': 'iBkK=.H[[*' + 'q8][)Ri}=q': 'bD5Cj?{})5' + 'q8fF$4,c6_': 'ssdH.@,b*<' + 'q:XyGf#>Wg': 'J(!?!{vv{7' + 'q=w[' + 'q[}7hk(+=e': 'mmn-72S%.C' + 'qiv/4tOlf=': 'IW*fIQ@Vn!' + qvITjNxkLN: 'Nq3Z?3&|oE' + 'qx%>fB$d+$': 'm6|Qi!lWAQ' + 'r(h9&#wc~B': 'i-&Ri-vDye' + 'r/B|ra%L,.': 'i;wkVQEC8~' + 'r5C646H*+*': 'H[sth&^jGj' + 'r6BV%|..*5': 'dm&o}^E:Y%' + 'raI-$$|wT': 'v~cPQn}Wc%' + 'r>g<|,FWi%': 'iqg6(sCS87' + 'r?E$4f-d0/': 'u~Z*V|m1JC' + 'rPekx@n9:L': 'rz5xk#1': '!lbQb6Q:w' + 's9-GL*@AXD': 'OIwD?Vq[H}' + 's<_WLSB3I/': 'Gir-4TL#W$' + 's>f40rn,O]': 'HE)H&.0Xn6' + 'sB7rZQsRKc}4kE=' + sM0GPt1HS1: 'c3fq*,<:~U' + 'sRw-ik:I$v': 'PB_{Euk>>/' + 'sSW/?WMy17': 'J@?!E/?|:y' + 'sT5=H|@KU|': 'erbhQg!RK]' + sXzvT.g31p: 'q|wSo|9[WL' + 'sY>@9w~i^p': 'N>!/r-|iKh' + 'skOM(?n$GG': 'OB*1GftSKF' + spKoFRM4if: 'yR{s}{|vzP' + 'ssbyA9}]QP': 'h46=t>$v#7' + 'stRa/S(0$F': 'w<=bLWa9^e' + 'sxOMs+bE8@': 'nNI(>d`5|I' + 'sx|l+io`B@': 'i>!/t@^}^?' + 's|K&2S,UP|': 'MHvNbPV}Qa' + 't$y1C2R-Av': 'Hmbq~BF@]H' + 't+v9b--,qs': 'mBU4P!~(nj' + 't-0>F]bu>P': 'uoblR~tJv{' + 't2&ri|[Un:': 'cz=5m*@Fez' + t3aZXtpNyi: 'Pkxoj6=pZF' + 't50y/[AEhF': 'zRmOT{Xeqm' + 'tMMtBUpb$&': 'C&k%h=UUn#' + 'tTnCoO/*tk': 'LE9jC#sSOE' + 't_h*fbN#:(': 'QUEZaS<.i&' + 't`cagaWApo': taEpjjAUDi + 'tcneuK7v%`': 'J2r:;Tb}bM' + tfCY4j6KEZ: mdpMDyaJo8 + 'tj&bK}%P!F': 'vqizD*x9.v' + 'twrhzJ[k:Z': 'if&Ipt4{R!' + 't|h3|;E4g{': 'g_banf#h(Q' + 'u-`cspdp)D': 'QF&bT0`se)' + 'u4^lH.xem5': 'n8DMh4+UMz' + 'u;|vR#mVMW': 'xBC@m/Re-7' + 'uH*Q(mcRt5': 'iT]9Jy+|:B' + 'uRo^w#p5Be': 'Gn220)gYMN' + 'uc61ToT%gt': 'J/Z,LIAPP>' + 'ui3>b-?a@m': 'vC~!WO}1Yx' + upiX0g1quR: 'H16eX1]VWw' + 'uv1fGXHN@)': 't9Ryk}`qj2' + 'u|I}oElaH)': 'L_#MCSsliG' + 'vE[T$`n9T)': 'O,{dn7CExu' + 'vKx<0Qa!aP': 'D&9$.-JH&Q' + 'vNiD6A.v#s': 'hqBB({k!2cXc': 'cPg&WpVKO~' + x2d4n4ADcP: 'J3~et+v|e@' + 'xb&P*pH(Q': 'B[,~m31[Og' + 'xqrJ^d[.(O': 'b/*Gfh]U>A' + 'yX:tmrd5;]': 'M0{EnlSZ[&' + 'z:*d{z(~V2': 'fs]5;0c;}B' + 'zSc]-^U=0=': 'hpvj)-!USf' + crowdanki:uuid: + 43e2586a-9a65-11e8-a777-a0481cc15658: 2aa62e36-601e-4e4c-a124-5a79b14f8697 diff --git a/fixtures/ultimate-geography/overlays/languages/sv.yaml b/fixtures/ultimate-geography/overlays/languages/sv.yaml new file mode 100644 index 0000000..b81a300 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/sv.yaml @@ -0,0 +1,772 @@ +id: overlay.translation.sv +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': 'Sydkorea förespråkar namnet Östhavet, Nordkorea Ostkoreanska havet.' + Abkhazia: Abchazien + Addis Ababa: Addis Abeba + Adriatic Sea: Adriatiska havet + Aegean Sea: Egeiska havet + Africa: Afrika + Akrotiri and Dhekelia: Akrotiri och Dhekelia + Albania: Albanien + Algeria: Algeriet + Algiers: Alger + Also known as Burma.: 'Även känt som Burma.' + Also known as Cabo Verde.: '' + Also known as Czechia.: '' + Also known as East Timor.: 'Även känt som Timor-Leste.' + Also known as Kiev.: '' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: 'Även känd som Siambukten.' + Also known as the Sea of Cortez.: 'Även känt som Cortez hav.' + 'Also spelled as Sana''a.': '' + American Samoa: Amerikanska Samoa + 'Andorra (narrower, coat of arms with motto)': 'Andorra (smalare, vapensköld med valspråk)' + Antarctica: Antarktis + Antigua and Barbuda: Antigua och Barbuda + Arabian Sea: Arabiska havet + Aral Sea: 'Aralsjön' + Arctic Ocean: Norra ishavet + Armenia: Armenien + Ashgabat: Asjchabad + Asia: Asien + Athens: Aten + Atlantic Ocean: Atlanten + Australia: Australien + 'Australia (white stars, two more stars)': 'Australien (vita stjärnor, två fler stjärnor)' + Austria: 'Österrike' + 'Austria (brighter red, wider white band)': 'Österrike (ljusare röd, bredare vitt band)' + Autonomous community of Spain.: Spansk autonom region. + Autonomous province of South Korea.: 'Autonom region i Sydkorea; tidigare känd som Quelpart.' + Autonomous region of Finland.: 'Självstyrande del av Finland.' + Autonomous region of Italy.: Italiensk autonom region. + Autonomous region of Papua New Guinea.: Autonom region i Papua Nya Guinea. + Autonomous region of Portugal.: + notes.note.azores.fields.field.country-info: 'Portugisisk autonom ögrupp.' + notes.note.madeira.fields.field.country-info: Portugisisk autonom region. + Azerbaijan: Azerbajdzjan + Azores: Azorerna + Baghdad: Bagdad + 'Bahrain (narrower, fewer serrated edges, red)': 'Bahrain (smalare, färre tänder, röd)' + Balkan Peninsula: 'Balkanhalvön' + Baltic Sea: 'Östersjön' + Banda Sea: 'Bandasjön' + Barents Sea: Barents hav + Bay of Bengal: Bengaliska viken + Bay of Biscay: Biscayabukten + Beijing: Peking + Belgium: Belgien + Belgrade: Belgrad + Bering Strait: Berings sund + Bishkek: Bisjkek + Black Sea: Svarta havet + 'Bolivia (coat of arms instead of star)': 'Bolivia (vapensköld istället för stjärna)' + Bosnia and Herzegovina: Bosnien och Hercegovina + Brazil: Brasilien + British Virgin Islands: 'Brittiska Jungfruöarna' + Brussels: Bryssel + Bucharest: Bukarest + Bulgaria: Bulgarien + Cairo: Kairo + Cambodia: Kambodja + Cameroon: Kamerun + 'Cameroon (green/red/yellow, yellow star)': 'Kamerun (grön/röd/gul, gul stjärna)' + Canada: Kanada + Canary Islands: 'Kanarieöarna' + Cape Verde: Kap Verde + Caribbean Sea: Karibiska havet + Caspian Sea: Kaspiska havet + Cayman Islands: 'Caymanöarna' + Celebes Sea: 'Sulawesisjön' + Celtic Sea: Keltiska havet + Central African Republic: Centralafrikanska republiken + Cetinje is an honorary capital.: 'Cetinje är hedershuvudstad.' + Chad: Tchad + 'Chad (slightly darker blue)': 'Tchad (aningen mörkare blå)' + China: Kina + City of San Marino: San Marino + Claimed and controlled: 'Hävdas och kontrolleras' + Claimed but not controlled: 'Påstått men inte kontrollerat' + 'Colombia (no coat of arms)': 'Colombia (ingen vapensköld)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: 'Kallas även Kotte eller Sri Jayawardenapura Kotte; Colombo refereras ofta till som huvudstad, men förorten Sri Jayawardenapura är officiell huvudstad.' + Comoros: Komorerna + Constituent country in the Kingdom of Denmark.: Riksdel av Danmark. + Constituent country of the Kingdom of the Netherlands.: 'Ett av fyra länder som utgör staten Nederländerna.' + Constituent country of the United Kingdom.: Riksdel av Storbritannien. + Cook Islands: 'Cooköarna' + Copenhagen: 'Köpenhamn' + Coral Sea: Korallhavet + Corsica: Korsika + Croatia: Kroatien + Crown dependency of the United Kingdom.: Brittisk kronbesittning. + Cuba: Kuba + 'Cuba (red triangle, blue stripes)': 'Kuba (röd triangel, blå ränder)' + 'Curaçao (two stars in top-left corner)': 'Curaçao (två stjärnor i övre vänstra hörnet)' + Cyprus: Cypern + Czech Republic: Tjeckien + Damascus: Damaskus + Dead Sea: 'Döda havet' + Democratic Republic of the Congo: Kongo-Kinshasa + Denmark: Danmark + Denmark Strait: Danmarksundet + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': 'Omstridd; hävdas av Israel; Ramallah är det administrativa centrumet.' + 'Disputed; claimed by Palestine.': 'Omstridd; hävdas av Palestina.' + Dominican Republic: Dominikanska republiken + Dushanbe: Dusjanbe + East China Sea: 'Östkinesiska havet' + East Siberian Sea: 'Östsibiriska havet' + 'Ecuador (with coat of arms)': 'Ecuador (med vapensköld)' + Egypt: Egypten + 'Egypt (emblem instead of text), Yemen (no text)': 'Egypten (statsvapen istället för text), Jemen (ingen text)' + 'Egypt (with emblem), Iraq (with text)': 'Egypten (med emblem), Irak (med text)' + 'El Salvador (different coat of arms, slightly darker blue)': 'El Salvador (annan vapensköld, aningen mörkare blå)' + English Channel: Engelska kanalen + Equatorial Guinea: Ekvatorialguinea + Estonia: Estland + Eswatini: Swaziland + Ethiopia: Etiopien + Europe: Europa + European Union: Europeiska unionen + Falkland Islands: 'Falklandsöarna' + Faroe Islands: 'Färöarna' + Federated States of Micronesia: Mikronesiens federerade stater + Formerly Zaire.: Formellt Demokratiska republiken Kongo. + Formerly known as Macedonia.: 'Tidigare känt som Makedonien.' + France: Frankrike + French Guiana: Franska Guyana + French Polynesia: Franska Polynesien + Georgia: Georgien + Germany: Tyskland + 'Ghana (star instead of coat of arms)': 'Ghana (stjärnor istället för vapensköld)' + Greece: Grekland + Greenland: 'Grönland' + 'Guinea (green and red flipped, slightly darker green)': 'Guinea (grön och röd växlade, aningen mörkare grön)' + Gulf of Alaska: Alaskagolfen + Gulf of California: Californiaviken + Gulf of Carpentaria: Carpentariaviken + Gulf of Guinea: Guineabukten + Gulf of Mexico: Mexikanska golfen + Gulf of Thailand: Thailandviken + Havana: Havanna + Helsinki: Helsingfors + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: 'Skandinavien består av Sverige, Norge och Danmark. Norden inkluderar även Finland och Island, och utomskandinaviska områden som Svalbard och Grönland. ' + Hong Kong: Hongkong + Hungary: Ungern + Iceland: Island + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': 'Island (blå bakgrund, rött och vitt kors), Norge (röd bakgrund, blått och vitt kors)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': 'Island (blå bakgrund, rött kors), Färöarna (vit bakgrund, rött och blått kors)' + Independent state claimed by Georgia.: 'Delvis erkänd stat, görs anspråk på av Georgien.' + Independent state claimed by Moldova.: 'Självutropad stat, görs anspråk på av Moldavien.' + Independent state claimed by Somalia.: 'Självutropad stat, görs anspråk på av Somalien.' + India: Indien + Indian Ocean: Indiska oceanen + Indonesia: Indonesien + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': 'Indonesien (vit och röd växlade, ljusare röd), Monaco (vit och röd växlade, smalare)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': 'Indonesien (bredare, ljusare röd), Polen (röd och vit växlade, bredare)' + Iraq: Irak + 'Iraq (text instead of emblem), Yemen (no emblem)': 'Irak (text istället för statsvapen), Jemen (inget statsvapen)' + Ireland: Irland + 'Ireland (orange and green flipped, wider)': 'Irland (orange och grön växlade, bredare)' + Island of Indonesia.: 'Ö i Indonesien.' + Italy: Italien + Ivory Coast: Elfenbenskusten + 'Ivory Coast (green and orange flipped, narrower)': 'Elfenbenskusten (grön och orange växlade, smalare)' + Jordan: Jordanien + Kaliningrad Oblast: Kaliningrad oblast + Kathmandu: Katmandu + Kazakhstan: Kazakstan + Known as Nur-Sultan between 2019 and 2022: Hette Nur-Sultan mellan 2019 och 2022 + Known as Swaziland until 2018.: 'Även känt som Eswatini.' + Kuwait City: Kuwait + Kyiv: Kiev + Kyrgyzstan: Kirgizistan + Laayoune: al-Ayun + Labrador Sea: Labradorhavet + Latvia: Lettland + 'Latvia (darker red, narrower white band)': 'Lettland (mörkare röd, smalare vitt band)' + Lebanon: Libanon + Libya: Libyen + Lisbon: Lissabon + Lithuania: Litauen + Luxembourg: Luxemburg + 'Luxembourg (lighter blue)': 'Luxemburg (ljusare blå)' + Luxembourg City: Luxemburg + Macau: Macao + Madagascar: Madagaskar + Maldives: Maldiverna + 'Mali (red and green flipped, slightly brighter green)': 'Mali (röd och grön växlade, aningen ljusare grön)' + Marshall Islands: 'Marshallöarna' + Mauritania: Mauretanien + Mediterranean Sea: Medelhavet + Melanesia: Melanesien + Mexico: Mexiko + Micronesia: Mikronesien + Moldova: Moldavien + 'Moldova (wider, coat of arms with eagle)': 'Moldavien (bredare, vapensköld med örn)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': 'Monaco (smalare, mörkare röd), Polen (röd och vit växlade, mörkare röd)' + Mongolia: Mongoliet + Morocco: Marocko + Moscow: Moskva + Mozambique: 'Moçambique' + Muscat: Muskat + 'Nauru (single star below yellow band)': 'Nauru (ensam stjärna under den gula randen)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': 'Nauru saknar städer, Yaren är de facto huvudort.' + Netherlands: 'Nederländerna' + 'Netherlands (darker blue)': 'Nederländerna (mörkare blå)' + New Caledonia: Nya Kaledonien + New Zealand: Nya Zeeland + 'New Zealand (red stars, two fewer stars)': 'Nya Zeeland (röda stjärnor, två färre stjärnor)' + 'Nicaragua (different coat of arms, slightly lighter blue)': 'Nicaragua (annan vapensköld, aningen ljusare blå)' + North America: Nordamerika + North Korea: Nordkorea + North Macedonia: Nordmakedonien + North Nicosia: Norra Nicosia + North Sea: 'Nordsjön' + Northern Cyprus: Nordcypern + Northern Ireland: Nordirland + Northern Mariana Islands: Nordmarianerna + Norway: Norge + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': 'Norge (röd bakgrund, blått kors), Färöarna (vit bakgrund, rött och blått kors)' + Norwegian Sea: Norska havet + Not a sovereign country: 'Ej suverän stat' + 'Nukuʻalofa': 'Nuku''alofa' + 'Oblast (administrative region) of the Russian Federation.': 'Även känt som Ryska Östpreussen.' + Oceania: Oceanien + Official capital was moved from Bujumbura to Gitega in 2019.: 'Tidigare känt som Kitega; flyttat från Bujumbura i slutet av 2018.' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: 'Flyttat från Malabo i 2026.' + 'Officially Côte d''Ivoire.': '' + Officially Luxembourg.: '' + Overseas department of France.: + notes.note.french-guiana.fields.field.country-info: Franskt utomeuropeiskt departement. + notes.note.guadeloupe.fields.field.country-info: Utomeuropeiskt franskt departement. + notes.note.martinique.fields.field.country-info: Franskt utomeuropeiskt departement. + notes.note.mayotte.fields.field.country-info: Franskt utomeuropeiskt departement. + notes.note.r-union.fields.field.country-info: Franskt utomeuropeiskt departement. + Overseas territory of France.: + notes.note.french-polynesia.fields.field.country-info: 'Franskt utomeuropeiskt förvaltningsområde.' + notes.note.new-caledonia.fields.field.country-info: Franskt utomeuropeiskt territorium. + notes.note.saint-martin.fields.field.country-info: 'Franskt utomeuropeiskt område.' + notes.note.wallis-and-futuna.fields.field.country-info: 'Franskt utomeuropeiskt område.' + Overseas territory of the United Kingdom.: + notes.note.akrotiri-and-dhekelia.fields.field.country-info: 'Brittiska “suveräna basområden”.' + notes.note.anguilla.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + notes.note.bermuda.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + notes.note.british-virgin-islands.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + notes.note.cayman-islands.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + notes.note.falkland-islands.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + notes.note.gibraltar.fields.field.country-info: Brittiskt territorium. + notes.note.turks-and-caicos-islands.fields.field.country-info: Brittiskt utomeuropeiskt territorium. + Pacific Ocean: Stilla havet + Palestine: Palestina + 'Palestine (black/white/green, red arrow)': 'Palestina (svart/vit/grön, röd triangel)' + 'Palestine (no symbol)': 'Palestina (ingen symbol)' + Papua New Guinea: Papua Nya Guinea + Partially recognised state claimed by China.: 'Delvis erkänd stat, görs anspråk på av Kina.' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: 'Även känt som Västsahariska republiken.' + Partially recognised state claimed by Serbia.: 'Delvis erkänd stat, görs anspråk på av Serbien.' + Persian Gulf: Persiska viken + Philippine Sea: 'Filippinska sjön' + Philippines: Filippinerna + Poland: Polen + Polynesia: Polynesien + Prague: Prag + Pretoria, Cape Town, Bloemfontein: Pretoria, Kapstaden, Bloemfontein + 'Puerto Rico (blue triangle, red stripes)': 'Puerto Rico (blå triangel, röda ränder)' + 'Qatar (wider, more serrated edges, maroon)': 'Qatar (bredare, fler tänder, rödbrun)' + Red Sea: 'Röda havet' + Region of France.: Fransk region. + Republic of the Congo: Kongo-Brazzaville + 'Reykjavík': Reykjavik + Romania: 'Rumänien' + 'Romania (slightly lighter blue)': 'Rumänien (aningen ljusare blå)' + Rome: Rom + Russia: Ryssland + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': 'Ryssland (ingen vapensköld), Slovenien (bredare, mindre vapensköld)' + Sahrawi Arab Democratic Republic: Sahariska arabiska demokratiska republiken + Saint Kitts and Nevis: Saint Kitts och Nevis + Saint Vincent and the Grenadines: Saint Vincent och Grenadinerna + Santiago: Santiago de Chile + Sardinia: Sardinien + Saudi Arabia: Saudiarabien + Scandinavia: Skandinavien + Scotland: Skottland + Sea of Galilee: 'Gennesaretsjön' + Sea of Japan: Japanska havet + Sea of Okhotsk: Ochotska havet + Semi-autonomous region of Tanzania.: 'Delvis självstyrande örike i union med Tanzania.' + 'Senegal (green/yellow/red, green star)': 'Senegal (grön/gul/röd, grön stjärna)' + Serbia: Serbien + Seychelles: Seychellerna + Sicily: Sicilien + Slovakia: Slovakien + 'Slovakia (narrower, bigger coat of arms)': 'Slovakien (smalare, större vapensköld)' + 'Slovakia (with coat of arms)': 'Slovakien (med vapensköld)' + Slovenia: Slovenien + Solomon Islands: 'Salomonöarna' + South Africa: Sydafrika + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': 'Sydafrika har ingen lagstadgad huvudstad, regeringen är delad över tre städer: Pretoria (administrativ), Kapstaden (legislativ), Bloemfontein (juridisk).' + South America: Sydamerika + South China Sea: Sydkinesiska havet + South Korea: Sydkorea + South Ossetia: Sydossetien + South Sudan: Sydsudan + Southern Ocean: Antarktiska oceanen + Sovereign country: 'Suverän stat' + Spain: Spanien + Special Administrative Region of China.: + notes.note.hong-kong.fields.field.country-info: 'Särskild administrativ region i Kina.' + notes.note.macau.fields.field.country-info: 'Särskild administrativ region i Kina, stavas alternativt Macau.' + Sri Jayawardenepura Kotte: Sri Jayawardenapura + 'St. George''s': 'Saint George''s' + 'St. John''s': 'Saint John''s' + State of the United States.: Amerikansk delstat. + State recognised only by Turkey and claimed by Cyprus.: 'Enbart erkänd av Turkiet, görs anspråk på av Cypern.' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: 'Delregion av Oceanien bestående av tusentals småöar i centrala och södra Stilla havet.' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: 'Delregion av Oceanien bestående av tusentals småöar i västra Stilla havet.' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: 'Delregion av Oceanien bestående av bland andra Vanuatu, Salomonöarna, Fiji och Papua Nya Guinea.' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': 'Sudan (röd/vit/svart, grön triangel), Sahariska arabiska demokratiska republiken (med stjärna och halvmåne)' + Sukhumi: Suchumi + Suriname: Surinam + Sweden: Sverige + Switzerland: Schweiz + 'Switzerland has no official capital; Bern is the de facto capital.': 'Schweiz har ingen officiell huvudstad; Bern är de facto huvudstad.' + Syria: Syrien + 'São Tomé and Príncipe': 'São Tomé och Príncipe' + Tajikistan: Tadzjikistan + Tashkent: Tasjkent + Tasman Sea: Tasmanhavet + Tehran: Teheran + The Bahamas: Bahamas + The Gambia: Gambia + Timor Sea: 'Timorsjön' + Timor-Leste: 'Östtimor' + Transnistria: Transnistrien + Trinidad and Tobago: Trinidad och Tobago + Tskhinvali: Tschinvali + Tunisia: Tunisien + Turkey: Turkiet + Turks and Caicos Islands: 'Turks- och Caicosöarna' + Ukraine: Ukraina + Ulaanbaatar: Ulan Bator + Unincorporated internal area of Norway.: 'Norsk ögrupp.' + Unincorporated territory of the United States.: 'Amerikanskt icke-inkorporerat område.' + United Arab Emirates: 'Förenade Arabemiraten' + United Kingdom: Storbritannien + United States Virgin Islands: 'Amerikanska Jungfruöarna' + United States of America: USA + Vatican City: + notes.note.vatican-city.fields.field.capital: Vatikanstaden + notes.note.vatican-city.fields.field.country: Vatikanstaten + Vienna: Wien + Wallis and Futuna: 'Wallis- och Futunaöarna' + Warsaw: Warszawa + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: 'Haag är administrativ huvudstad.' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: 'Dar es-Salaam är de facto administrativ huvudstad.' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': 'Stavas även al-''Ayun, El Aaiún; Tifariti är de facto säte för parlamentet.' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: 'Lobamba är traditionell och lagstiftande huvudstad.' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: 'Cotonou är administrativ huvudstad.' + While Sucre is the constitutional capital, La Paz is the seat of government.: 'La Paz är säte för regeringen.' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: 'Stavas även Yamassoukro; Abidjan är de facto administrativ huvudstad.' + White Sea: Vita havet + World region covering the Australian continent and most of the islands in the Pacific Ocean.: 'Världsdel bestående av Australien och majoriteten av öarna och ögrupperna i Stilla havet.' + Yellow Sea: Gula havet + Yemen: Jemen + Yerevan: Jerevan + 'Åland Islands': 'Åland' + additions: + notes.note.arabian-sea.fields.field.country-info: 'Även känt som Arabiska sjön.' + notes.note.bahrain.fields.field.country-info: Stavas alternativt Bahrein. + notes.note.balkan-peninsula.fields.field.country-info: 'Även känt som Balkan.' + notes.note.belarus.fields.field.country-info: 'Även känt som Vitryssland.' + notes.note.federated-states-of-micronesia.fields.field.country-info: 'Även känt som Mikronesiska federationen.' + notes.note.mongolia.fields.field.capital-info: 'Stavas även Ulaanbaatar.' + notes.note.myanmar.fields.field.capital-info: 'Stavas även Nay Pyi Taw.' + notes.note.persian-gulf.fields.field.country-info: 'Även känd som Iranska viken.' + notes.note.qatar.fields.field.country-info: Stavas alternativt Katar. + notes.note.republic-of-the-congo.fields.field.country-info: Formellt Republiken Kongo. + notes.note.sea-of-galilee.fields.field.country-info: 'Även känd som Galileiska sjön och Tiberiassjön.' + notes.note.united-kingdom.fields.field.country-info: 'Formellt Förenade kungariket.' + notes.note.united-states-of-america.fields.field.country-info: 'Formellt Amerikas förenta stater, även känt som Förenta staterna.' + notes.note.yellow-sea.fields.field.country-info: 'Kallas Västhavet i Nord- och Sydkorea.' + notes.note.yemen.fields.field.country-info: Stavas alternativt Yemen. + variables: + label.capital: + Capital: Huvudstad + label.capital-hint.answer: + 'Hint: {{Capital hint}}': 'Ledtråd: {{Capital hint}}' + label.flag: + Flag: Flagga + label.location: + Location: 'Läge' + note-type.name: + Ultimate Geography: 'Ultimate Geography [SV]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': 'Flaggan liknar {{Flag similarity}}.' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'HU{V)h>->1' + '5JJ2]i)p4': 'ch9]RU,k}a' + '5S(f7%O-$': 'u?NN' + 'EWXNc<6ju' + LPF4AfaHKR: 'DMw7[z1|/f' + 'Lol+XIvY21': 'AUQ5QqT7N~' + 'M$bc0[jYf0': 'B4o4Qgpxa=' + 'O~pXkAbd3': 'u._5@pRbGb' + 'QSq%~rFaP-': 'A`,,|zaW//' + 'Qlib4)q2Hk': 'Lg|45@I{1+' + 'SlOEXfq#|': 'u>bFUX4,^(' + 'W5?6yQZD2': 'Bp:8AxZIJ*' + '_]DH+g9!$': 'g*C+;[^vP9' + 'b@M[XTc?}5': 'OzZ|9/zKGy' + 'bD?R}i8_[c': 'B0j5`h}ycY' + 'bW)ch:vs1P': 'hWSXAL,1[1' + 'b[(2`4t&?J': 'P$;>yK&Bb#' + 'b^Try&me1A': 'N@2CXFim}.' + 'b`5Q_Z=HQT': 'MPv~EB{R-o' + 'bd~Ght5tLR': 'FBWKraq$]`' + 'bk2ByZIeU+': 'GO8K>ugF' + 'c(,2{yXI#E': '(;;{-bktU' + 'c,5(mUGzYT': 'b.K2|uT9#&' + 'cAlyc~G.y%': 'p){pPjR5:$' + 'cEO^y)Md[G': 'ite]=1-[IW' + 'cL?8!xP#Wj': 'ya#p*nPn}U' + 'cPO|Cru8n<': 'b4O.fdq=&&' + 'cV4idO^r8W': 'f&H7U0-[5}' + 'cYKvF#ptF7': 'P;wx%Rx}g%' + 'c^,~EC6Pb2': 'KG~t}M~,_o' + 'ck!6;xK$/^': 'vHOU`CM:P+' + 'cv+Nq9:a$PS' + 'd3c`&/1Y^D': 'y!.rR1N]Q%' + 'd5m8%D6,;u': 't!YaMvtxM)' + 'dC:e5G4J/p': 'iZ`BoOu7TH' + 'dUZ^=SE5BG': '^(x:orV#n' + 'dWA49p}F{k': 'O%#xYWz^#M' + 'dY96`C-f(_': KUSv4_ZftC + 'd``B*6:eCx': 'eItZ|YXm$3' + 'dbP/Cj}sB+': yychoEU/n9 + 'de=9c:,@j9': 'o6f7z~ZCr6' + 'dlU7VG|3Gd': 'f*A:Z4bkRD' + 'd}R:qHj1,7': 'pG~&s-.Pv=' + 'e!KSebP}pt': 'EnHK+Ktzh+' + 'e+/O]%*qfk': 'M@9EfpNi;z' + 'e.t$Vi5,>f': 'eqi30~lJCU' + 'e42BpV,P}C': 'o@l9/@&CEQ' + 'e5P*D#I{m{': 'p;W>c.=a5.' + 'eHH?U': 'iWh/XzA:Fx' + 'euW0jm=wfR': 'F5Fbm]10e3' + 'e{e8vi@^PY': 'O8La>q*+=D' + 'f#4-L[mp5*': 'nh!${YsPie' + 'f*2@qiHh_1': 'fL!0PN': 't1sE<.qtO/' + 'f21=-A~W;~': 'qv@;*gj=VH' + 'f>c7x2E]Q6': 'O|!r8pZc_@' + 'fRSM{K+;[w': 'x2Ho#9JF2&' + 'fT!w8R#>dg': 'taGg_[({a>' + 'fV`&p81%d(': 'N&=0FE?0K3' + fkq7lhLkdH: 'x}$b4LJvm3' + 'fxAN),FAc$': 'QyZ%*OdzFY' + 'g-]bb&[.E!': 'H/1j+}vOwc' + 'g9v~y/;=z(': caoMw/l6Qq + 'g<[f89~U)A': 'eRztGZ?5gt' + 'gBCR?!O*;E': 'wY{he1D;Tc' + 'gB|K:+r?1V': 'ncoVNK:5c/' + 'gDsLPj(9S#': 'EykZ%2$zVY' + 'gFOojQO(MH': 'n$t4JTV.L[' + 'gG6SWfM>H0': 'c3_S$ge,H%' + 'gRVZ]qJ#>O': 'ywSb;Oor?l' + 'gYIoR`|AxW': 'm9{}Vj*h)B' + 'gePC:Xt_nW': 'we!,qZc~VX' + 'gp[h@]y13l': 'o6Q=4QRc:P' + 'gsS{jR,]|Q': 'DnUNQy>V@c' + 'gx:;za!?C9': 'O6XK(yEdjN' + 'gz9${p}er*': 'O:){fAE+[h' + 'g~%pF`(x{u': 'qvU`=w(275' + 'h&`>n98~V1': i7sQICmsKP + 'h9Jr(H[=1~': 'lg>S)d~UyQ' + 'h?a*UKfT_M': 'uU![?ri)d_' + 'hE5j*e,Q?[': 'oVtF.y]H)M' + 'hNE9}>;Q&-': 'Ae@YR@cdSW' + 'hXNTVF<:SH': 'FO/D@z0E%#' + 'hZ/V/D&rO`': 'k^PtQpRdLQ' + 'h_9=mkZXAH': 'q*^h!N31k*' + hoHandbYAy: 'heo$u3%xYs' + 'hrI_uiW>E]': 'L3Z}n~/Szn' + 'h}lnSN_f$_': 'yp(op%kwx]' + 'h~5xz+=ke~': 'Gpeahm(L/~c=': 'c=F0q+d|m#' + 'i^J8&$(1]|': 'ugyWN%[pXR' + 'ia8as9sCY|': 'b2;iz|xg^@' + 'igiK,ff[eO': 'N`eaJBT!9': 'LH]h^.GA_i' + 'ilGtw#=asM': 'Qi.AoC*UvT' + 'j%*8gN)q>d': 'uVs:]g8l.&' + 'j)i)pB*HJ,': 'OlYF5,:|Q3' + 'j2IQ=f=w3@': 'B;S:g|}': 'rJG$qf|FO4' + 'jCd]`-=k,:': 'QO[lJt~Q]N' + 'jDA-V?/hVj': 'GilXrl.A_%' + jI9P-f6r3M: 'z}n.If4:?)' + 'jKIDq|}J&c': 'qW1*i(&R3$' + 'jKO,9y0M;#': 'p02Yuw`G)/' + 'jN^Vc%9OQ5': 'B`1@FnD9*.' + 'jN|NAUP*h}': 'h>OmJ~oZ~|' + 'jTNoKo}Bu+': 'rF%zt5M&?-' + 'jX=#G8wu#(': 'ki}T;HSiij' + 'jYz[ibrr9m': 'D.R!j]):&%' + 'jcu!gLw/&r': 'j(X|k-_Sv|' + 'jrM28*HbyG': OgRSGzP9iU + 'j|z@PMgdx,': 'gM+}.:OZH]' + 'k$1_yaF?9#': 'gM!|AcSE~W' + 'k/u1:B%DJH': 'yJ+?5Bb~=:' + 'k0{O[6l]bH': 'Pc}I71FH$K' + 'k1]FH+s8j@': 'A`?%3b$nUX' + 'k9E.p^jkXg' + 'k}A9]O:0xw': 'lsc1G{vU4.' + 'l$2O|9w`F~': 'x^M8w2)s#:' + 'l$zM~ihPFE': 'B,[GBme-:n' + 'lI)Ah/E': ':+A,]QJt-' + 'l*j*EE:!Y?': 'EKcj[W^+w]' + 'l+>Ki!j#v+': 'sj6y@z=dRP' + 'l3Ly8PBxt(': 'n$0d;K4M?l' + 'lAxH.CfJB@': 'r#k4%R`2$IfI' + 'm2F|cUkKhv': 'qvhov1fh!t' + 'm5j,b/y7T|g>': 'eDx9dZ&k(k' + 'mz[>>AxIr4': 'z9T_hmmT*f' + 'm{noqE29%I': 'w3V@j|Foq~' + 'm{oM,jr>C#': 'rVhH@X0hOr' + 'n&>e`2FO/S': 'l{J_M.Vp>X' + 'n+t>2`:;P/': 'wH[5PSfX;K' + 'n:7&>YT5A=': 'J.*@Nk4>0*' + 'nIfRner*)`9o' + 'o#&RMA_REo': 'w7J5,@!ZO`' + 'o3VS!KWb/@': 'Nky*kq6)GC' + 'o9{x/yz': 'jZpi[+b>`2' + 'oa87&PB&Bm': 'GH~LmtD8o#' + 'onMKID2d;D': 'F[Hq;*V/7K' + 'o~}KVA:VbI': 'njbrSU^agd' + 'p#R$-mG/L': 'xA+]rlW|<7' + 'p.yv>BQ470': 'FGu[yfPiJ7' + 'p:SPZ:e/*[': 'Op1]SnugtV' + 'pDDrFhd%u': 'w5=:(t5%R6' + 'pYRw[=6;JI': 'FoXsg8;#yi' + 'p]t8:Tka7q': 'f*vz&Ph[bR' + pkj3RPBUrF: 'o%$r]1QH{t' + 'pwwZu{7f~(': CpZYay1jbg + 'pz(G*g': 'J{(N4{^e:N' + 'q2#Yv[O0:|': 'tJ&;D}C]UV' + 'q7do-wLx%E': 'v?vAk$tZAa' + 'q8][)Ri}=q': 'N@bHVDe+Kb' + 'q8fF$4,c6_': '[&[K^KiK8' + 'q:XyGf#>Wg': 'O>(c0%czoO' + 'q=w[fB$d+$': 'Mj&h' + 'r6BV%|..*5': 'Qmb.+7huoY' + 'raI-$$|wT': 'f`?#zj#)DE' + 'r>g<|,FWi%': 'Ko1^yAR.yn' + 'r?E$4f-d0/': 'gux9;qM)nK' + 'rPekx@n9:L': 'kjo~PAZ.,y' + 'rSneU!3}bn': 'myDYy': 'Q@3rl@WPXz' + 's9-GL*@AXD': 'nN,;(#X}Wt' + 's<_WLSB3I/': 'DVc[0B1#@*' + 's>f40rn,O]': 'J]3b`qj!hC' + 'sB7rZQsR@9w~i^p': 'H_pUK6Xu]`' + 'skOM(?n$GG': 'hj]p(iMKl7' + spKoFRM4if: 'c@58oWbN3U' + 'ssbyA9}]QP': 'D((j`lUo!d' + 'stRa/S(0$F': 'KHEo&Oa7_F' + 'sxOMs+bE8@': 'J+O2qUIUz|' + 'sx|l+io`B@': 'p?*+RZF]bu>P': 'y=oLcr9$cQ' + 't2&ri|[Un:': 'c7[u_eSWiy' + t3aZXtpNyi: OX,mUDIStL + 't50y/[AEhF': 'I=mZ*3?!_l' + 'tMMtBUpb$&': '|*Z`50Bgs' + 'tTnCoO/*tk': 'Qg3C+rbaa*' + 't_h*fbN#:(': 'Leo~hDb-?a@m': 'A:WhEL~x!A' + upiX0g1quR: 'mSow!CwtLI' + 'uv1fGXHN@)': AUgq3IkilN + 'u|I}oElaH)': 'lNTL4T}K1)' + 'vE[T$`n9T)': 'nB97`B-u)' + 'vKx<0Qa!aP': 'fyeM9@jJW0' + 'vNiD6A.v#s': 'P.ls/JP5<}' + 'va/ky=f5pK': fGB0mK7OmU + 'vf)G?[_?Pf': 'u3u7Z|VQV`' + 'vgdj#X?8dB': 'b^b:}HG<(/' + 'vpOTK6tMr@': 'QIn7X6P~>i' + 'vppEA|$@~g': 'koAN*cMu(A' + 'vrn%j%6{nu': 'F%kRV:W:T$' + 'vwc3}.=Z#&': 'l,[F>UJ/PN' + 'wa>{k!2cXc': 'x=Gm50ksY@' + x2d4n4ADcP: 'p6lxT;d^Pq' + 'xb&P*pH(Q': 'P)2eJ7Sr]e' + 'xqrJ^d[.(O': 'g67ir]U+=9' + 'yX:tmrd5;]': 'GZ2yh|R:fR' + 'z:*d{z(~V2': 'x-s/YndH;0' + 'zSc]-^U=0=': lGG8WbUQzc + crowdanki:uuid: + 43c5ba66-9a65-11e8-90c9-a0481cc15658: 75bfcdb5-0ff3-4038-83cb-3e6ed974f439 + 43e2586a-9a65-11e8-a777-a0481cc15658: 3090ce89-5fd8-4107-86df-3e7a74ec288f +deck: + name: + intent: replace + value: 'Ultimate Geography [SV]' + expected_base: + value: Ultimate Geography + description: + intent: replace + 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! + expected_base: + 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! diff --git a/fixtures/ultimate-geography/overlays/languages/zh-tw.yaml b/fixtures/ultimate-geography/overlays/languages/zh-tw.yaml new file mode 100644 index 0000000..a8682f7 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/zh-tw.yaml @@ -0,0 +1,1032 @@ +id: overlay.translation.zh-tw +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': '' + Abkhazia: '阿布哈茲共和國 (Abkhazia)' + Abu Dhabi: '阿布達比 (Abu Dhabi)' + Abuja: '阿布加 (Abuja)' + Accra: '阿克拉 (Accra)' + Addis Ababa: '阿迪斯阿貝巴 (Addis Ababa)' + Adriatic Sea: '亞得里亞海 (Adriatic Sea)' + Aegean Sea: '愛琴海 (Aegean Sea)' + Afghanistan: '阿富汗 (Afghanistan)' + Africa: '非洲 (Africa)' + Akrotiri and Dhekelia: '亞克羅提利與德凱利亞 (Akrotiri and Dhekelia)' + Alaska: '阿拉斯加 (Alaska)' + Albania: '阿爾巴尼亞 (Albania)' + Algeria: '阿爾巴尼亞 (Algeria)' + Algiers: '阿爾及爾 (Algiers)' + Alofi: '阿洛非 (Alofi)' + Also known as Burma.: '' + Also known as Cabo Verde.: '' + Also known as Czechia.: '' + Also known as East Timor.: '' + Also known as Kiev.: '又稱為基輔。' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: '' + Also known as the Sea of Cortez.: '又稱科爾特斯海。' + 'Also spelled as Sana''a.': '' + American Samoa: '美屬薩摩亞 (American Samoa)' + Amman: '安曼 (Amman)' + Amsterdam: '阿姆斯特丹 (Amsterdam)' + Andorra: '安道爾 (Andorra)' + 'Andorra (narrower, coat of arms with motto)': '安道爾(更窄、徽章)' + Andorra la Vella: '安道爾城 (Andorra la Vella)' + Angola: '安哥拉 (Angola)' + Anguilla: '安奎拉 (Anguilla)' + Ankara: '安卡拉 (Ankara)' + Antananarivo: '安塔那那利弗 (Antananarivo)' + Antarctica: '南極洲 (Antarctica)' + Antigua and Barbuda: '安地卡及巴布達 (Antigua and Barbuda)' + Apia: '阿庇亞 (Apia)' + Arabian Sea: '阿拉伯海 (Arabian Sea)' + Aral Sea: '鹹海 (Aral Sea)' + Arctic Ocean: '北冰洋 (Arctic Ocean)' + Argentina: '阿根廷 (Argentina)' + Armenia: '亞美尼亞 (Armenia)' + Aruba: '荷屬阿魯巴 (Aruba)' + Ashgabat: '阿什哈巴特 (Ashgabat)' + Asia: '亞洲 (Asia)' + Asmara: '阿斯馬拉 (Asmara)' + Astana: '阿斯塔納 (Astana)' + 'Asunción': '亞松森 (Asunción)' + Athens: '雅典 (Athens)' + Atlantic Ocean: '大西洋 (Atlantic Ocean)' + Australia: '澳大利亞 (Australia)' + 'Australia (white stars, two more stars)': '澳洲(白色星星、多兩顆星星)' + Austria: '奧地利 (Austria)' + 'Austria (brighter red, wider white band)': '奧地利(更淺紅色、白線較寬)' + Autonomous community of Spain.: '西班牙的自治區。' + Autonomous province of South Korea.: '南韓的自治區。' + Autonomous region of Finland.: '芬蘭的自治區。' + Autonomous region of Italy.: '義大利的自治區。' + Autonomous region of Papua New Guinea.: '巴布亞紐幾內亞的自治區。' + Autonomous region of Portugal.: '葡萄牙的自治區。' + Avarua: '阿瓦魯阿 (Avarua)' + Azerbaijan: '亞塞拜然 (Azerbaijan)' + Azores: '亞速群島 (Azores)' + Baghdad: '巴格達 (Baghdad)' + Bahrain: '巴林 (Bahrain)' + 'Bahrain (narrower, fewer serrated edges, red)': '巴林(更窄、更少鋸齒狀邊緣、紅色)' + Baku: '巴庫 (Baku)' + Bali: '峇里島 (Bali)' + Balkan Peninsula: '巴爾幹半島 (Balkan Peninsula)' + Baltic Sea: '波羅的海 (Baltic Sea)' + Bamako: '巴馬科 (Bamako)' + Banda Sea: '班達海 (Banda Sea)' + Bandar Seri Begawan: '斯里巴加灣市 (Bandar Seri Begawan)' + Bangkok: '曼谷 (Bangkok)' + Bangladesh: '孟加拉 (Bangladesh)' + Bangui: '班基 (Bangui)' + Banjul: '班竹市 (Banjul)' + Barbados: '巴貝多 (Barbados)' + Barents Sea: '巴倫支海 (Barents Sea)' + Basseterre: '巴士底 (Basseterre)' + Bay of Bengal: '孟加拉灣 (Bay of Bengal)' + Bay of Biscay: '比斯開灣 (Bay of Biscay)' + Beijing: '北京 (Beijing)' + Beirut: '貝魯特 (Beirut)' + Belarus: '白俄羅斯 (Belarus)' + Belfast: '貝爾法斯特 (Belfast)' + Belgium: '比利時 (Belgium)' + Belgrade: '貝爾格勒 (Belgrade)' + Belize: '貝里斯 (Belize)' + Belmopan: '貝爾墨邦 (Belmopan)' + Benin: '貝南 (Benin)' + Bering Strait: '白令海峽 (Bering Strait)' + Berlin: '柏林 (Berlin)' + Bermuda: '百慕達 (Bermuda)' + Bern: '伯爾尼 (Bern)' + Bhutan: '不丹 (Bhutan)' + Bishkek: '比斯凱克 (Bishkek)' + Bissau: '比索 (Bissau)' + Black Sea: '黑海 (Black Sea)' + 'Bogotá': '波哥大 (Bogotá)' + Bolivia: '玻利維亞 (Bolivia)' + 'Bolivia (coat of arms instead of star)': '波利維亞(徽章、非星星)' + Bosnia and Herzegovina: '波士尼亞與赫塞哥維納 (Bosnia and Herzegovina)' + Botswana: '波札那 (Botswana)' + Bougainville: '布干維爾島 (Bougainville)' + 'Brasília': '巴西利亞 (Brasília)' + Bratislava: '布拉提斯拉瓦 (Bratislava)' + Brazil: '巴西 (Brazil)' + Brazzaville: '布拉薩 (Brazzaville)' + Bridgetown: '橋鎮 (Bridgetown)' + British Virgin Islands: '英屬維京群島 (British Virgin Islands)' + Brunei: '汶萊 (Brunei)' + Brussels: '布魯塞爾 (Brussels)' + Bucharest: '布加勒斯特 (Bucharest)' + Budapest: '布達佩斯 (Budapest)' + Buenos Aires: '布宜諾斯艾利斯 (Buenos Aires)' + Bulgaria: '保加利亞 (Bulgaria)' + Burkina Faso: '布吉納法索 (Burkina Faso)' + Burundi: '蒲隆地共和國 (Burundi)' + Cairo: '開羅 (Cairo)' + Cambodia: '柬埔寨 (Cambodia)' + Cameroon: '喀麥隆共和國 (Cameroon)' + 'Cameroon (green/red/yellow, yellow star)': '喀麥隆(綠紅黃、黃色星星)' + Canada: '加拿大 (Canada)' + Canary Islands: '加納利群島 (Canary Islands)' + Canberra: '坎培拉 (Canberra)' + Cape Verde: '維德角共和國 (Cape Verde)' + Caracas: '卡拉卡斯 (Caracas)' + Cardiff: '卡地夫 (Cardiff)' + Caribbean Sea: '加勒比海 (Caribbean Sea)' + Caspian Sea: '裏海 (Caspian Sea)' + Castries: '卡斯翠 (Castries)' + Cayman Islands: '開曼群島 (Cayman Islands)' + Celebes Sea: '西里伯斯海(蘇拉威西海) (Celebes Sea)' + Celtic Sea: '凱爾特海 (Celtic Sea)' + Central African Republic: '中非共和國 (Central African Republic)' + Cetinje is an honorary capital.: '策提涅是歷史首都。' + Chad: '查德 (Chad)' + 'Chad (slightly darker blue)': '查德(稍更深藍)' + Charlotte Amalie: '夏洛特阿馬利亞 (Charlotte Amalie)' + Chile: '智利 (Chile)' + China: '中國 (China)' + 'Chișinău': '基希訥烏 (Chișinău)' + City of San Marino: '聖馬利諾 (City of San Marino)' + Ciudad de la Paz: '拉巴斯城 (Ciudad de la Paz)' + Claimed and controlled: '宣稱擁有主權並有實際控制權' + Claimed but not controlled: '宣稱擁有主權但沒有實際控制權' + Colombia: '哥倫比亞 (Colombia)' + 'Colombia (no coat of arms)': '哥倫比亞(無徽章)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: '雖然可倫坡被稱為首都,但"斯里賈亞瓦德納普拉科特"是正式與立法的首都。' + Comoros: '葛摩聯盟 (Comoros)' + Conakry: '柯那克里 (Conakry)' + Constituent country in the Kingdom of Denmark.: '丹麥的構成國。' + Constituent country of the Kingdom of the Netherlands.: '荷蘭的構成國。' + Constituent country of the United Kingdom.: '英國的構成國。' + Cook Islands: '庫克群島 (Cook Islands)' + Copenhagen: '哥本哈根 (Copenhagen)' + Coral Sea: '珊瑚海 (Coral Sea)' + Corsica: '科西嘉 (Corsica)' + Costa Rica: '哥斯大黎加 (Costa Rica)' + Croatia: '克羅埃西亞 (Croatia)' + Crown dependency of the United Kingdom.: '英國皇家屬地。' + Cuba: '古巴 (Cuba)' + 'Cuba (red triangle, blue stripes)': '古巴(紅色三角、紅條紋)' + 'Curaçao': '庫拉索 (Curaçao)' + 'Curaçao (two stars in top-left corner)': '庫拉索(左上角有兩顆星星)' + Cyprus: '賽普勒斯 (Cyprus)' + Czech Republic: '捷克 (Czech Republic)' + Dakar: '達卡 (Dakar)' + Damascus: '大馬士革 (Damascus)' + Dead Sea: '死海 (Dead Sea)' + Democratic Republic of the Congo: '剛果民主共和國 (Democratic Republic of the Congo)' + Denmark: '丹麥 (Denmark)' + Denmark Strait: '丹麥海峽 (Denmark Strait)' + Dhaka: '達卡 (Dhaka)' + Dili: '帝利 (Dili)' + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': '有爭議,以色列宣稱擁有主權,拉馬拉是行政中心。' + 'Disputed; claimed by Palestine.': '有爭議,巴勒斯坦宣稱擁有主權。' + Djibouti: + notes.note.djibouti.fields.field.capital: '吉布地 (Djibouti)' + notes.note.djibouti.fields.field.country: '吉布地共和國 (Djibouti)' + Dodoma: '杜篤馬 (Dodoma)' + Doha: '杜哈 (Doha)' + Dominica: '多明尼加 (Dominica)' + Dominican Republic: '多明尼加共和國 (Dominican Republic)' + Dublin: '都柏林 (Dublin)' + Dushanbe: '杜尚貝 (Dushanbe)' + East China Sea: '東海 (East China Sea)' + East Siberian Sea: '東西伯利亞海 (East Siberian Sea)' + Ecuador: '厄瓜多 (Ecuador)' + 'Ecuador (with coat of arms)': '厄瓜多(有徽章)' + Edinburgh: '愛丁堡 (Edinburgh)' + Egypt: '埃及 (Egypt)' + 'Egypt (emblem instead of text), Yemen (no text)': '埃及(徽章、非文字)、葉門(無文字)' + 'Egypt (with emblem), Iraq (with text)': '埃及(徽章)、伊拉克(文字)' + El Salvador: '薩爾瓦多 (El Salvador)' + 'El Salvador (different coat of arms, slightly darker blue)': '薩爾瓦多(不同徽章、稍深藍)' + England: '英格蘭 (England)' + English Channel: '英吉利海峽 (English Channel)' + Equatorial Guinea: '赤道幾內亞 (Equatorial Guinea)' + Eritrea: '厄利垂亞 (Eritrea)' + Estonia: '愛沙尼亞 (Estonia)' + Eswatini: '史瓦帝尼 (Eswatini)' + Ethiopia: '衣索比亞 (Ethiopia)' + Europe: '歐洲 (Europe)' + European Union: '歐盟 (European Union)' + Falkland Islands: '福克蘭群島 (Falkland Islands)' + Faroe Islands: '法羅群島 (Faroe Islands)' + Federated States of Micronesia: '密克羅尼西亞 (Federated States of Micronesia)' + Fiji: '斐濟 (Fiji)' + Finland: '芬蘭 (Finland)' + Formerly Zaire.: '前身為薩伊。' + Formerly known as Macedonia.: '舊稱馬其頓。' + France: '法國 (France)' + Freetown: '自由城 (Freetown)' + French Guiana: '法屬圭亞那 (French Guiana)' + French Polynesia: '法屬玻里尼西亞 (French Polynesia)' + Funafuti: '富那富提 (Funafuti)' + Gabon: '加彭共和國 (Gabon)' + Gaborone: '嘉柏隆里 (Gaborone)' + Georgetown: '喬治城 (Georgetown)' + Georgia: '喬治亞 (Georgia)' + Germany: '格國 (Germany)' + Ghana: '迦納 (Ghana)' + 'Ghana (star instead of coat of arms)': '迦納(星星、非徽章)' + Gibraltar: '直布羅陀 (Gibraltar)' + Gitega: '吉特加 (Gitega)' + Greece: '希臘 (Greece)' + Greenland: '格陵蘭 (Greenland)' + Grenada: '格瑞那達 (Grenada)' + Guadeloupe: '瓜地洛普 (Guadeloupe)' + Guam: '關島 (Guam)' + Guatemala: '瓜地馬拉 (Guatemala)' + Guatemala City: '瓜地馬拉市 (Guatemala City)' + Guernsey: '根西 (Guernsey)' + Guinea: '幾內亞 (Guinea)' + 'Guinea (green and red flipped, slightly darker green)': '圭亞那(綠紅對調、稍微深綠)' + Guinea-Bissau: '幾內亞比索共和國 (Guinea-Bissau)' + Gulf of Alaska: '阿拉斯加灣 (Gulf of Alaska)' + Gulf of California: '加利福尼亞灣 (Gulf of California)' + Gulf of Carpentaria: '喀本塔利亞灣 (Gulf of Carpentaria)' + Gulf of Guinea: '幾內亞灣 (Gulf of Guinea)' + Gulf of Mexico: '墨西哥灣 (Gulf of Mexico)' + Gulf of Thailand: '泰國灣 (Gulf of Thailand)' + Guyana: '蓋亞那 (Guyana)' + 'Hagåtña': '亞加納 (Hagåtña)' + Haiti: '海地 (Haiti)' + Hanoi: '河內 (Hanoi)' + Harare: '哈拉雷 (Harare)' + Hargeisa: '哈爾格薩 (Hargeisa)' + Havana: '哈瓦那 (Havana)' + Hawaii: '夏威夷 (Hawaii)' + Helsinki: '赫爾辛基 (Helsinki)' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: '北歐的歷史和文化地區,包括丹麥、挪威和瑞典國家,有時還包括芬蘭和冰島。' + Honduras: '宏都拉斯 (Honduras)' + Hong Kong: '香港 (Hong Kong)' + Honiara: '荷尼阿拉 (Honiara)' + Hudson Bay: '哈德遜灣 (Hudson Bay)' + Hungary: '匈牙利 (Hungary)' + Iceland: '冰島 (Iceland)' + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': '冰島(藍底、紅白十字)、挪威(紅底、藍白十字)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': '冰島(藍底、紅十字)、法羅群島(白底、紅藍十字)' + Independent state claimed by Georgia.: + notes.note.abkhazia.fields.field.country-info: '喬治亞宣稱擁有該地區主權,但目前是獨立狀態。' + notes.note.south-ossetia.fields.field.country-info: '從喬治亞獨立。' + Independent state claimed by Moldova.: '摩爾多瓦宣稱擁有該地區主權,但目前是獨立狀態。' + Independent state claimed by Somalia.: '索馬利亞宣稱擁有該地區主權,但目前是獨立狀態。' + India: '印度 (India)' + Indian Ocean: '印度洋 (Indian Ocean)' + Indonesia: '印度尼西亞(印尼) (Indonesia)' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': '印尼(白紅對調、更多紅色)、摩納哥(白紅對調、更窄)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': '印尼(更寬、更淺紅)、波蘭(紅白對調、更寬)' + Iran: '伊朗 (Iran)' + Iraq: '伊拉克 (Iraq)' + 'Iraq (text instead of emblem), Yemen (no emblem)': '伊拉克(文字、無徽章)、葉門(無徽章)' + Ireland: '愛爾蘭 (Ireland)' + 'Ireland (orange and green flipped, wider)': '愛爾蘭(橘綠對調、更寬)' + Islamabad: '伊斯蘭馬巴德 (Islamabad)' + Island of Indonesia.: '印尼的島嶼。' + Isle of Man: '曼島 (Isle of Man)' + Israel: '以色列 (Israel)' + Italy: '義大利 (Italy)' + Ivory Coast: '象牙海岸 (Ivory Coast)' + 'Ivory Coast (green and orange flipped, narrower)': '象牙海岸(綠橘對調、更窄)' + Jakarta: '雅加達 (Jakarta)' + Jamaica: '牙買加 (Jamaica)' + Japan: '日本 (Japan)' + Java: '爪哇 (Java)' + Jeju: '濟州市 (Jeju)' + Jersey: '澤西島 (Jersey)' + Jerusalem: '耶路撒冷 (Jerusalem)' + Jordan: '約旦 (Jordan)' + Juba: '朱巴 (Juba)' + Kabul: '喀布爾 (Kabul)' + Kaliningrad Oblast: '加里寧格勒州 (Kaliningrad Oblast)' + Kampala: '坎帕拉 (Kampala)' + Kathmandu: '加德滿都 (Kathmandu)' + Kazakhstan: '哈薩克 (Kazakhstan)' + Kenya: '肯亞 (Kenya)' + Khartoum: '喀土穆 (Khartoum)' + Kigali: '吉佳利 (Kigali)' + Kingston: '京斯敦 (Kingston)' + Kingstown: '金石城 (Kingstown)' + Kinshasa: '金夏沙 (Kinshasa)' + Kiribati: '吉里巴斯 (Kiribati)' + Known as Nur-Sultan between 2019 and 2022: '2019年至2022年間稱為Nur-Sultan' + Known as Swaziland until 2018.: '' + Kosovo: '科索沃 (Kosovo)' + Kuala Lumpur: '吉隆坡 (Kuala Lumpur)' + Kuwait: '科威特 (Kuwait)' + Kuwait City: '科威特市 (Kuwait City)' + Kyiv: '基輔 (Kyiv)' + Kyrgyzstan: '吉爾吉斯 (Kyrgyzstan)' + Laayoune: '阿尤恩 (Laayoune)' + Labrador Sea: '拉布拉多海 (Labrador Sea)' + Laos: '寮國 (Laos)' + Latvia: '拉脫維亞 (Latvia)' + 'Latvia (darker red, narrower white band)': '拉脫維亞(更深紅、窄白線)' + Lebanon: '黎巴嫩 (Lebanon)' + Lesotho: '賴索托 (Lesotho)' + Liberia: '賴比瑞亞共和國 (Liberia)' + Libreville: '自由市 (Libreville)' + Libya: '利比亞 (Libya)' + Liechtenstein: '列支敦斯登 (Liechtenstein)' + Lilongwe: '里朗威 (Lilongwe)' + Lima: '利馬 (Lima)' + Lisbon: '里斯本 (Lisbon)' + Lithuania: '立陶宛 (Lithuania)' + Ljubljana: '盧布爾雅那 (Ljubljana)' + 'Lomé': '洛梅 (Lomé)' + London: '倫敦 (London)' + Luanda: '盧安達 (Luanda)' + Lusaka: '路沙卡 (Lusaka)' + Luxembourg: '盧森堡 (Luxembourg)' + 'Luxembourg (lighter blue)': '盧森堡(更淺藍)' + Luxembourg City: '盧森堡市 (Luxembourg City)' + Macau: '澳門 (Macau)' + Madagascar: '馬達加斯加 (Madagascar)' + Madeira: '馬德拉群島 (Madeira)' + Madrid: '馬德里 (Madrid)' + Majuro: '馬久羅 (Majuro)' + Malawi: '馬拉威共和國 (Malawi)' + Malaysia: '馬來西亞 (Malaysia)' + Maldives: '馬爾地夫 (Maldives)' + Mali: '馬利 (Mali)' + 'Mali (red and green flipped, slightly brighter green)': '馬利(紅綠對調、更淺綠)' + Malta: '馬爾他 (Malta)' + 'Malé': '馬列市 (Malé)' + Managua: '馬納瓜 (Managua)' + Manama: '麥納瑪 (Manama)' + Manila: '馬尼拉 (Manila)' + Maputo: '馬布多 (Maputo)' + Mariehamn: '瑪麗港 (Mariehamn)' + Marshall Islands: '馬爾紹群島 (Marshall Islands)' + Martinique: '馬丁尼克 (Martinique)' + Maseru: '馬賽魯 (Maseru)' + Mauritania: '茅利塔尼亞伊斯蘭共和國 (Mauritania)' + Mauritius: '模里西斯共和國 (Mauritius)' + Mayotte: '馬約特島 (Mayotte)' + Mbabane: '姆巴巴內市 (Mbabane)' + Mediterranean Sea: '地中海 (Mediterranean Sea)' + Melanesia: '美拉尼西亞 (Melanesia)' + Mexico: '墨西哥 (Mexico)' + Mexico City: '墨西哥城 (Mexico City)' + Micronesia: '密克羅尼西亞 (Micronesia)' + Minsk: '明斯克 (Minsk)' + Mogadishu: '摩加迪休 (Mogadishu)' + Moldova: '摩爾多瓦 (Moldova)' + 'Moldova (wider, coat of arms with eagle)': '摩爾多瓦(更寬、老鷹)' + Monaco: '摩納哥 (Monaco)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': '摩納哥(更窄、更深紅)、波蘭(紅白對調、更深紅)' + Mongolia: '蒙古 (Mongolia)' + Monrovia: '蒙羅維亞 (Monrovia)' + Montenegro: '蒙特內哥羅 (Montenegro)' + Montevideo: '蒙特維多市 (Montevideo)' + Morocco: '摩洛哥 (Morocco)' + Moroni: '莫洛尼 (Moroni)' + Moscow: '莫斯科 (Moscow)' + Mozambique: '莫三比克共和國 (Mozambique)' + Muscat: '馬斯開特 (Muscat)' + Myanmar: '緬甸 (Myanmar)' + 'N''Djamena': '恩加美納 (N''Djamena)' + Nairobi: '奈洛比 (Nairobi)' + Namibia: '納米比亞共和國 (Namibia)' + Nassau: '拿騷 (Nassau)' + Nauru: '諾魯共和國 (Nauru)' + 'Nauru (single star below yellow band)': '諾魯(僅一顆星星於黃線下)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': '諾魯沒有法定首都,但亞倫是實際首都。' + Naypyidaw: '奈比都 (Naypyidaw)' + Nepal: '尼泊爾 (Nepal)' + Netherlands: '荷蘭 (Netherlands)' + 'Netherlands (darker blue)': '荷蘭(更深藍)' + New Caledonia: '新喀里多尼亞 (New Caledonia)' + New Delhi: '新德里 (New Delhi)' + New Zealand: '紐西蘭 (New Zealand)' + 'New Zealand (red stars, two fewer stars)': '紐西蘭(紅色星星、少兩顆星星)' + Ngerulmud: '恩吉魯穆德 (Ngerulmud)' + Niamey: '尼阿美 (Niamey)' + Nicaragua: '尼加拉瓜 (Nicaragua)' + 'Nicaragua (different coat of arms, slightly lighter blue)': '尼加拉瓜(不同徽章、稍淺藍)' + Nicosia: '尼克西亞 (Nicosia)' + Niger: '尼日 (Niger)' + Nigeria: '奈及利亞 (Nigeria)' + Niue: '紐埃島 (Niue)' + North America: '北美洲 (North America)' + North Korea: '北韓 (North Korea)' + North Macedonia: '北馬其頓 (North Macedonia)' + North Nicosia: '尼古西亞市 (North Nicosia)' + North Sea: '北海 (North Sea)' + Northern Cyprus: '北賽普勒斯 (Northern Cyprus)' + Northern Ireland: '北愛爾蘭 (Northern Ireland)' + Northern Mariana Islands: '北馬里亞納群島 (Northern Mariana Islands)' + Norway: '挪威 (Norway)' + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': '挪威(紅底、藍十字)' + Norwegian Sea: '挪威海 (Norwegian Sea)' + Not a sovereign country: '不是主權國家' + Nouakchott: '諾克少 (Nouakchott)' + 'Nouméa': '努美阿 (Nouméa)' + 'Nukuʻalofa': '努瓜婁發 (Nukuʻalofa)' + Nuuk: '努克 (Nuuk)' + 'Oblast (administrative region) of the Russian Federation.': '俄羅斯的州。' + Oceania: '大洋洲 (Oceania)' + Official capital was moved from Bujumbura to Gitega in 2019.: '2019年正式從布瓊布拉遷都到基特加。' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: '2026年官方首都从马拉博迁至拉巴斯城。' + 'Officially Côte d''Ivoire.': '正式名: cote d''ivoire。' + Officially Luxembourg.: '' + Oman: '阿曼王國 (Oman)' + Oranjestad: '奧拉涅斯塔德 (Oranjestad)' + Oslo: '奧斯陸 (Oslo)' + Ottawa: '渥太華 (Ottawa)' + Ouagadougou: '瓦加杜古 (Ouagadougou)' + Overseas department of France.: '法國的海外省。' + Overseas territory of France.: '法國的海外領土。' + Overseas territory of the United Kingdom.: '英國的海外領土。' + Pacific Ocean: '太平洋 (Pacific Ocean)' + Pakistan: '巴基斯坦 (Pakistan)' + Palau: '帛琉 (Palau)' + Palestine: '巴勒斯坦 (Palestine)' + 'Palestine (black/white/green, red arrow)': '巴勒斯坦(黑白綠、紅色箭頭)' + 'Palestine (no symbol)': '巴勒斯坦(無標誌)' + Palikir: '帕里克爾 (Palikir)' + Panama: '巴拿馬 (Panama)' + Panama City: '巴拿馬城 (Panama City)' + Papeete: '帕皮提 (Papeete)' + Papua New Guinea: '巴布亞紐幾內亞 (Papua New Guinea)' + Paraguay: '巴拉圭 (Paraguay)' + Paramaribo: '巴拉馬利波 (Paramaribo)' + Paris: '巴黎 (Paris)' + Partially recognised state claimed by China.: '中國宣稱擁有該地區主權,但僅有部分國家承認。' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: '摩洛哥宣稱擁有該地區主權,但僅有部分國家承認。又稱為西撒哈拉。' + Partially recognised state claimed by Serbia.: '塞爾維亞宣稱擁有該地區主權,但僅有部分國家承認。' + Persian Gulf: '波斯灣 (Persian Gulf)' + Peru: '祕魯 (Peru)' + Philippine Sea: '菲律賓海 (Philippine Sea)' + Philippines: '菲律賓 (Philippines)' + Phnom Penh: '金邊 (Phnom Penh)' + Podgorica: '波德戈里察 (Podgorica)' + Poland: '波蘭 (Poland)' + Polynesia: '玻里尼西亞 (Polynesia)' + Port Louis: '路易士港 (Port Louis)' + Port Moresby: '莫士比港 (Port Moresby)' + Port Vila: '維拉港 (Port Vila)' + Port of Spain: '西班牙港 (Port of Spain)' + Port-au-Prince: '太子港 (Port-au-Prince)' + Porto-Novo: '新港 (Porto-Novo)' + Portugal: '葡萄牙 (Portugal)' + Prague: '布拉格 (Prague)' + Praia: '培亞 (Praia)' + Pretoria, Cape Town, Bloemfontein: '普利托利亞、開普敦、布隆泉 (Pretoria, Cape Town, Bloemfontein)' + Pristina: '普里斯提納 (Pristina)' + Puerto Rico: '波多黎各 (Puerto Rico)' + 'Puerto Rico (blue triangle, red stripes)': '波多黎各(藍三角、紅條紋)' + Pyongyang: '平壤 (Pyongyang)' + Qatar: '卡達 (Qatar)' + 'Qatar (wider, more serrated edges, maroon)': '卡達(更寬、更多鋸齒狀邊緣、栗紅色)' + Quito: '基多市 (Quito)' + Rabat: '拉巴特 (Rabat)' + Red Sea: '紅海 (Red Sea)' + Region of France.: '法國的地區。' + Republic of the Congo: '剛果共和國 (Republic of the Congo)' + 'Reykjavík': '雷克雅維克 (Reykjavík)' + Riga: '里加 (Riga)' + Riyadh: '利雅德 (Riyadh)' + Romania: '羅馬尼亞 (Romania)' + 'Romania (slightly lighter blue)': '羅馬尼亞(稍微淺藍)' + Rome: '羅馬 (Rome)' + Roseau: '羅索 (Roseau)' + Russia: '俄羅斯 (Russia)' + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': '俄羅斯(沒徽章)、斯洛維尼亞(更寬、更小的徽章)' + Rwanda: '盧安達 (Rwanda)' + 'Réunion': '留尼旺島 (Réunion)' + Sahrawi Arab Democratic Republic: '撒拉威阿拉伯民主共和國(西薩哈拉) (Sahrawi Arab Democratic Republic)' + Saint Kitts and Nevis: '聖克里斯多福及尼維斯聯邦 (Saint Kitts and Nevis)' + Saint Lucia: '聖露西亞 (Saint Lucia)' + Saint Martin: '聖馬丁 (Saint Martin)' + Saint Vincent and the Grenadines: '聖文森及格瑞那丁 (Saint Vincent and the Grenadines)' + Samoa: '薩摩亞 (Samoa)' + 'San José': '聖荷西 (San José)' + San Juan: '聖胡安 (San Juan)' + San Marino: '聖馬利諾 (San Marino)' + San Salvador: '聖薩爾瓦多 (San Salvador)' + Sanaa: '沙那 (Sanaa)' + Santiago: '聖地牙哥 (Santiago)' + Santo Domingo: '聖多明哥 (Santo Domingo)' + Sarajevo: '塞拉耶佛 (Sarajevo)' + Sardinia: '薩丁尼亞 (Sardinia)' + Saudi Arabia: '沙烏地阿拉伯 (Saudi Arabia)' + Scandinavia: '斯堪地那維亞 (Scandinavia)' + Scotland: '蘇格蘭 (Scotland)' + Sea of Galilee: '加利利海 (Sea of Galilee)' + Sea of Japan: '日本海 (Sea of Japan)' + Sea of Okhotsk: '鄂霍次克海 (Sea of Okhotsk)' + Semi-autonomous region of Tanzania.: '坦尚尼亞的半自治區。' + Senegal: '塞內加爾 (Senegal)' + 'Senegal (green/yellow/red, green star)': '塞內加爾(綠黃紅、綠色星星)' + Seoul: '首爾 (Seoul)' + Serbia: '塞爾維亞 (Serbia)' + Seychelles: '塞席爾共和國 (Seychelles)' + Sicily: '西西里島 (Sicily)' + Sierra Leone: '獅子山 (Sierra Leone)' + Singapore: '新加坡 (Singapore)' + Sint Maarten: '荷屬聖馬丁 (Sint Maarten)' + Skopje: '斯科普里 (Skopje)' + Slovakia: '斯洛伐克 (Slovakia)' + 'Slovakia (narrower, bigger coat of arms)': '斯洛伐克(更窄、更大的徽章)' + 'Slovakia (with coat of arms)': '斯洛伐克(有徽章)' + Slovenia: '斯洛維尼亞 (Slovenia)' + Sofia: '索菲亞 (Sofia)' + Solomon Islands: '索羅門群島 (Solomon Islands)' + Somalia: '索馬利亞 (Somalia)' + Somaliland: '索馬利蘭共和國 (Somaliland)' + South Africa: '南非 (South Africa)' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': '南非沒有法定首都,並有三大城市:普勒托利亞(行政)、開普敦(立法)、布隆方丹(司法)。' + South America: '南美洲 (South America)' + South China Sea: '南海 (South China Sea)' + South Korea: '韓國(南韓) (South Korea)' + South Ossetia: '南奧塞梯 (South Ossetia)' + South Sudan: '南蘇丹 (South Sudan)' + South Tarawa: '南塔拉瓦 (South Tarawa)' + Southern Ocean: '南冰洋 (Southern Ocean)' + Sovereign country: '主權國家' + Spain: '西班牙 (Spain)' + Special Administrative Region of China.: '中國的特別行政區。' + Sri Jayawardenepura Kotte: '可倫坡、 斯里賈亞瓦德納普拉科特 (Sri Jayawardenepura Kotte)' + Sri Lanka: '斯里蘭卡 (Sri Lanka)' + 'St. George''s': '聖喬治市 (St. George''s)' + 'St. John''s': '聖約翰 (St. John''s)' + State of the United States.: '美國的州。' + State recognised only by Turkey and claimed by Cyprus.: '只被土耳其承認,賽普勒斯宣稱擁有該地區主權。' + Stockholm: '斯德哥爾摩 (Stockholm)' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: '大洋洲次區域,由太平洋中部和南部的數千個小島嶼組成。' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: '大洋洲次區域,由西太平洋數千個小島嶼組成。' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: '大洋洲次區域,包括萬那杜、所羅門群島、斐濟和巴布亞紐幾內亞。' + Sucre: '蘇克瑞 (Sucre)' + Sudan: '蘇丹 (Sudan)' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': '蘇丹(紅白黑、綠色箭頭)、西撒哈拉(星星與新月)' + Sukhumi: '蘇呼米 (Sukhumi)' + Sumatra: '蘇門答臘 (Sumatra)' + Suriname: '蘇利南 (Suriname)' + Suva: '蘇瓦 (Suva)' + Svalbard: '斯瓦巴 (Svalbard)' + Sweden: '瑞典 (Sweden)' + Switzerland: '瑞士 (Switzerland)' + 'Switzerland has no official capital; Bern is the de facto capital.': '瑞士沒有法定首都,但伯爾尼是實際首都。' + Syria: '敘利亞 (Syria)' + 'São Tomé': '聖多美 (São Tomé)' + 'São Tomé and Príncipe': '聖多美普林西比民主共和國 (São Tomé and Príncipe)' + Taipei: '台北 (Taipei)' + Taiwan: '台灣(中華民國) (Taiwan)' + Tajikistan: '塔吉克共和國 (Tajikistan)' + Tallinn: '塔林 (Tallinn)' + Tanzania: '坦尚尼亞 (Tanzania)' + Tashkent: '塔什干 (Tashkent)' + Tasman Sea: '塔斯曼海 (Tasman Sea)' + Tbilisi: '第比利斯 (Tbilisi)' + Tegucigalpa: '德古西加巴 (Tegucigalpa)' + Tehran: '德黑蘭 (Tehran)' + Thailand: '泰國 (Thailand)' + The Bahamas: '巴哈馬 (The Bahamas)' + The Gambia: '甘比亞共和國 (The Gambia)' + Thimphu: '辛布市 (Thimphu)' + Timor Sea: '帝汶海 (Timor Sea)' + Timor-Leste: '東帝汶 (Timor-Leste)' + Tirana: '地拉那 (Tirana)' + Tiraspol: '基希涅夫 (Tiraspol)' + Togo: '多哥 (Togo)' + Tokyo: '東京 (Tokyo)' + Tonga: '東加 (Tonga)' + Transnistria: '德涅斯特河沿岸共和國 (Transnistria)' + Trinidad and Tobago: '千里達及托巴哥 (Trinidad and Tobago)' + Tripoli: '的黎波里 (Tripoli)' + Tskhinvali: '茲辛瓦利 (Tskhinvali)' + Tunis: '突尼斯 (Tunis)' + Tunisia: '突尼西亞 (Tunisia)' + Turkey: '土耳其 (Turkey)' + Turkmenistan: '土庫曼 (Turkmenistan)' + Turks and Caicos Islands: '土克凱可群島 (Turks and Caicos Islands)' + Tuvalu: '吐瓦魯 (Tuvalu)' + 'Tórshavn': '托爾斯港 (Tórshavn)' + Uganda: '烏干達 (Uganda)' + Ukraine: '烏克蘭 (Ukraine)' + Ulaanbaatar: '烏蘭巴托 (Ulaanbaatar)' + Unincorporated internal area of Norway.: '挪威非建制地區。' + Unincorporated territory of the United States.: '美國的屬地。' + United Arab Emirates: '阿拉伯聯合大公國 (United Arab Emirates)' + United Kingdom: '英國 (United Kingdom)' + United States Virgin Islands: '美屬維京群島 (United States Virgin Islands)' + United States of America: '美國(美利堅合眾國) (United States of America)' + Uruguay: '烏拉圭 (Uruguay)' + Uzbekistan: '烏茲別克 (Uzbekistan)' + Vaduz: '瓦杜茲 (Vaduz)' + Valletta: '瓦萊塔 (Valletta)' + Vanuatu: '萬那杜 (Vanuatu)' + Vatican City: '梵蒂岡 (Vatican City)' + Venezuela: '委內瑞拉 (Venezuela)' + Victoria: '維多利亞 (Victoria)' + Vienna: '維也納 (Vienna)' + Vientiane: '永珍 (Vientiane)' + Vietnam: '越南 (Vietnam)' + Vilnius: '維爾紐斯 (Vilnius)' + Wales: '威爾斯 (Wales)' + Wallis and Futuna: '瓦利斯群島和富圖那群島 (Wallis and Futuna)' + Warsaw: '華沙 (Warsaw)' + Washington, D.C.: '華盛頓DC (Washington, D.C.)' + Wellington: '威靈頓 (Wellington)' + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: '阿姆斯特丹是正式首都,行政立法首都為海牙。' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: '雖然多多馬是法定首都,但三蘭港是實際首都。' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': '雖然阿尤恩是法定首都,但是提法里提實際首都。' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: '雖然姆巴巴納是法定、行政首都,洛班巴是傳統、精神、立法首都。' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: '雖然波多諾伏是法定首都,但柯多努是實際首都。' + While Sucre is the constitutional capital, La Paz is the seat of government.: '雖然蘇克雷是法定首都,但是拉巴斯政府所在。' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: '雖然亞穆蘇克羅是法定首都,但阿必尚是實際首都。' + White Sea: '白海 (White Sea)' + Willemstad: '威廉城 (Willemstad)' + Windhoek: '溫荷克 (Windhoek)' + World region covering the Australian continent and most of the islands in the Pacific Ocean.: '覆蓋澳洲大陸和太平洋大部分島嶼的區域。' + Yamoussoukro: '雅穆索戈 (Yamoussoukro)' + 'Yaoundé': '雅溫德 (Yaoundé)' + Yaren: '雅連 (Yaren)' + Yellow Sea: '黃海 (Yellow Sea)' + Yemen: '葉門 (Yemen)' + Yerevan: '耶烈萬 (Yerevan)' + Zagreb: '薩格勒布 (Zagreb)' + Zambia: '桑比亞 (Zambia)' + Zanzibar: '尚吉巴 (Zanzibar)' + Zimbabwe: '辛巴威 (Zimbabwe)' + 'Åland Islands': '奧蘭群島 (Åland Islands)' + additions: + notes.note.kiribati.fields.field.capital-info: '拉巴斯正在建造來取代馬拉波成為首都。' + variables: + label.capital: + Capital: '首都' + label.capital-hint.answer: + 'Hint: {{Capital hint}}': '提示:{{Capital hint}}' + label.flag: + Flag: '旗幟' + label.location: + Location: '位置' + note-type.name: + Ultimate Geography: 'Ultimate Geography [ZH-TW]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': '旗幟類似於:{{Flag similarity}}。' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'hP;i-k*Lq&' + '5JJ2]i)p4': 's%K%uD#*sw' + '5S(f7%O-$': 'we_2BuB.Z+' + '?n!)c/h=Q': 'wONP|LX.Q_' + 'AES],s:8=': 'rg~Oxq&v,q' + 'B:xq' + 'G/^_##fcPN': 'G9wDHnt]lR' + 'Gn;q2Y)Xe4': 'yb_9K`wphY' + 'H*c[ML+`s=': 'w;##5l={aM' + 'He!e=h?_2V': 'gkNaPEci^F' + 'J%,fuysl*&': 'MLn%?@{3-5' + 'JOT:U4ayh@': 'IxuFPY]!|V' + 'K*b}ip' + 'Lol+XIvY21': 'r=GA`|/%}m' + 'M$bc0[jYf0': 'nzVFM(2hen' + 'O~pXkAbd3': 'qG*a}3UG)u' + 'QSq%~rFaP-': 'sUt(spIN=3' + 'Qlib4)q2Hk': 'gRvIS4^Lge' + 'SlOEXfq#|': 'GfT}?H(Itk' + 'W5?6yQZD2': 'yLf3$zA,NG' + '_]DH+g9!$': 'A[&}6tcP(D' + 'b@M[XTc?}5': 'QA2Z`&%>+{' + 'bD?R}i8_[c': 'w~+;g=4]lx' + 'bW)ch:vs1P': 'Gp[PE61fhV' + 'b[(2`4t&?J': 'QZnt.4(%mo' + 'b^Try&me1A': 'ip)J{' + 'c(,2{yXI#E': 'KN=}b8)Yv%' + 'c,5(mUGzYT': 'C-=fx`$bT}' + 'cAlyc~G.y%': 'vm0Z5,/o[~' + 'cEO^y)Md[G': 'wd}GQaBf8^' + 'cL?8!xP#Wj': 'MPN<8x{bf#' + 'cPO|Cru8n<': 'd0,7(>HBr,' + 'cV4idO^r8W': 'C$4G)9=It:' + 'cYKvF#ptF7': 'OcX5@&/D_t' + 'c^,~EC6Pb2': 'O%hMRDX^>9' + 'ck!6;xK$/^': 'EkZVD=RUj#' + 'cv+Nq9:aZU!P_' + 'dbP/Cj}sB+': 'j,}M=b.Icy' + 'de=9c:,@j9': 'Q*%#@o})>]' + 'dlU7VG|3Gd': 'GAjT.#!J1@' + 'd}R:qHj1,7': 'Jr]Rx$vdq$' + 'e!KSebP}pt': '?:,j5Yjyl' + 'e+/O]%*qfk': 'CpdOVg*sVU' + 'e.t$Vi5,>f': 'wXGUH~)Ql(' + 'e42BpV,P}C': 'g;e5dQC_N=' + 'e5P*D#I{m{': 'lR/BZ##`|O' + 'eD' + 'e?h=Xhs+K&': 'AW_@tSbsx2' + 'eALZe^HCHq': 'vjf^W|(*8<' + 'eHJ?)TAdTo': 'PNd$F4p&3c' + 'eN#^kPGv*L': iH_T4wLlp, + 'eTHH?U': 'D}wop3Sg;c' + 'euW0jm=wfR': 'A-#|rzmH_H' + 'e{e8vi@^PY': 'O@9XZJX!0PN': c.ecIN49mi + 'f21=-A~W;~': 'FiNf!6B`-A' + 'f>c7x2E]Q6': 'm5V-SM=<@2' + 'fRSM{K+;[w': 'BFtu|[LXC[' + 'fT!w8R#>dg': 'MNe>6KJz>Q' + 'fV`&p81%d(': F9Gg3P7TV7 + fkq7lhLkdH: 'r`ewLw!Gt^' + 'fxAN),FAc$': 'Wu<%L~@!R' + 'g-]bb&[.E!': 'FkNL`}5EmT' + 'g9v~y/;=z(': 'z]rT_bEg+8' + 'g<[f89~U)A': 'l+c!U^Cm[3' + 'gBCR?!O*;E': 'F=!;a!Xf#Z' + 'gB|K:+r?1V': 'D88*#1:8B]' + 'gDsLPj(9S#': 'eQS2[,]W[c' + 'gFOojQO(MH': jxChKhrR_f + 'gG6SWfM>H0': 'NLqA4ocW^(' + 'gRVZ]qJ#>O': 'b=f8dwxf6,' + 'gYIoR`|AxW': 'F%ZvaD~rn$' + 'gePC:Xt_nW': 'dH5->JA4?,' + 'gp[h@]y13l': 'gj79~OV>hd' + 'gsS{jR,]|Q': 'EGG=`g;_2a' + 'gx:;za!?C9': 'xy{u)[Y5T.' + 'gz9${p}er*': 'i_WupG5`NH' + 'g~%pF`(x{u': 'x)P#4EEu03' + 'h&`>n98~V1': 'sUU#6=tmRv' + 'h9Jr(H[=1~': 'Q9jU$.Ei4q' + 'h?a*UKfT_M': 's#8JOS3OzS' + 'hE5j*e,Q?[': 'bUSqk+D|{;' + 'hNE9}>;Q&-': 'v0nE;h~kZ~' + 'hXNTVF<:SH': 'xpoQrEE]': 'iwf|+Y-DMZ' + 'h}lnSN_f$_': 'il_3&ea+_9' + 'h~5xz+=ke~': 'tC&_:#$T2i' + 'i)]+09JUR&': 't0PNjEDy~7' + 'i2]v|D3@:j': '^+oKH:A=t' + 'iB-8Br~onq': 'NPUF#o!QS-' + 'iIS#Nw]tr]': 'extWrMavp*' + 'iR;Um(L/~c=': 'xcDnaIk^fK' + 'i^J8&$(1]|': 'EF`@1,WVBK' + 'ia8as9sCY|': 'CQar?B#q*u' + 'igiK,ff[eO': '4iE[{aD4K' + 'ijP>aJBT!9': 'Po[p>b@7Jo' + 'ilGtw#=asM': 'l5`IXuhK?7' + 'j%*8gN)q>d': 'PjuCASX!1!' + 'j)i)pB*HJ,': ePKaTy,DGR + 'j2IQ=f=w3@': 'rv)qyS:)2O' + j7eEZkzsCZ: 'EH/@$WQER7' + 'j9:K,~DQ2v': 'n4>@0j_wL[' + 'jg|}': 'yHyzT~7ggt' + 'jCd]`-=k,:': 'o8gfBsL4(;' + 'jDA-V?/hVj': 'KRhqtz)r:' + jI9P-f6r3M: 'l>/)Hug!mW' + 'jKIDq|}J&c': 'p#pLS.CKZC' + 'jKO,9y0M;#': 'Bh#Ve`j#UW' + 'jN^Vc%9OQ5': 'q}(1`DT.am' + 'jN|NAUP*h}': 'uG2|*|Yh}_' + 'jTNoKo}Bu+': 'r>T|)f-nqo' + 'jX=#G8wu#(': 'fdc=:,[<%A' + 'jYz[ibrr9m': wTDfQYhjoa + 'jcu!gLw/&r': 'xIHB(EY-2e' + 'jrM28*HbyG': 's(o*X{,=M~' + 'j|z@PMgdx,': 'q@%w%D:POo' + 'k$1_yaF?9#': 'D9qv;Owm8Y' + 'k/u1:B%DJH': 'EvKZ+P|nmE' + 'k0{O[6l]bH': 'Mf#0M*Y}7m' + 'k1]FH+s8j@': zYu.6VjeUn + 'k9E.p^H3&w' + 'kE!iKHLN_g': 'E^+}$Fd}]X' + 'kQ:NIhJy~E': 'Gu^T-,V0xb' + 'kS)hDm%w}R': 'EazycZ_Z^_' + 'kX]hN=_.c-': oRMP/z9PW4 + 'kZ52zV#[Iv': 'fO-dah>=g>' + 'k[)#L4.[v3': 'Q1_h^!DKi!j#v+': 'sAcpxOzKv[' + 'l3Ly8PBxt(': 'f8O7+6f;RR' + 'lAxH.CfJB@': 'f>P{9v2]Bz' + 'lH^2D]w;[H': 'bqj>}eJA-L' + 'lU@C3IDn' + 'lhWI;$JC9(': 'I|[UZ!jURX' + libliptkB4: 'L^O|)G!]GZ' + 'lmRujD!(A7': 'iVnn1=xi{4' + 'ln^3p)j3b.': rNkWo7rM8m + 'lp@e##m.!+': 'b*bdAyf+0*' + 'lpbD0St&OU': 'e~PdHQDvCH' + 'lq`T^ZBF2Y': 'v[n(vSU^>b' + 'l}[BG9.nQS': 'O$abbk6n:u' + 'm)WUtJS`H&': 'Aj|9mQ]LS`' + 'm*#%sE.`.;': 't4{:V^Zw/O' + 'm*PYE=#wr^': 'qu:q$z3/}3' + 'm2F|cUkKhv': 'w-@AZOCawT' + 'm5j,b/y' + mJH4kwPF.-: 'x&b*o/}?f+' + 'm[96fa+HDS': 'sEq1@q]mMW' + mbGXc0y3Pe: 'Jl+~MwbBm-' + 'mbz=7n[0v9': 'c&t,@V?gkS' + 'mcc&0Kq^xE': ';jH7jP541' + 'mi+kf0LTW.': 'Q(VLcfD->n' + 'mk1VltJl<]': hoD/2dfvqx + 'mk20`,N=`=': 'uG9NEoHA_;' + 'mkgcU7[bA%': 'c^8$Lj5E?5' + 'mq)8`hi{hb': 'z:Rh_xOA1G' + 'mtd;>7T|g>': 'Q>AxIr4': 'gasQ]L`h5j' + 'm{noqE29%I': 'F.<0drjn&]' + 'm{oM,jr>C#': 'eA`%lT9XaC' + 'n&>e`2FO/S': 'cH>zn$fA;c' + 'n+t>2`:;P/': 'FM9N-?A5Ml' + 'n:7&>YT5A=': 'NSInc{q%IT' + 'nIfRn/yz': 'pr(?+A]W}t' + 'oa87&PB&Bm': 'Q:^I|cS~DH' + 'onMKID2d;D': 'If){M~;KV>' + 'o~}KVA:VbI': 'sA4?<]nph#' + 'p#R$-mG/L': 'xJp80dr|*d' + 'p.yv>BQ470': 'rs2^LqZyrx' + 'p:SPZ:e/*[': 'H]k$=x-e-{' + 'pDDrFhd%u': 't9TR?}R~%(' + 'pYRw[=6;JI': 'ENyS3po73(' + 'p]t8:Tka7q': 'Lt&mV$ktKh' + pkj3RPBUrF: 'gK5X3$8iIZ' + 'pwwZu{7f~(': 'N_~b;-nO>k' + 'pz(G*g': 'x%fcV]x!1_' + 'q2#Yv[O0:|': 's7Ivo{cHUY' + 'q7do-wLx%E': 'J#8B}2kf`U' + 'q8][)Ri}=q': 'Gk(tda>R/G' + 'q8fF$4,c6_': 'rNOpE,.$2?' + 'q:XyGf#>Wg': 'Jo|8kWbNKx' + 'q=w[' + 'qVi;t%/`F|': 'l7l~M4fz$?' + 'q[S4`,F0D=': 'm*g7V&~U8!' + 'q[}7hk(+=e': 'p@qz|I2nB5' + 'qiv/4tOlf=': 'Kg#ygW-[r' + qvITjNxkLN: 'oxS[,m0cv0' + 'qx%>fB$d+$': 'gBm$0u(##-' + 'r(h9&#wc~B': wacQ,48J1. + 'r/B|ra%L,.': '1;U0O.*[;' + 'r5C646H*+*': 'gMjU[c/jTb' + 'r6BV%|..*5': 'L+?uN^YOS5' + 'raI-$$|wT': 'kiaWqH-#7N' + 'r>g<|,FWi%': 'qK|#LatRJ9' + 'r?E$4f-d0/': P/Qv,VDEKQ + 'rPekx@n9:L': 'pRgD#Wsw2^' + 'rSneU!3}bn': 'o5,a23d~xt' + 'r]*Ioa%$bK': 'e]bdeJ!9Y9' + 's#:aIK$9^x': 'QsVUPdid;>' + 's4E?ZF[E<>': 'q^.I~1sjd/' + 's9-GL*@AXD': 's*-,K{=?iY' + 's<_WLSB3I/': 'QU($n4/X`2' + 's>f40rn,O]': 'f5K4q}cbI+' + 'sB7rZQsR@9w~i^p': 'O.Q96?A)d<' + 'skOM(?n$GG': 'Max>1>F1Glt/' + 'ssbyA9}]QP': 'A63kOvd::%' + 'stRa/S(0$F': LiEC8Ub3M8 + 'sxOMs+bE8@': 'I$=}<6%^YZ' + 'sx|l+io`B@': 'AZ}#k|F]bu>P': 'Enj/0}MeE0' + 't2&ri|[Un:': 'ufqRE1R}N|' + t3aZXtpNyi: 'v^*lp145,O82' + tfCY4j6KEZ: 'f7H42;?aOG' + 'tj&bK}%P!F': 'peu`}Xvu$<' + 'twrhzJ[k:Z': 'KJ7-X>}A,U' + 't|h3|;E4g{': 'sc15KG*9]<' + 'u-`cspdp)D': 'qiTMK38ei(' + 'u4^lH.xem5': 'FIs`1u}Odu' + 'u;|vR#mVMW': 'k/b,[~~NHp' + 'uH*Q(mcRt5': 'pT|UHy5*l&' + 'uRo^w#p5Be': 'CwUYOu3jq^' + 'uc61ToT%gt': 'x@%548M,.>' + 'ui3>b-?a@m': 'w;7hg#' + 'u|I}oElaH)': 'K^{=PMLj&`' + 'vE[T$`n9T)': 'h3jZfpL|`9' + 'vKx<0Qa!aP': 'BEF$8wbVr:' + 'vNiD6A.v#s': 'qOdNh:f:iF' + 'va/ky=f5pK': 'kS{CQ@,$y|' + 'vf)G?[_?Pf': 'DOhb!^w01(' + 'vgdj#X?8dB': 'c[b7Gzotv2' + 'vpOTK6tMr@': 'mg;tA3T#.4' + 'vppEA|$@~g': 'Ev5}H0X%#/' + 'vrn%j%6{nu': 'wkF*9:~90B' + 'vwc3}.=Z#&': 'v,6i%=6]ZK' + 'wa>{k!2cXc': 'k.>hAR#~`y' + x2d4n4ADcP: 'F&`S7L11,M' + 'xb&P*pH(Q': 'epq0k>2D`}' + 'xqrJ^d[.(O': 'k-Xc-G%>[C' + 'yX:tmrd5;]': 'q?nInVZ5|s' + 'z:*d{z(~V2': 'u;+Tt:Wc_*' + 'zSc]-^U=0=': qHwhDpenXZ + crowdanki:uuid: + 43c5ba66-9a65-11e8-90c9-a0481cc15658: 3d365148-83a7-4b53-8dbc-e3b183524025 + 43e2586a-9a65-11e8-a777-a0481cc15658: d75ad494-71f3-4909-beda-b80ec04c7a0d +deck: + name: + intent: replace + value: 'Ultimate Geography [ZH-TW]' + expected_base: + value: Ultimate Geography + description: + intent: replace + value: | + 完整說明 | 發布版本 | 貢獻指南 + + Ultimate Geography v5.3 包含: + + - 全世界 205 個主權國家 (820 張卡片) + - 59 個領土、世界區域和其他實體 (103 張卡片) + - 48 個海洋 (48 張卡片,僅有地圖) + - 7 個大陸 (7 張卡片,僅有地圖) + - 總共 319 個不同的筆記, 978 張卡片, 221 個國旗 以及 319 個地圖. + + 本牌組 包含 英文(English), 德文(German), 西班牙文(Spanish), 法文(French), 挪威語(Norwegian), 捷克語(Czech), 俄語(Russian), 荷蘭語(Dutch), 瑞典語(Swedish), 葡萄牙文(Portuguese), 簡體中文(Simplified Chinese/ZH), 繁體中文(Traditional Chinese/ZH-TW), 波蘭語(Polish), 意大利文(Italian)丹麥語(Danish)。 同時 每種語言皆有延伸版本。 為了幫助記憶和提供上下文,一些筆記帶有額外的資訊,例如類似的國旗、治理相關資訊、國家別名等。 + + 你還能使用Anki的過濾牌組功能 , 將您的學習重點放在某個種類的牌上, 例如主權國家州、一種筆記模板(例如地圖對國家)或特定大陸(例如歐洲)。 + + 這個牌組在 GitHub上維護。如果您發現錯誤、有建議或想要幫助我們,請不要猶豫打開一個問題(Issue)。 想要隨時了解新版本? 查看(Watch)我們的 GitHub Repository或者訂閱(Subscribe)發布通知(releases feed)! + expected_base: + 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! diff --git a/fixtures/ultimate-geography/overlays/languages/zh.yaml b/fixtures/ultimate-geography/overlays/languages/zh.yaml new file mode 100644 index 0000000..785bc48 --- /dev/null +++ b/fixtures/ultimate-geography/overlays/languages/zh.yaml @@ -0,0 +1,1032 @@ +id: overlay.translation.zh +kind: translation +translations: + changes: + 'A naming dispute exists between the sea''s bordering countries, with South Korea notably supporting the name East Sea.': '日本海(Sea of Japan)的周边国家之间存在命名争议,韩国(South Korea)尤其支持东海(East Sea)的名称,中国在元朝及明朝初时期称“鲸海”。' + Abkhazia: '阿布哈兹(Abkhazia)' + Abu Dhabi: '阿布扎比(Abu Dhabi)' + Abuja: '阿布贾(Abuja)' + Accra: '阿克拉(Accra)' + Addis Ababa: '亚的斯亚贝巴(Addis Ababa)' + Adriatic Sea: '亚得里亚海(Adriatic Sea)' + Aegean Sea: '爱琴海(Aegean Sea)' + Afghanistan: '阿富汗(Afghanistan)' + Africa: '非洲(Africa)' + Akrotiri and Dhekelia: '阿克罗蒂里和德凯利亚(Akrotiri and Dhekelia)' + Alaska: '阿拉斯加(Alaska)' + Albania: '阿尔巴尼亚(Albania)' + Algeria: '阿尔及利亚(Algeria)' + Algiers: '阿尔及尔(Algiers)' + Alofi: '阿洛菲(Alofi)' + Also known as Burma.: '缅甸在国际上公认有2种名字(Myanmar, Burma)。' + Also known as Cabo Verde.: '也称为佛得角(Cabo Verde)。' + Also known as Czechia.: '也被称为捷克(Czechia)。' + Also known as East Timor.: '也称为东帝汶(Timor-Leste)。' + Also known as Kiev.: '基辅在国际上公认有2种名字(Kyiv, Kiev)。' + 'Also known as Türkiye': '' + Also known as the Gulf of Siam.: '也被称为暹罗湾(Gulf of Siam)。' + Also known as the Sea of Cortez.: '也被称为科尔特斯海(Sea of Cortez)。' + 'Also spelled as Sana''a.': '' + American Samoa: '美属萨摩亚(American Samoa)' + Amman: '安曼(Amman)' + Amsterdam: '阿姆斯特丹(Amsterdam)' + Andorra: '安道尔(Andorra)' + 'Andorra (narrower, coat of arms with motto)': '安道尔(更窄,带有座右铭的纹章)' + Andorra la Vella: '安道尔城(Andorra la Vella)' + Angola: '安哥拉(Angola)' + Anguilla: '安圭拉(Anguilla)' + Ankara: '安卡拉(Ankara)' + Antananarivo: '塔那那利佛(Antananarivo)' + Antarctica: '南极洲(Antarctica)' + Antigua and Barbuda: '安提瓜和巴布达(Antigua and Barbuda)' + Apia: '阿皮亚(Apia)' + Arabian Sea: '阿拉伯海(Arabian Sea)' + Aral Sea: '咸海(Aral Sea)' + Arctic Ocean: '北冰洋(Arctic Ocean)' + Argentina: '阿根廷(Argentina)' + Armenia: '亚美尼亚(Armenia)' + Aruba: '阿鲁巴(Aruba)' + Ashgabat: '阿什哈巴德(Ashgabat)' + Asia: '亚洲(Asia)' + Asmara: '阿斯马拉(Asmara)' + Astana: '阿斯塔纳(Astana)' + 'Asunción': '亚松森(Asunción)' + Athens: '雅典(Athens)' + Atlantic Ocean: '大西洋(Atlantic Ocean)' + Australia: '澳大利亚(Australia)' + 'Australia (white stars, two more stars)': '澳大利亚(白星,多两颗星)' + Austria: '奥地利(Austria)' + 'Austria (brighter red, wider white band)': '奥地利(更亮的红色,更宽的白色带)' + Autonomous community of Spain.: '西班牙(Spain)自治区。' + Autonomous province of South Korea.: '韩国(South Korea)自治省。' + Autonomous region of Finland.: '芬兰(Finland)自治区。' + Autonomous region of Italy.: '意大利(Italy)自治区。' + Autonomous region of Papua New Guinea.: '巴布亚新几内亚(Papua New Guinea)自治区。' + Autonomous region of Portugal.: '葡萄牙(Portugal)自治区。' + Avarua: '阿瓦鲁阿(Avarua)' + Azerbaijan: '阿塞拜疆(Azerbaijan)' + Azores: '亚速尔群岛(Azores)' + Baghdad: '巴格达(Baghdad)' + Bahrain: '巴林(Bahrain)' + 'Bahrain (narrower, fewer serrated edges, red)': '巴林(更窄,锯齿边缘更少,红色)' + Baku: '巴库(Baku)' + Bali: '巴厘岛(Bali)' + Balkan Peninsula: '巴尔干半岛(Balkan Peninsula)' + Baltic Sea: '波罗的海(Baltic Sea)' + Bamako: '巴马科(Bamako)' + Banda Sea: '班达海(Banda Sea)' + Bandar Seri Begawan: '斯里巴加湾市(Bandar Seri Begawan)' + Bangkok: '曼谷(Bangkok)' + Bangladesh: '孟加拉国(Bangladesh)' + Bangui: '班吉(Bangui)' + Banjul: '班珠尔(Banjul)' + Barbados: '巴巴多斯(Barbados)' + Barents Sea: '巴伦支海(Barents Sea)' + Basseterre: '巴斯特尔(Basseterre)' + Bay of Bengal: '孟加拉湾(Bay of Bengal)' + Bay of Biscay: '比斯开湾(Bay of Biscay)' + Beijing: '北京(Beijing)' + Beirut: '贝鲁特(Beirut)' + Belarus: '白俄罗斯(Belarus)' + Belfast: '贝尔法斯特(Belfast)' + Belgium: '比利时(Belgium)' + Belgrade: '贝尔格莱德(Belgrade)' + Belize: '伯利兹(Belize)' + Belmopan: '贝尔莫潘(Belmopan)' + Benin: '贝宁(Benin)' + Bering Strait: '白令海峡(Bering Strait)' + Berlin: '柏林(Berlin)' + Bermuda: '百慕大(Bermuda)' + Bern: '伯尔尼(Bern)' + Bhutan: '不丹(Bhutan)' + Bishkek: '比什凯克(Bishkek)' + Bissau: '比绍(Bissau)' + Black Sea: '黑海(Black Sea)' + 'Bogotá': '波哥大(Bogotá)' + Bolivia: '玻利维亚(Bolivia)' + 'Bolivia (coat of arms instead of star)': '玻利维亚(徽章而不是星形)' + Bosnia and Herzegovina: '波斯尼亚和黑塞哥维那(Bosnia and Herzegovina),简称“波黑”' + Botswana: '博茨瓦纳(Botswana)' + Bougainville: '布干维尔(Bougainville)' + 'Brasília': '巴西利亚(Brasília)' + Bratislava: '布拉迪斯拉发(Bratislava)' + Brazil: '巴西(Brazil)' + Brazzaville: '布拉柴维尔(Brazzaville)' + Bridgetown: '布里奇敦(Bridgetown)' + British Virgin Islands: '英属维尔京群岛(British Virgin Islands)' + Brunei: '文莱(Brunei)' + Brussels: '布鲁塞尔(Brussels)' + Bucharest: '布加勒斯特(Bucharest)' + Budapest: '布达佩斯(Budapest)' + Buenos Aires: '布宜诺斯艾利斯(Buenos Aires)' + Bulgaria: '保加利亚(Bulgaria)' + Burkina Faso: '布基纳法索(Burkina Faso)' + Burundi: '布隆迪(Burundi)' + Cairo: '开罗(Cairo)' + Cambodia: '柬埔寨(Cambodia)' + Cameroon: '喀麦隆(Cameroon)' + 'Cameroon (green/red/yellow, yellow star)': '喀麦隆(绿/红/黄,黄星)' + Canada: '加拿大(Canada)' + Canary Islands: '加那利群岛(Canary Islands)' + Canberra: '堪培拉(Canberra)' + Cape Verde: '佛得角(Cape Verde)' + Caracas: '加拉加斯(Caracas)' + Cardiff: '卡迪夫(Cardiff)' + Caribbean Sea: '加勒比海(Caribbean Sea)' + Caspian Sea: '里海(Caspian Sea)' + Castries: '卡斯特里(Castries)' + Cayman Islands: '开曼群岛(Cayman Islands)' + Celebes Sea: '苏拉威西海(Celebes Sea, 亦称西里伯斯海)' + Celtic Sea: '凯尔特海(Celtic Sea)' + Central African Republic: '中非共和国(Central African Republic)' + Cetinje is an honorary capital.: '采蒂涅(Cetinje),黑山古都和宗教、历史中心。' + Chad: '乍得(Chad)' + 'Chad (slightly darker blue)': '乍得(略深蓝色)' + Charlotte Amalie: '夏洛特阿马利亚(Charlotte Amalie)' + Chile: '智利(Chile)' + China: '中国(China)' + 'Chișinău': '基希讷乌(Chișinău)' + City of San Marino: '圣马力诺市(City of San Marino)' + Ciudad de la Paz: '拉巴斯城(Ciudad de la Paz)' + Claimed and controlled: '声称对其拥有主权并拥有实际控制权' + Claimed but not controlled: '声称对其拥有主权但没有实际控制权' + Colombia: '哥伦比亚(Colombia)' + 'Colombia (no coat of arms)': '哥伦比亚(无国徽)' + Colombo is often referred to as the capital but Sri Jayawardenepura Kotte, a suburb of Colombo, is the official, legislative capital.: '科伦坡(Colombo)通常被称为首都,但科伦坡郊区的斯里贾亚瓦德纳普拉科特(Sri Jayawardenepura Kotte)是官方的立法首都。' + Comoros: '科摩罗(Comoros)' + Conakry: '科纳克里(Conakry)' + Constituent country in the Kingdom of Denmark.: '丹麦属地之一。' + Constituent country of the Kingdom of the Netherlands.: '荷兰(Netherlands)的一部分。' + Constituent country of the United Kingdom.: '英国属地之一。' + Cook Islands: '库克群岛(Cook Islands)' + Copenhagen: '哥本哈根(Copenhagen)' + Coral Sea: '珊瑚海(Coral Sea)' + Corsica: '科西嘉岛(Corsica)' + Costa Rica: '哥斯达黎加(Costa Rica)' + Croatia: '克罗地亚(Croatia)' + Crown dependency of the United Kingdom.: '英国(U.K.)的皇家属地。' + Cuba: '古巴(Cuba)' + 'Cuba (red triangle, blue stripes)': '古巴(红三角,蓝条纹)' + 'Curaçao': '库拉索(Curaçao)' + 'Curaçao (two stars in top-left corner)': '库拉索岛(左上角的两颗星)' + Cyprus: '塞浦路斯(Cyprus)' + Czech Republic: '捷克共和国(Czech Republic)' + Dakar: '达喀尔(Dakar)' + Damascus: '大马士革(Damascus)' + Dead Sea: '死海(Dead Sea)' + Democratic Republic of the Congo: '刚果民主共和国(Democratic Republic of the Congo)' + Denmark: '丹麦(Denmark)' + Denmark Strait: '丹麦海峡(Denmark Strait)' + Dhaka: '达卡(Dhaka)' + Dili: '帝力(Dili)' + 'Disputed; claimed by Israel; Ramallah is the administrative centre.': '有争议的领土; 以色列(Israel)声称对其拥有主权; 拉马拉(Ramallah)是行政中心。' + 'Disputed; claimed by Palestine.': '有争议的领土; 巴勒斯坦(Palestine)声称对其拥有主权。' + Djibouti: '吉布提(Djibouti)' + Dodoma: '多多马(Dodoma)' + Doha: '多哈(Doha)' + Dominica: '多米尼加(Dominica)' + Dominican Republic: '多米尼加共和国(Dominican Republic)' + Dublin: '都柏林(Dublin)' + Dushanbe: '杜尚别(Dushanbe)' + East China Sea: '东海(East China Sea)' + East Siberian Sea: '东西伯利亚海(East Siberian Sea)' + Ecuador: '厄瓜多尔(Ecuador)' + 'Ecuador (with coat of arms)': '厄瓜多尔(有国徽)' + Edinburgh: '爱丁堡(Edinburgh)' + Egypt: '埃及(Egypt)' + 'Egypt (emblem instead of text), Yemen (no text)': '埃及(标志代替文字)、也门(无文字)' + 'Egypt (with emblem), Iraq (with text)': '埃及(带徽章)、伊拉克(带文字)' + El Salvador: '萨尔瓦多(El Salvador)' + 'El Salvador (different coat of arms, slightly darker blue)': '萨尔瓦多(不同的纹章,略深的蓝色)' + England: '英格兰(England)' + English Channel: '英吉利海峡(English Channel)' + Equatorial Guinea: '赤道几内亚(Equatorial Guinea)' + Eritrea: '厄立特里亚(Eritrea)' + Estonia: '爱沙尼亚(Estonia)' + Eswatini: '埃斯瓦蒂尼(Eswatini)' + Ethiopia: '埃塞俄比亚(Ethiopia)' + Europe: '欧洲(Europe)' + European Union: '欧盟(European Union)' + Falkland Islands: '福克兰群岛(Falkland Islands)' + Faroe Islands: '法罗群岛(Faroe Islands)' + Federated States of Micronesia: '密克罗尼西亚联邦(Federated States of Micronesia)' + Fiji: '斐济(Fiji)' + Finland: '芬兰(Finland)' + Formerly Zaire.: '原名扎伊尔(Zaire)。' + Formerly known as Macedonia.: '原名马其顿(Macedonia)。' + France: '法国(France)' + Freetown: '弗里敦(Freetown)' + French Guiana: '法属圭亚那(French Guiana)' + French Polynesia: '法属波利尼西亚(French Polynesia)' + Funafuti: '富纳富提(Funafuti)' + Gabon: '加蓬(Gabon)' + Gaborone: '哈博罗内(Gaborone)' + Georgetown: '乔治敦(Georgetown)' + Georgia: '格鲁吉亚(Georgia)' + Germany: '德国(Germany)' + Ghana: '加纳(Ghana)' + 'Ghana (star instead of coat of arms)': '加纳(星而不是国徽)' + Gibraltar: '直布罗陀(Gibraltar)' + Gitega: '基特加(Gitega)' + Greece: '希腊(Greece)' + Greenland: '格陵兰(Greenland)' + Grenada: '格林纳达(Grenada)' + Guadeloupe: '瓜德罗普岛(Guadeloupe)' + Guam: '关岛(Guam)' + Guatemala: '危地马拉(Guatemala)' + Guatemala City: '危地马拉市(Guatemala City)' + Guernsey: '根西岛(Guernsey)' + Guinea: '几内亚(Guinea)' + 'Guinea (green and red flipped, slightly darker green)': '几内亚(绿色和红色翻转,绿色略深)' + Guinea-Bissau: '几内亚比绍(Guinea-Bissau)' + Gulf of Alaska: '阿拉斯加湾(Gulf of Alaska)' + Gulf of California: '加利福尼亚湾(Gulf of California)' + Gulf of Carpentaria: '卡奔塔利亚湾(Gulf of Carpentaria)' + Gulf of Guinea: '几内亚湾(Gulf of Guinea)' + Gulf of Mexico: '墨西哥湾(Gulf of Mexico)' + Gulf of Thailand: '泰国湾(Gulf of Thailand)' + Guyana: '圭亚那(Guyana)' + 'Hagåtña': '哈加特纳(Hagåtña)' + Haiti: '海地(Haiti)' + Hanoi: '河内(Hanoi)' + Harare: '哈拉雷(Harare)' + Hargeisa: '哈尔格萨(Hargeisa)' + Havana: '哈瓦那(Havana)' + Hawaii: '夏威夷(Hawaii)' + Helsinki: '赫尔辛基(Helsinki)' + Historical and cultural region in Northern Europe, which includes the countries of Denmark, Norway and Sweden, and sometimes Finland and Iceland.: '北欧(Northern Europe)的历史和文化区域,包括丹麦(Denmark)、挪威(Norway)和瑞典(Sweden),有时也包括芬兰(Finland)和冰岛(Iceland)。' + Honduras: '洪都拉斯(Honduras)' + Hong Kong: '香港(Hong Kong)' + Honiara: '霍尼亚拉(Honiara)' + Hudson Bay: '哈德逊湾(Hudson Bay)' + Hungary: '匈牙利(Hungary)' + Iceland: '冰岛(Iceland)' + 'Iceland (blue background, red and white cross), Norway (red background, blue and white cross)': '冰岛(蓝底,红白交叉),挪威(红底,蓝白交叉)' + 'Iceland (blue background, red cross), Faroe Islands (white background, red and blue cross)': '冰岛(蓝底,红十字),法罗群岛(白底,红蓝交叉)' + Independent state claimed by Georgia.: '格鲁吉亚(Georgia)宣称拥有主权的独立国家。' + Independent state claimed by Moldova.: '摩尔多瓦(Moldova)宣称拥有主权的独立国家。' + Independent state claimed by Somalia.: '索马里(Somalia)宣称拥有主权的独立国家。' + India: '印度(India)' + Indian Ocean: '印度洋(Indian Ocean)' + Indonesia: '印度尼西亚(Indonesia)' + 'Indonesia (white and red flipped, brighter red), Monaco (white and red flipped, narrower)': '印度尼西亚(白色和红色翻转,红色更亮),摩纳哥(白色和红色翻转,更窄)' + 'Indonesia (wider, brighter red), Poland (red and white flipped, wider)': '印度尼西亚(更宽,红色更亮),波兰(红色和白色翻转,更宽)' + Iran: '伊朗(Iran)' + Iraq: '伊拉克(Iraq)' + 'Iraq (text instead of emblem), Yemen (no emblem)': '伊拉克(文字代替徽记)、也门(无徽记)' + Ireland: '爱尔兰(Ireland)' + 'Ireland (orange and green flipped, wider)': '爱尔兰(橙色和绿色翻转,更宽)' + Islamabad: '伊斯兰堡(Islamabad)' + Island of Indonesia.: '印度尼西亚(Indonesia)的岛屿之一。' + Isle of Man: '马恩岛(Isle of Man)' + Israel: '以色列(Israel)' + Italy: '意大利(Italy)' + Ivory Coast: '科特迪瓦(Ivory Coast)' + 'Ivory Coast (green and orange flipped, narrower)': '科特迪瓦(绿色和橙色翻转,更窄)' + Jakarta: '雅加达(Jakarta)' + Jamaica: '牙买加(Jamaica)' + Japan: '日本(Japan)' + Java: '爪哇岛(Java)' + Jeju: '济州岛(Jeju)' + Jersey: '泽西岛(Jersey)' + Jerusalem: '耶路撒冷(Jerusalem)' + Jordan: '约旦(Jordan)' + Juba: '朱巴(Juba)' + Kabul: '喀布尔(Kabul)' + Kaliningrad Oblast: '加里宁格勒州(Kaliningrad Oblast)' + Kampala: '坎帕拉(Kampala)' + Kathmandu: '加德满都(Kathmandu)' + Kazakhstan: '哈萨克斯坦(Kazakhstan)' + Kenya: '肯尼亚(Kenya)' + Khartoum: '喀土穆(Khartoum)' + Kigali: '基加利(Kigali)' + Kingston: '金斯敦(Kingston)' + Kingstown: '金斯敦(Kingstown)' + Kinshasa: '金沙萨(Kinshasa)' + Kiribati: '基里巴斯(Kiribati)' + Known as Nur-Sultan between 2019 and 2022: '在 2019 年至 2022 年间称为努尔苏丹 (Nur-Sultan)' + Known as Swaziland until 2018.: '2018年之前被称为斯威士兰(Swaziland)。' + Kosovo: '科索沃(Kosovo)' + Kuala Lumpur: '吉隆坡(Kuala Lumpur)' + Kuwait: '科威特(Kuwait)' + Kuwait City: '科威特城(Kuwait City)' + Kyiv: '基辅(Kyiv)' + Kyrgyzstan: '吉尔吉斯斯坦(Kyrgyzstan)' + Laayoune: '阿尤恩(Laayoune)' + Labrador Sea: '拉布拉多海(Labrador Sea)' + Laos: '老挝(Laos)' + Latvia: '拉脱维亚(Latvia)' + 'Latvia (darker red, narrower white band)': '拉脱维亚(较深的红色,较窄的白色带)' + Lebanon: '黎巴嫩(Lebanon)' + Lesotho: '莱索托(Lesotho)' + Liberia: '利比里亚(Liberia)' + Libreville: '利伯维尔(Libreville)' + Libya: '利比亚(Libya)' + Liechtenstein: '列支敦士登(Liechtenstein)' + Lilongwe: '利隆圭(Lilongwe)' + Lima: '利马(Lima)' + Lisbon: '里斯本(Lisbon)' + Lithuania: '立陶宛(Lithuania)' + Ljubljana: '卢布尔雅那(Ljubljana)' + 'Lomé': '洛美(Lomé)' + London: '伦敦(London)' + Luanda: '罗安达(Luanda)' + Lusaka: '卢萨卡(Lusaka)' + Luxembourg: '卢森堡(Luxembourg)' + 'Luxembourg (lighter blue)': '卢森堡(浅蓝色)' + Luxembourg City: '卢森堡市(Luxembourg City)' + Macau: '澳门(Macau)' + Madagascar: '马达加斯加(Madagascar)' + Madeira: '马德拉(Madeira)' + Madrid: '马德里(Madrid)' + Majuro: '马朱罗(Majuro)' + Malawi: '马拉维(Malawi)' + Malaysia: '马来西亚(Malaysia)' + Maldives: '马尔代夫(Maldives)' + Mali: '马里(Mali)' + 'Mali (red and green flipped, slightly brighter green)': '马里(红绿翻转,绿色略亮)' + Malta: '马耳他(Malta)' + 'Malé': '马累(Malé)' + Managua: '马那瓜(Managua)' + Manama: '麦纳麦(Manama)' + Manila: '马尼拉(Manila)' + Maputo: '马普托(Maputo)' + Mariehamn: '玛丽港(Mariehamn)' + Marshall Islands: '马绍尔群岛(Marshall Islands)' + Martinique: '马提尼克(Martinique)' + Maseru: '马塞卢(Maseru)' + Mauritania: '毛里塔尼亚(Mauritania)' + Mauritius: '毛里求斯(Mauritius)' + Mayotte: '马约特岛(Mayotte)' + Mbabane: '姆巴巴内(Mbabane)' + Mediterranean Sea: '地中海(Mediterranean Sea)' + Melanesia: '美拉尼西亚(Melanesia)' + Mexico: '墨西哥(Mexico)' + Mexico City: '墨西哥城(Mexico City)' + Micronesia: '密克罗尼西亚(Micronesia)' + Minsk: '明斯克(Minsk)' + Mogadishu: '摩加迪沙(Mogadishu)' + Moldova: '摩尔多瓦(Moldova)' + 'Moldova (wider, coat of arms with eagle)': '摩尔多瓦(更宽,鹰纹)' + Monaco: '摩纳哥(Monaco)' + 'Monaco (narrower, darker red), Poland (red and white flipped, darker red)': '摩纳哥(更窄,红色更深),波兰(红白翻转,红色更深)' + Mongolia: '蒙古(Mongolia)' + Monrovia: '蒙罗维亚(Monrovia)' + Montenegro: '黑山(Montenegro)' + Montevideo: '蒙得维的亚(Montevideo)' + Morocco: '摩洛哥(Morocco)' + Moroni: '莫罗尼(Moroni)' + Moscow: '莫斯科(Moscow)' + Mozambique: '莫桑比克(Mozambique)' + Muscat: '马斯喀特(Muscat)' + Myanmar: '缅甸(Myanmar)' + 'N''Djamena': '恩贾梅纳(N''Djamena)' + Nairobi: '内罗毕(Nairobi)' + Namibia: '纳米比亚(Namibia)' + Nassau: '拿骚(Nassau)' + Nauru: '瑙鲁(Nauru)' + 'Nauru (single star below yellow band)': '瑙鲁(黄带下方的单星)' + 'Nauru has no official capital; the Yaren District is the de facto capital.': '瑙鲁(Nauru)无正式首都,政府机关设在亚伦区(the Yaren District)。' + Naypyidaw: '内比都(Naypyidaw)' + Nepal: '尼泊尔(Nepal)' + Netherlands: '荷兰(Netherlands)' + 'Netherlands (darker blue)': '荷兰(深蓝色)' + New Caledonia: '新喀里多尼亚(New Caledonia)' + New Delhi: '新德里(New Delhi)' + New Zealand: '新西兰(New Zealand)' + 'New Zealand (red stars, two fewer stars)': '新西兰(红星,少两颗星)' + Ngerulmud: '恩格鲁穆德(Ngerulmud)' + Niamey: '尼亚美(Niamey)' + Nicaragua: '尼加拉瓜(Nicaragua)' + 'Nicaragua (different coat of arms, slightly lighter blue)': '尼加拉瓜(不同的纹章,略浅的蓝色)' + Nicosia: '尼科西亚(Nicosia)' + Niger: '尼日尔(Niger)' + Nigeria: '尼日利亚(Nigeria)' + Niue: '纽埃(Niue)' + North America: '北美洲(North America)' + North Korea: '朝鲜(North Korea)' + North Macedonia: '北马其顿(North Macedonia)' + North Nicosia: '北尼科西亚(North Nicosia)' + North Sea: '北海(North Sea)' + Northern Cyprus: '北塞浦路斯(Northern Cyprus)' + Northern Ireland: '北爱尔兰(Northern Ireland)' + Northern Mariana Islands: '北马里亚纳群岛(Northern Mariana Islands)' + Norway: '挪威(Norway)' + 'Norway (red background, blue cross), Faroe Islands (white background, red and blue cross)': '挪威(红底蓝十字)、法罗群岛(白底红蓝十字)' + Norwegian Sea: '挪威海(Norwegian Sea)' + Not a sovereign country: '不是主权国家' + Nouakchott: '努瓦克肖特(Nouakchott)' + 'Nouméa': '努美阿(Nouméa)' + 'Nukuʻalofa': '努库阿洛法(Nukuʻalofa)' + Nuuk: '努克(Nuuk)' + 'Oblast (administrative region) of the Russian Federation.': '俄罗斯(Russia)联邦州(行政区)。' + Oceania: '大洋洲(Oceania)' + Official capital was moved from Bujumbura to Gitega in 2019.: '2019 年官方首都从布琼布拉(Bujumbura)迁至基特加(Gitega)。' + Official capital was moved from Malabo to Ciudad de la Paz in 2026.: '2026 年官方首都从马拉博(Malabo)迁至拉巴斯城(Ciudad de la Paz)。' + 'Officially Côte d''Ivoire.': '官方名为科特迪瓦(Côte d''Ivoire), 全称科特迪瓦共和国(The Republic of Côte d''Ivoire)' + Officially Luxembourg.: '官方称为卢森堡(Luxembourg)。' + Oman: '阿曼(Oman)' + Oranjestad: '奥拉涅斯塔德(Oranjestad)' + Oslo: '奥斯陆(Oslo)' + Ottawa: '渥太华(Ottawa)' + Ouagadougou: '瓦加杜古(Ouagadougou)' + Overseas department of France.: '法国(France)的海外省份。' + Overseas territory of France.: '法国(France)的海外领土。' + Overseas territory of the United Kingdom.: '英国(U.K.)的海外领土。' + Pacific Ocean: '太平洋(Pacific Ocean)' + Pakistan: '巴基斯坦(Pakistan)' + Palau: '帕劳(Palau)' + Palestine: '巴勒斯坦(Palestine)' + 'Palestine (black/white/green, red arrow)': '巴勒斯坦(黑/白/绿,红色箭头)' + 'Palestine (no symbol)': '巴勒斯坦(无符号)' + Palikir: '帕利基尔(Palikir)' + Panama: '巴拿马(Panama)' + Panama City: '巴拿马城(Panama City)' + Papeete: '帕皮提(Papeete)' + Papua New Guinea: '巴布亚新几内亚(Papua New Guinea)' + Paraguay: '巴拉圭(Paraguay)' + Paramaribo: '帕拉马里博(Paramaribo)' + Paris: '巴黎(Paris)' + Partially recognised state claimed by China.: '中国宣称对台湾拥有主权,但仅被部分国家承认' + Partially recognised state claimed by Morocco. Also known as Western Sahara.: '摩洛哥(Morocco)宣称拥有主权但仅被部分国家承认的地区。' + Partially recognised state claimed by Serbia.: '塞尔维亚(Serbia)宣称拥有主权但仅被部分国家承认的地区。' + Persian Gulf: '波斯湾(Persian Gulf)' + Peru: '秘鲁(Peru)' + Philippine Sea: '菲律宾海(Philippine Sea)' + Philippines: '菲律宾(Philippines)' + Phnom Penh: '金边(Phnom Penh)' + Podgorica: '波德戈里察(Podgorica)' + Poland: '波兰(Poland)' + Polynesia: '波利尼西亚(Polynesia)' + Port Louis: '路易港(Port Louis)' + Port Moresby: '莫尔兹比港(Port Moresby)' + Port Vila: '维拉港(Port Vila)' + Port of Spain: '西班牙港(Port of Spain)' + Port-au-Prince: '太子港(Port-au-Prince)' + Porto-Novo: '波多诺伏(Porto-Novo, 也被称之为“新港”)' + Portugal: '葡萄牙(Portugal)' + Prague: '布拉格(Prague)' + Praia: '普拉亚(Praia)' + Pretoria, Cape Town, Bloemfontein: '比勒陀利亚(Pretoria)、开普敦(Cape Town)、布隆方丹(Bloemfontein)' + Pristina: '普里什蒂纳(Pristina)' + Puerto Rico: '波多黎各(Puerto Rico)' + 'Puerto Rico (blue triangle, red stripes)': '波多黎各(蓝色三角形,红色条纹)' + Pyongyang: '平壤(Pyongyang)' + Qatar: '卡塔尔(Qatar)' + 'Qatar (wider, more serrated edges, maroon)': '卡塔尔(更宽,锯齿状边缘,栗色)' + Quito: '基多(Quito)' + Rabat: '拉巴特(Rabat)' + Red Sea: '红海(Red Sea)' + Region of France.: '法国(France)地区。' + Republic of the Congo: '刚果共和国(Republic of the Congo)' + 'Reykjavík': '雷克雅未克(Reykjavík)' + Riga: '里加(Riga)' + Riyadh: '利雅得(Riyadh)' + Romania: '罗马尼亚(Romania)' + 'Romania (slightly lighter blue)': '罗马尼亚(略浅蓝色)' + Rome: '罗马(Rome)' + Roseau: '罗索(Roseau)' + Russia: '俄罗斯(Russia)' + 'Russia (no coat of arms), Slovenia (wider, smaller coat of arms)': '俄罗斯(无国徽)、斯洛文尼亚(更宽、更小的国徽)' + Rwanda: '卢旺达(Rwanda)' + 'Réunion': '留尼汪岛(Réunion)' + Sahrawi Arab Democratic Republic: '阿拉伯撒哈拉民主共和国(Sahrawi Arab Democratic Republic)' + Saint Kitts and Nevis: '圣基茨和尼维斯(Saint Kitts and Nevis)' + Saint Lucia: '圣卢西亚(Saint Lucia)' + Saint Martin: '圣马丁(Saint Martin)' + Saint Vincent and the Grenadines: '圣文森特和格林纳丁斯(Saint Vincent and the Grenadines)' + Samoa: '萨摩亚(Samoa)' + 'San José': '圣何塞(San José)' + San Juan: '圣胡安(San Juan)' + San Marino: '圣马力诺(San Marino)' + San Salvador: '圣萨尔瓦多(San Salvador)' + Sanaa: '萨那(Sanaa)' + Santiago: '圣地亚哥(Santiago)' + Santo Domingo: '圣多明各(Santo Domingo)' + Sarajevo: '萨拉热窝(Sarajevo)' + Sardinia: '撒丁岛(Sardinia)' + Saudi Arabia: '沙特阿拉伯(Saudi Arabia)' + Scandinavia: '斯堪的纳维亚(Scandinavia)' + Scotland: '苏格兰(Scotland)' + Sea of Galilee: '加利利海(Sea of Galilee)' + Sea of Japan: '日本海(Sea of Japan)' + Sea of Okhotsk: '鄂霍次克海(Sea of Okhotsk)' + Semi-autonomous region of Tanzania.: '坦桑尼亚(Tanzania)的半自治区。' + Senegal: '塞内加尔(Senegal)' + 'Senegal (green/yellow/red, green star)': '塞内加尔(绿/黄/红,绿星)' + Seoul: '首尔(Seoul)' + Serbia: '塞尔维亚(Serbia)' + Seychelles: '塞舌尔(Seychelles)' + Sicily: '西西里岛(Sicily)' + Sierra Leone: '塞拉利昂(Sierra Leone)' + Singapore: '新加坡(Singapore)' + Sint Maarten: '圣马丁岛(Sint Maarten)' + Skopje: '斯科普里(Skopje)' + Slovakia: '斯洛伐克(Slovakia)' + 'Slovakia (narrower, bigger coat of arms)': '斯洛伐克(更窄、更大的国徽)' + 'Slovakia (with coat of arms)': '斯洛伐克(有国徽)' + Slovenia: '斯洛文尼亚(Slovenia)' + Sofia: '索菲亚(Sofia)' + Solomon Islands: '所罗门群岛(Solomon Islands)' + Somalia: '索马里(Somalia)' + Somaliland: '索马里兰(Somaliland)' + South Africa: '南非(South Africa)' + 'South Africa has no legally defined capital: the branches of government are split over three cities: Pretoria (executive), Cape Town (legislative) and Bloemfontein (judicial).': '南非没有法定首都,政府部门分为三个城市:行政首都(中央政府所在地)为比勒陀利亚(Pretoria, 2005年起讨论改为 茨瓦内/Tshwane 但至今没有实施),立法首都(议会所在地)为开普敦(Cape Town),司法首都(最高法院所在地)为布隆方丹(Bloemfontein)。' + South America: '南美洲(South America)' + South China Sea: '南海(South China Sea)' + South Korea: '韩国(South Korea)' + South Ossetia: '南奥塞梯(South Ossetia)' + South Sudan: '南苏丹(South Sudan)' + South Tarawa: '南塔拉瓦(South Tarawa)' + Southern Ocean: '南大洋(Southern Ocean)' + Sovereign country: '主权国家' + Spain: '西班牙(Spain)' + Special Administrative Region of China.: '中国特别行政区。' + Sri Jayawardenepura Kotte: '斯里贾亚瓦德纳普拉科特(Sri Jayawardenepura Kotte),亦称科特' + Sri Lanka: '斯里兰卡(Sri Lanka)' + 'St. George''s': '圣乔治(St. George''s)' + 'St. John''s': '圣约翰(St. John''s)' + State of the United States.: '美国(U.S.)的州。' + State recognised only by Turkey and claimed by Cyprus.: '塞浦路斯(Cyprus)声称拥有主权的地区但仅被土耳其(Turkey)承认。' + Stockholm: '斯德哥尔摩(Stockholm)' + Subregion of Oceania comprising thousands of small islands in the central and southern Pacific Ocean.: '大洋洲(Oceania)的一个区域,由太平洋(Pacific Ocean)中部和南部的数千个小岛组成。' + Subregion of Oceania comprising thousands of small islands in the western Pacific Ocean.: '大洋洲的一个区域包括西太平洋的数千个小岛。' + Subregion of Oceania, which includes Vanuatu, the Solomon Islands, Fiji, and Papua New Guinea.: '大洋洲(Oceania)的一个区域,包括瓦努阿图(Vanuatu)、所罗门群岛(Solomon Islands)、斐济(Fiji)和巴布亚新几内亚(Papua New Guinea)。' + Sucre: '苏克雷(Sucre)' + Sudan: '苏丹(Sudan)' + 'Sudan (red/white/black, green arrow), Sahrawi Arab Democratic Republic (with star and crescent)': '苏丹(红/白/黑、绿箭头)、阿拉伯撒哈拉民主共和国(有星形和新月形)' + Sukhumi: '苏呼米(Sukhumi)' + Sumatra: '苏门答腊岛(Sumatra)' + Suriname: '苏里南(Suriname)' + Suva: '苏瓦(Suva)' + Svalbard: '斯瓦尔巴群岛(Svalbard)' + Sweden: '瑞典(Sweden)' + Switzerland: '瑞士(Switzerland)' + 'Switzerland has no official capital; Bern is the de facto capital.': '瑞士没有官方首都;伯尔尼(Bern)是实际首都。' + Syria: '叙利亚(Syria)' + 'São Tomé': '圣多美(São Tomé)' + 'São Tomé and Príncipe': '圣多美和普林西比(São Tomé and Príncipe)' + Taipei: '台北(Taipei)' + Taiwan: '台湾(Taiwan)' + Tajikistan: '塔吉克斯坦(Tajikistan)' + Tallinn: '塔林(Tallinn)' + Tanzania: '坦桑尼亚(Tanzania)' + Tashkent: '塔什干(Tashkent)' + Tasman Sea: '塔斯曼海(Tasman Sea)' + Tbilisi: '第比利斯(Tbilisi)' + Tegucigalpa: '特古西加尔巴(Tegucigalpa)' + Tehran: '德黑兰(Tehran)' + Thailand: '泰国(Thailand)' + The Bahamas: '巴哈马(The Bahamas)' + The Gambia: '冈比亚(The Gambia)' + Thimphu: '廷布(Thimphu)' + Timor Sea: '帝汶海(Timor Sea)' + Timor-Leste: '东帝汶(Timor-Leste)' + Tirana: '地拉那(Tirana)' + Tiraspol: '蒂拉斯波尔(Tiraspol)' + Togo: '多哥(Togo)' + Tokyo: '东京(Tokyo)' + Tonga: '汤加(Tonga)' + Transnistria: '德涅斯特河沿岸(Transnistria)' + Trinidad and Tobago: '特立尼达和多巴哥(Trinidad and Tobago)' + Tripoli: '的黎波里(Tripoli)' + Tskhinvali: '茨欣瓦利(Tskhinvali)' + Tunis: '突尼斯(Tunis)' + Tunisia: '突尼斯(Tunisia)' + Turkey: '土耳其(Turkey)' + Turkmenistan: '土库曼斯坦(Turkmenistan)' + Turks and Caicos Islands: '特克斯和凯科斯群岛(Turks and Caicos Islands)' + Tuvalu: '图瓦卢(Tuvalu)' + 'Tórshavn': '托尔斯港(Tórshavn)' + Uganda: '乌干达(Uganda)' + Ukraine: '乌克兰(Ukraine)' + Ulaanbaatar: '乌兰巴托(Ulaanbaatar)' + Unincorporated internal area of Norway.: '挪威(Norway)的无建制内部区域。' + Unincorporated territory of the United States.: '美国(U.S.)无建制属地。' + United Arab Emirates: '阿拉伯联合酋长国(United Arab Emirates)' + United Kingdom: '英国(United Kingdom)' + United States Virgin Islands: '美属维尔京群岛(United States Virgin Islands)' + United States of America: '美国(United States of America, 全称美利坚合众国)' + Uruguay: '乌拉圭(Uruguay)' + Uzbekistan: '乌兹别克斯坦(Uzbekistan)' + Vaduz: '瓦杜兹(Vaduz)' + Valletta: '瓦莱塔(Valletta)' + Vanuatu: '瓦努阿图(Vanuatu)' + Vatican City: + notes.note.vatican-city.fields.field.capital: '梵蒂冈城(Vatican City)' + notes.note.vatican-city.fields.field.country: '梵蒂冈(Vatican City)' + Venezuela: '委内瑞拉(Venezuela)' + Victoria: '维多利亚(Victoria)' + Vienna: '维也纳(Vienna)' + Vientiane: '万象(Vientiane)' + Vietnam: '越南(Vietnam)' + Vilnius: '维尔纽斯(Vilnius)' + Wales: '威尔士(Wales)' + Wallis and Futuna: '瓦利斯和富图纳群岛(Wallis and Futuna)' + Warsaw: '华沙(Warsaw)' + Washington, D.C.: '华盛顿特区(Washington, D.C.)' + Wellington: '惠灵顿(Wellington)' + While Amsterdam is the official capital, The Hague is the seat of the executive and legislative branches.: '虽然阿姆斯特丹(Amsterdam)是官方首都,但海牙(Hague)是行政和立法部门的所在地。' + While Dodoma is the official capital, Dar es Salaam is the de facto seat of government.: '虽然多多马(Dodoma)是官方首都,但达累斯萨拉姆(Dar es Salaam)是实际政府所在地。' + 'While Laayoune, also known as El Aaiún, is the declared capital, Tifariti is the de facto seat of government.': '虽然阿尤恩(Laayoune,西班牙语称为El Aaiún,中文含义是泉水),是官方宣称的首都,但提法里提(Tifariti)是实际政府所在地。' + While Mbabane is the official, executive capital, Lobamba is the traditional, spiritual and legislative capital.: '姆巴巴内(Mbabane)是官方的行政首都,而洛班巴(Lobamba)是传统观念中的首都、精神首都和立法首都。' + While Porto-Novo is the official capital, Cotonou is the de facto seat of government.: '虽然波多诺伏(Porto-Novo)是官方首都,但科托努(Cotonou)是实际政府所在地。' + While Sucre is the constitutional capital, La Paz is the seat of government.: '虽然苏克雷(Sucre)是宪法认定的行政首都,但拉巴斯(La Paz)是政府所在地和财政中心。' + While Yamoussoukro is the official capital, Abidjan is the de facto seat of government.: '虽然亚穆苏克罗(Yamoussoukro)是官方首都,但阿比让(Abidjan)是实际政府所在地。' + White Sea: '白海(White Sea)' + Willemstad: '威廉斯塔德(Willemstad)' + Windhoek: '温得和克(Windhoek)' + World region covering the Australian continent and most of the islands in the Pacific Ocean.: '包括澳大利亚大陆(Australian continent)和太平洋(Pacific Ocean)大部分岛屿的世界大洲。' + Yamoussoukro: '亚穆苏克罗(Yamoussoukro)' + 'Yaoundé': '雅温得(Yaoundé)' + Yaren: '亚伦(Yaren)' + Yellow Sea: '黄海(Yellow Sea)' + Yemen: '也门(Yemen)' + Yerevan: '埃里温(Yerevan)' + Zagreb: '萨格勒布(Zagreb)' + Zambia: '赞比亚(Zambia)' + Zanzibar: '桑给巴尔(Zanzibar)' + Zimbabwe: '津巴布韦(Zimbabwe)' + 'Åland Islands': '奥兰群岛(Åland Islands)' + additions: + notes.note.myanmar.fields.field.capital-info: '缅甸于2005年11月6日首都从仰光(Yangon)迁往内比都(Naypyidaw)。' + notes.note.netherlands.fields.field.country-info: '现名尼德兰(Netherlands),中文通译为荷兰(Holanda)' + notes.note.yellow-sea.fields.field.country-info: '虽然国际上普遍认为黄海(Yellow Sea)包含了渤海(Bohai Sea),但在中国(China),它们通常被视为两个独立的海域。' + variables: + label.capital: + Capital: '首都' + label.capital-hint.answer: + 'Hint: {{Capital hint}}': '提示:{{Capital hint}}' + label.flag: + Flag: '旗帜' + label.location: + Location: '位置' + note-type.name: + Ultimate Geography: 'Ultimate Geography [ZH]' + sentence.flag-similar: + 'Flag similar to {{Flag similarity}}.': '旗帜类似于:{{Flag similarity}}。' + adapter_ids: + crowdanki:guid: + ')Y}Qy.3GK': 'jgG[|g62bu' + '5JJ2]i)p4': 'caU' + 'AES],s:8=': 'Q*@?lCT!6Y' + 'B:x`;1ezpn' + 'J%,fuysl*&': 'CV)zM-W' + 'M$bc0[jYf0': 'kLO=|#$+U:' + 'O~pXkAbd3': 'b&jm5o,1&}' + 'QSq%~rFaP-': 'm,bcMekm)F' + 'Qlib4)q2Hk': 'KaG.-9WVf?' + 'SlOEXfq#|': 'bDY>zGS#mC' + 'W5?6yQZD2': 'lgk=]hEdgU' + '_]DH+g9!$': 'zzE~LA%s{3' + 'b@M[XTc?}5': 'hyd{qAD}`v' + 'bD?R}i8_[c': 'q5WM.]hP/0D#' + 'bd~Ght5tLR': 'u]nUNO+ITf' + 'bk2ByZIeU+': 'd1TE(!31ZM' + 'bnEK#)[;qT': 'bY]b2xX@do' + 'c(,2{yXI#E': 'w5xWQO/x*-' + 'c,5(mUGzYT': 'IJx`PW+GwM' + 'cAlyc~G.y%': 'lVC}2]rXGx' + 'cEO^y)Md[G': 'ka~R7f}F%C' + 'cL?8!xP#Wj': 'e,fTnR*#azp' + 'cYKvF#ptF7': 'GTU:+%4s@|' + 'c^,~EC6Pb2': 'b|pv4=f4>n' + 'ck!6;xK$/^': 'P@BL~hgv1=' + 'cv+Nq9:a/N^[' + 'd3c`&/1Y^D': 'rY(r:vfR-a' + 'd5m8%D6,;u': 'wr%uNr%3yR' + 'dY96`C-f(_': 'K4=tg/ma1*' + 'd``B*6:eCx': 'lLRpv)^=-$' + 'dbP/Cj}sB+': 'r^Ba54xJuo' + 'de=9c:,@j9': 's:lS}~kTP7' + 'dlU7VG|3Gd': 'L0pY~Sl++C' + 'd}R:qHj1,7': 'B=Vz){?|~&' + 'e!KSebP}pt': 'Ne?2/nxwzz' + 'e+/O]%*qfk': 'Nwl0,aw[|b' + 'e.t$Vi5,>f': CpvA2cf_vf + 'e42BpV,P}C': 'OGFso_;Eyd' + 'e5P*D#I{m{': 'OKGkn:78,$' + 'eHH?U': 'P+Aat{#Q`@' + 'euW0jm=wfR': 'Ks:+^W@?f;' + 'e{e8vi@^PY': 'Fa]aBu=L1z' + 'f#4-L[mp5*': 'HJiVX@m]Bz' + 'f*2@qiHh_1': 'tXc}dmX&<>' + 'f*LO~>!0PN': 'j@1%Q{#*[u' + 'f21=-A~W;~': 'e<)log]FU?' + 'f>c7x2E]Q6': 'h=:xts:/of' + 'fRSM{K+;[w': bd/jbWJUgZ + 'fT!w8R#>dg': 'I6g)*h8Ip<' + 'fV`&p81%d(': 'cxf-&1_fEY' + fkq7lhLkdH: 'jjhGh!2yXE' + 'fxAN),FAc$': '6eRH0': 'r4vN]%uAKT' + 'gRVZ]qJ#>O': 'HRgL]mMe{v' + 'gYIoR`|AxW': 'FQ&H#hn98~V1': 'KpAD6q(C@6' + 'h9Jr(H[=1~': 'I]xI>B~d|i' + 'h?a*UKfT_M': 'JHYQ@rcDWA' + 'hE5j*e,Q?[': 'k_crZ6<6H@' + 'hNE9}>;Q&-': 'D`cK5{Bf(v' + 'hXNTVF<:SH': 'H@*7Gwq;c+' + 'hZ/V/D&rO`': 'o>gTQqW#WH' + 'h_9=mkZXAH': 'jKHmpl~?LE' + hoHandbYAy: 'g)E]': 'C8f6MF_v~q' + 'h}lnSN_f$_': 'FLi~`:rK*C' + 'h~5xz+=ke~': 's~>jc!QM+^' + 'i)]+09JUR&': 'MQ8`{2J;$l' + 'i2]v|D3@:j': 'E_he|O^6Oy' + 'iB-8Br~onq': Q75rz,Y9Lx + 'iIS#Nw]tr]': 'Pvc}L-U<4u' + 'iR;Um(L/~c=': 'dD4#OU[O?X' + 'i^J8&$(1]|': 'xy4vZ[O~^)' + 'ia8as9sCY|': OMmv.9i-m0 + 'igiK,ff[eO': 'CKTDS>^z.J' + 'ijP>aJBT!9': 'vlG>hjq6pi' + 'ilGtw#=asM': 'p@-D*C4Kbm' + 'j%*8gN)q>d': 'sv0@wkmk6L' + 'j)i)pB*HJ,': 'OQ1fL&%bu.' + 'j2IQ=f=w3@': 'K#d=_hIZKE' + j7eEZkzsCZ: cwo0tsYhFy + 'j9:K,~DQ2v': 'I}a6d8q1zJ' + 'jg|}': 'spE8=x-*=s' + 'jCd]`-=k,:': 'J.Jb{*5W&x' + 'jDA-V?/hVj': 'ilyPETFGW*D4)' + 'jcu!gLw/&r': 'tUSX;Ir|#b' + 'jrM28*HbyG': 'lh63b}7+i*' + 'j|z@PMgdx,': 'Ev>k]3}Q,)' + 'k$1_yaF?9#': 'mtvJwCsa{r' + 'k/u1:B%DJH': 'Dm&Q1+grS7' + 'k0{O[6l]bH': 'O--6u|WoWh' + 'k1]FH+s8j@': 'k}!h>.GGM}' + 'k9E.p^Ki!j#v+': 'nq~gisgjC/' + 'l3Ly8PBxt(': 'Kz0j{/>Yf;' + 'lAxH.CfJB@': 'L8k_N_5Y[s' + 'lH^2D]w;[H': 'D%X|~3..Zn' + 'lU@C37T|g>': 'puuJ#xat%v' + 'mz[>>AxIr4': 'nL!|kr$O/~' + 'm{noqE29%I': 'PS+ir>Fe)M' + 'm{oM,jr>C#': 'e%9U7YA-t6' + 'n&>e`2FO/S': 'lvc.@k4vE}' + 'n+t>2`:;P/': 'gbrfcUD:c-' + 'n:7&>YT5A=': 'QiSNJi)a#e' + 'nIfRnj' + 'nsS59c`VL,': 'mF&WTyq~08' + 'nuU8A_$i2M': 'e?vaE69=en' + 'n{{o2K1:dw': 'eyK$*+kkTP' + 'o#&RMA_REo': 'M2da>YCqC' + 'o3VS!KWb/@': 'fv#=q%4>c3' + 'o9{x/yz': 'kUnN|-Yeg2' + 'oa87&PB&Bm': 'zA3)&;T.So' + 'onMKID2d;D': 'EC?WTwipgP' + 'o~}KVA:VbI': 'o#V]PfbsJg' + 'p#R$-mG/L': 'uM1!Wa7;Z8' + 'p.yv>BQ470': 'I(kYtUqNp0' + 'p:SPZ:e/*[': 'x.-f`#aRav' + 'pDDrFhd%u': 'dg': 'F/1cJ>Fv&k' + 'q2#Yv[O0:|': 'm(Ep%@|]8b' + 'q7do-wLx%E': 'QJlzFH~z_P' + 'q8][)Ri}=q': 'ynaq{(em+.' + 'q8fF$4,c6_': zdjM-aCLo_ + 'q:XyGf#>Wg': 'IAa]E=O/{E' + 'q=w[fB$d+$': v6tcadB/em + 'r(h9&#wc~B': 'D!;%*R6J&o' + 'r/B|ra%L,.': 'EU8;wgRtD*' + 'r5C646H*+*': 'y0~OHJtwl7' + 'r6BV%|..*5': 'N/%+2}mGrG' + 'raI-$$|wT': 'JPoz};:Ba!' + 'r>g<|,FWi%': 'pW1W#4,|[C' + 'r?E$4f-d0/': 'rXXpo0h~v.' + 'rPekx@n9:L': 'yn:3FCAjQ@' + 'rSneU!3}bn': 'v^^9nD/1[^' + 'r]*Ioa%$bK': 'D': 'pT%UNItd:G' + 's9-GL*@AXD': 'D[xLjx,P&[' + 's<_WLSB3I/': 'p}s&{JwwI+' + 's>f40rn,O]': 'EwsGS_!k)u' + 'sB7rZQsR@9w~i^p': 'o1*G=hagzj' + 'skOM(?n$GG': FnkCCScYEu + spKoFRM4if: 'FlA8Y*/98r' + 'ssbyA9}]QP': 'vNk8~iA!~6' + 'stRa/S(0$F': 'Lr{~p.Jk<_' + 'sxOMs+bE8@': 'H/0e[]~?ap' + 'sx|l+io`B@': 'FTq&ip7kv.' + 's|K&2S,UP|': 'rrK]w=O%&;' + 't$y1C2R-Av': 's,r3*=P3-/' + 't+v9b--,qs': 'lzJ?#,5I62' + 't-0>F]bu>P': 'n0?y%]P19<' + 't2&ri|[Un:': 'HkZ${rRt2w' + t3aZXtpNyi: 'um^^I*6+xA' + 't50y/[AEhF': 'K$Iv_pVvA$' + 'tMMtBUpb$&': 'n-MjqM#r' + 't`cagaWApo': 'B;/=;Z5Hf>' + 'tcneuK7v%`': 'eYe;odSry.' + tfCY4j6KEZ: 'D9@;gV0`._' + 'tj&bK}%P!F': 'rlB,Mw_{9u' + 'twrhzJ[k:Z': 'pCEgO0#[W7' + 't|h3|;E4g{': 'rs3JWjn9}q' + 'u-`cspdp)D': 'le]C{9L5gP' + 'u4^lH.xem5': 'ikqk(b.J~O' + 'u;|vR#mVMW': 'my>[7JB9|d' + 'uH*Q(mcRt5': 'Q*lz)cq4AX' + 'uRo^w#p5Be': 'ypD(;&(hS}' + 'uc61ToT%gt': 'fi3!(|*ko:' + 'ui3>b-?a@m': 'f~6bdns@H]' + upiX0g1quR: 'J~N,woIJ(]' + 'uv1fGXHN@)': 'gA!f|vb<.t' + 'u|I}oElaH)': DBaFxxcqE1 + 'vE[T$`n9T)': 'y3XH$Vv!}O' + 'vKx<0Qa!aP': 'drOR)V?/DQ' + 'vNiD6A.v#s': 'o)}JIV-g[+' + 'va/ky=f5pK': 'C9Yd9s9x^4' + 'vf)G?[_?Pf': 'BJ.AT#.[rO' + 'vgdj#X?8dB': vKqtjIB/vI + 'vpOTK6tMr@': 'qL~S9=NS(@<' + 'wa>{k!2cXc': 'BuC717Wq+L' + x2d4n4ADcP: 'had@3^QMe<' + 'xb&P*pH(Q': 'w:QQuWvDwe' + 'xqrJ^d[.(O': 'kHR&Uv%`gZ' + 'yX:tmrd5;]': 'Fp7TupO=4T' + 'z:*d{z(~V2': 'Lw@Xd3XL=}' + 'zSc]-^U=0=': 'NlR(^Q+:^E' + crowdanki:uuid: + 43c5ba66-9a65-11e8-90c9-a0481cc15658: 6c995ee1-4b62-4019-a033-de0ef8651c83 + 43e2586a-9a65-11e8-a777-a0481cc15658: 30c7ff39-56b5-4693-8e16-aa862eb7a619 +deck: + name: + intent: replace + value: 'Ultimate Geography [ZH]' + expected_base: + value: Ultimate Geography + description: + intent: replace + value: | + 完整说明 | 发行说明 | 贡献指南 + + Ultimate Geography v5.3 特性: + + - 全世界 205 个主权国家 (820 张卡片) + - 59 个领土、世界区域和其他实体 (103 张卡片) + - 48 个海洋 (48 张卡片,仅地图) + - 7 个大陆 (7 张卡片,仅地图) + - 总计 319 个独立笔记, 978 张卡片, 221 个旗帜 以及 319 个地图. + + 本卡组 支持 英语(English), 德语(German), 西班牙语(Spanish), 法语(French), 挪威语(Norwegian), 捷克语(Czech), 俄语(Russian), 荷兰语(Dutch), 瑞典语(Swedish), 葡萄牙语(Portuguese), 简化字(Simplified Chinese/ZH), 正體字(Traditional Chinese/ZH-TW), 波兰语(Polish), 意大利语(Italian)丹麦语(Danish)。 同时 每种语言均支持拓展版本。 为了帮助记忆并在学习时提供上下文,一些笔记带有额外的信息,例如类似的旗帜、治理信息、替代国家名称等。 + + 你还能使用Anki的过滤牌组功能 , 将您的研究重点放在套牌的子集上, 例如主权州、单个笔记模板(例如地图→国家/地区)或特定大陆(例如欧洲)。 + + 这个卡组在 GitHub上维护。如果您发现错误、有建议或想要帮助,请不要犹豫打开一个问题(Issue)。 想要随时了解新版本? 观看(Watch)我们的 GitHub 仓库(Repository)或者订阅(Subscribe)发布提要(releases feed)! + expected_base: + 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! diff --git a/fixtures/ultimate-geography/overlays/variants/experimental.yaml b/fixtures/ultimate-geography/overlays/variants/experimental.yaml new file mode 100644 index 0000000..fd78b2d --- /dev/null +++ b/fixtures/ultimate-geography/overlays/variants/experimental.yaml @@ -0,0 +1,745 @@ +id: overlay.variant.experimental +kind: extension +field_additions: + note-type.ultimate-geography: + fields: + field.region-code: Region code + values: + note.abkhazia: + field.region-code: '' + note.adriatic-sea: + field.region-code: '' + note.aegean-sea: + field.region-code: '' + note.afghanistan: + field.region-code: AF + note.africa: + field.region-code: '' + note.akrotiri-and-dhekelia: + field.region-code: '' + note.alaska: + field.region-code: '' + note.albania: + field.region-code: AL + note.algeria: + field.region-code: DZ + note.american-samoa: + field.region-code: '' + note.andorra: + field.region-code: AD + note.angola: + field.region-code: AO + note.anguilla: + field.region-code: '' + note.antarctica: + field.region-code: '' + note.antigua-and-barbuda: + field.region-code: AG + note.arabian-sea: + field.region-code: '' + note.aral-sea: + field.region-code: '' + note.arctic-ocean: + field.region-code: '' + note.argentina: + field.region-code: AR + note.armenia: + field.region-code: AM + note.aruba: + field.region-code: '' + note.asia: + field.region-code: '' + note.atlantic-ocean: + field.region-code: '' + note.australia: + field.region-code: AU + note.austria: + field.region-code: AT + note.azerbaijan: + field.region-code: AZ + note.azores: + field.region-code: '' + note.bahrain: + field.region-code: BH + note.bali: + field.region-code: '' + note.balkan-peninsula: + field.region-code: '' + note.baltic-sea: + field.region-code: '' + note.banda-sea: + field.region-code: '' + note.bangladesh: + field.region-code: BD + note.barbados: + field.region-code: BB + note.barents-sea: + field.region-code: '' + note.bay-of-bengal: + field.region-code: '' + note.bay-of-biscay: + field.region-code: '' + note.belarus: + field.region-code: BY + note.belgium: + field.region-code: BE + note.belize: + field.region-code: BZ + note.benin: + field.region-code: BJ + note.bering-strait: + field.region-code: '' + note.bermuda: + field.region-code: '' + note.bhutan: + field.region-code: BT + note.black-sea: + field.region-code: '' + note.bolivia: + field.region-code: BO + note.bosnia-and-herzegovina: + field.region-code: BA + note.botswana: + field.region-code: BW + note.bougainville: + field.region-code: '' + note.brazil: + field.region-code: BR + note.british-virgin-islands: + field.region-code: '' + note.brunei: + field.region-code: BN + note.bulgaria: + field.region-code: BG + note.burkina-faso: + field.region-code: BF + note.burundi: + field.region-code: BI + note.cambodia: + field.region-code: KH + note.cameroon: + field.region-code: CM + note.canada: + field.region-code: CA + note.canary-islands: + field.region-code: '' + note.cape-verde: + field.region-code: CV + note.caribbean-sea: + field.region-code: '' + note.caspian-sea: + field.region-code: '' + note.cayman-islands: + field.region-code: '' + note.celebes-sea: + field.region-code: '' + note.celtic-sea: + field.region-code: '' + note.central-african-republic: + field.region-code: CF + note.chad: + field.region-code: TD + note.chile: + field.region-code: CL + note.china: + field.region-code: CN + note.colombia: + field.region-code: CO + note.comoros: + field.region-code: KM + note.cook-islands: + field.region-code: '' + note.coral-sea: + field.region-code: '' + note.corsica: + field.region-code: '' + note.costa-rica: + field.region-code: CR + note.croatia: + field.region-code: HR + note.cuba: + field.region-code: CU + note.cura-ao: + field.region-code: '' + note.cyprus: + field.region-code: CY + note.czech-republic: + field.region-code: CZ + note.dead-sea: + field.region-code: '' + note.democratic-republic-of-the-congo: + field.region-code: CD + note.denmark: + field.region-code: DK + note.denmark-strait: + field.region-code: '' + note.djibouti: + field.region-code: DJ + note.dominica: + field.region-code: DM + note.dominican-republic: + field.region-code: DO + note.east-china-sea: + field.region-code: '' + note.east-siberian-sea: + field.region-code: '' + note.ecuador: + field.region-code: EC + note.egypt: + field.region-code: EG + note.el-salvador: + field.region-code: SV + note.england: + field.region-code: '' + note.english-channel: + field.region-code: '' + note.equatorial-guinea: + field.region-code: GQ + note.eritrea: + field.region-code: ER + note.estonia: + field.region-code: EE + note.eswatini: + field.region-code: SZ + note.ethiopia: + field.region-code: ET + note.europe: + field.region-code: '' + note.european-union: + field.region-code: '' + note.falkland-islands: + field.region-code: FK + note.faroe-islands: + field.region-code: FO + note.federated-states-of-micronesia: + field.region-code: '' + note.fiji: + field.region-code: FJ + note.finland: + field.region-code: FI + note.france: + field.region-code: FR + note.french-guiana: + field.region-code: GF + note.french-polynesia: + field.region-code: '' + note.gabon: + field.region-code: GA + note.georgia: + field.region-code: GE + note.germany: + field.region-code: DE + note.ghana: + field.region-code: GH + note.gibraltar: + field.region-code: '' + note.greece: + field.region-code: GR + note.greenland: + field.region-code: GL + note.grenada: + field.region-code: '' + note.guadeloupe: + field.region-code: GP + note.guam: + field.region-code: '' + note.guatemala: + field.region-code: GT + note.guernsey: + field.region-code: '' + note.guinea: + field.region-code: GN + note.guinea-bissau: + field.region-code: GW + note.gulf-of-alaska: + field.region-code: '' + note.gulf-of-california: + field.region-code: '' + note.gulf-of-carpentaria: + field.region-code: '' + note.gulf-of-guinea: + field.region-code: '' + note.gulf-of-mexico: + field.region-code: '' + note.gulf-of-thailand: + field.region-code: '' + note.guyana: + field.region-code: GY + note.haiti: + field.region-code: HT + note.hawaii: + field.region-code: '' + note.honduras: + field.region-code: HN + note.hong-kong: + field.region-code: HK + note.hudson-bay: + field.region-code: '' + note.hungary: + field.region-code: HU + note.iceland: + field.region-code: IS + note.india: + field.region-code: IN + note.indian-ocean: + field.region-code: '' + note.indonesia: + field.region-code: ID + note.iran: + field.region-code: IR + note.iraq: + field.region-code: IQ + note.ireland: + field.region-code: IE + note.isle-of-man: + field.region-code: IM + note.israel: + field.region-code: IL + note.italy: + field.region-code: IT + note.ivory-coast: + field.region-code: CI + note.jamaica: + field.region-code: JM + note.japan: + field.region-code: JP + note.java: + field.region-code: '' + note.jeju: + field.region-code: '' + note.jersey: + field.region-code: '' + note.jordan: + field.region-code: JO + note.kaliningrad-oblast: + field.region-code: '' + note.kazakhstan: + field.region-code: KZ + note.kenya: + field.region-code: KE + note.kiribati: + field.region-code: '' + note.kosovo: + field.region-code: '' + note.kuwait: + field.region-code: KW + note.kyrgyzstan: + field.region-code: KG + note.labrador-sea: + field.region-code: '' + note.land-islands: + field.region-code: AX + note.laos: + field.region-code: LA + note.latvia: + field.region-code: LV + note.lebanon: + field.region-code: LB + note.lesotho: + field.region-code: LS + note.liberia: + field.region-code: LR + note.libya: + field.region-code: LY + note.liechtenstein: + field.region-code: '' + note.lithuania: + field.region-code: LT + note.luxembourg: + field.region-code: LU + note.macau: + field.region-code: '' + note.madagascar: + field.region-code: MG + note.madeira: + field.region-code: '' + note.malawi: + field.region-code: MW + note.malaysia: + field.region-code: MY + note.maldives: + field.region-code: '' + note.mali: + field.region-code: ML + note.malta: + field.region-code: '' + note.marshall-islands: + field.region-code: '' + note.martinique: + field.region-code: MQ + note.mauritania: + field.region-code: MR + note.mauritius: + field.region-code: MU + note.mayotte: + field.region-code: YT + note.mediterranean-sea: + field.region-code: '' + note.melanesia: + field.region-code: '' + note.mexico: + field.region-code: MX + note.micronesia: + field.region-code: '' + note.moldova: + field.region-code: MD + note.monaco: + field.region-code: '' + note.mongolia: + field.region-code: MN + note.montenegro: + field.region-code: ME + note.morocco: + field.region-code: MA + note.mozambique: + field.region-code: MZ + note.myanmar: + field.region-code: MM + note.namibia: + field.region-code: NA + note.nauru: + field.region-code: '' + note.nepal: + field.region-code: NP + note.netherlands: + field.region-code: NL + note.new-caledonia: + field.region-code: NC + note.new-zealand: + field.region-code: NZ + note.nicaragua: + field.region-code: NI + note.niger: + field.region-code: NE + note.nigeria: + field.region-code: NG + note.niue: + field.region-code: '' + note.north-america: + field.region-code: '' + note.north-korea: + field.region-code: KP + note.north-macedonia: + field.region-code: MK + note.north-sea: + field.region-code: '' + note.northern-cyprus: + field.region-code: '' + note.northern-ireland: + field.region-code: '' + note.northern-mariana-islands: + field.region-code: '' + note.norway: + field.region-code: NO + note.norwegian-sea: + field.region-code: '' + note.oceania: + field.region-code: '' + note.oman: + field.region-code: OM + note.pacific-ocean: + field.region-code: '' + note.pakistan: + field.region-code: PK + note.palau: + field.region-code: '' + note.palestine: + field.region-code: PS + note.panama: + field.region-code: PA + note.papua-new-guinea: + field.region-code: PG + note.paraguay: + field.region-code: PY + note.persian-gulf: + field.region-code: '' + note.peru: + field.region-code: PE + note.philippine-sea: + field.region-code: '' + note.philippines: + field.region-code: PH + note.poland: + field.region-code: PL + note.polynesia: + field.region-code: '' + note.portugal: + field.region-code: PT + note.puerto-rico: + field.region-code: PR + note.qatar: + field.region-code: QA + note.r-union: + field.region-code: RE + note.red-sea: + field.region-code: '' + note.republic-of-the-congo: + field.region-code: CG + note.romania: + field.region-code: RO + note.russia: + field.region-code: RU + note.rwanda: + field.region-code: RW + note.s-o-tom-and-pr-ncipe: + field.region-code: ST + note.sahrawi-arab-democratic-republic: + field.region-code: '' + note.saint-kitts-and-nevis: + field.region-code: '' + note.saint-lucia: + field.region-code: '' + note.saint-martin: + field.region-code: '' + note.saint-vincent-and-the-grenadines: + field.region-code: '' + note.samoa: + field.region-code: '' + note.san-marino: + field.region-code: '' + note.sardinia: + field.region-code: '' + note.saudi-arabia: + field.region-code: SA + note.scandinavia: + field.region-code: '' + note.scotland: + field.region-code: '' + note.sea-of-galilee: + field.region-code: '' + note.sea-of-japan: + field.region-code: '' + note.sea-of-okhotsk: + field.region-code: '' + note.senegal: + field.region-code: SN + note.serbia: + field.region-code: RS + note.seychelles: + field.region-code: '' + note.sicily: + field.region-code: '' + note.sierra-leone: + field.region-code: SL + note.singapore: + field.region-code: SG + note.sint-maarten: + field.region-code: '' + note.slovakia: + field.region-code: SK + note.slovenia: + field.region-code: SI + note.solomon-islands: + field.region-code: SB + note.somalia: + field.region-code: SO + note.somaliland: + field.region-code: '' + note.south-africa: + field.region-code: ZA + note.south-america: + field.region-code: '' + note.south-china-sea: + field.region-code: '' + note.south-korea: + field.region-code: KR + note.south-ossetia: + field.region-code: '' + note.south-sudan: + field.region-code: SS + note.southern-ocean: + field.region-code: '' + note.spain: + field.region-code: ES + note.sri-lanka: + field.region-code: LK + note.sudan: + field.region-code: SD + note.sumatra: + field.region-code: '' + note.suriname: + field.region-code: SR + note.svalbard: + field.region-code: SJ + note.sweden: + field.region-code: SE + note.switzerland: + field.region-code: CH + note.syria: + field.region-code: SY + note.taiwan: + field.region-code: TW + note.tajikistan: + field.region-code: TJ + note.tanzania: + field.region-code: TZ + note.tasman-sea: + field.region-code: '' + note.thailand: + field.region-code: TH + note.the-bahamas: + field.region-code: BS + note.the-gambia: + field.region-code: GM + note.timor-leste: + field.region-code: TL + note.timor-sea: + field.region-code: '' + note.togo: + field.region-code: TG + note.tonga: + field.region-code: '' + note.transnistria: + field.region-code: '' + note.trinidad-and-tobago: + field.region-code: TT + note.tunisia: + field.region-code: TN + note.turkey: + field.region-code: TR + note.turkmenistan: + field.region-code: TM + note.turks-and-caicos-islands: + field.region-code: '' + note.tuvalu: + field.region-code: '' + note.uganda: + field.region-code: UG + note.ukraine: + field.region-code: UA + note.united-arab-emirates: + field.region-code: AE + note.united-kingdom: + field.region-code: GB + note.united-states-of-america: + field.region-code: US + note.united-states-virgin-islands: + field.region-code: '' + note.uruguay: + field.region-code: UY + note.uzbekistan: + field.region-code: UZ + note.vanuatu: + field.region-code: VU + note.vatican-city: + field.region-code: '' + note.venezuela: + field.region-code: VE + note.vietnam: + field.region-code: VN + note.wales: + field.region-code: '' + note.wallis-and-futuna: + field.region-code: '' + note.white-sea: + field.region-code: '' + note.yellow-sea: + field.region-code: '' + note.yemen: + field.region-code: YE + note.zambia: + field.region-code: ZM + note.zanzibar: + field.region-code: '' + note.zimbabwe: + field.region-code: ZW +note_types: + note-type.ultimate-geography: + intent: merge + variables: + variant.name-suffix: + intent: override + value: ' [Experimental]' + expected_base: + value: ' [Extended]' + card_templates: + template.country-map: + intent: merge + question_format: + intent: merge + value: |- + + +
{{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/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()) From 25ebf007f90b4cb486502e31bde34960d68497ef Mon Sep 17 00:00:00 2001 From: Jordan Munch O'Hare Date: Mon, 25 May 2026 09:51:55 +0200 Subject: [PATCH 4/8] docs: add Brain Brew federation guidance --- .gitignore | 3 + AGENTS.md | 46 + CONTEXT.md | 239 + README.md | 279 +- documentation/README.md | 18 + documentation/docs/authoring/diff-explain.md | 58 + documentation/docs/authoring/extensions.md | 81 + documentation/docs/authoring/field-fills.md | 63 + .../docs/authoring/manifests-targets.md | 95 + .../docs/authoring/packages-locking.md | 84 + documentation/docs/authoring/translations.md | 98 + documentation/docs/authoring/verify-export.md | 87 + documentation/docs/authoring/workspace.md | 65 + documentation/docs/concepts/canonical-deck.md | 72 + documentation/docs/concepts/identity.md | 62 + documentation/docs/concepts/media.md | 59 + documentation/docs/concepts/overlays.md | 101 + .../docs/concepts/what-is-federation.md | 52 + .../docs/examples/downstream-package.md | 66 + .../docs/examples/hardcore-geography.md | 68 + .../docs/examples/ultimate-geography.md | 76 + .../docs/getting-started/cli-tour.md | 79 + documentation/docs/getting-started/install.md | 70 + .../docs/getting-started/quickstart.md | 96 + documentation/docs/intro.md | 39 + documentation/docs/reference/cli.md | 100 + ...ow-scope-to-local-first-deck-federation.md | 23 + ...use-canonical-deck-as-federation-format.md | 23 + ...0013-use-stable-ids-as-primary-identity.md | 23 + ...llow-overlays-to-target-any-deck-entity.md | 23 + ...d-overlay-stack-with-explicit-conflicts.md | 23 + ...lays-as-sparse-canonical-deck-fragments.md | 23 + ...stable-canonicalized-source-round-trips.md | 23 + ...-canonical-deck-file-as-source-of-truth.md | 23 + ...canonical-yaml-for-canonical-deck-files.md | 23 + .../0020-use-rust-for-core-and-cli.md | 23 + ...ure-rust-workspace-around-reusable-core.md | 23 + ...preserve-anki-compatible-deck-semantics.md | 23 + ...xclude-review-state-from-canonical-deck.md | 23 + ...edia-as-external-assets-with-references.md | 23 + .../0025-use-strict-validation-by-default.md | 23 + .../0026-fail-on-unsupported-adapter-data.md | 23 + ...ed-base-for-destructive-overlay-changes.md | 23 + ...pe-system-until-cli-semantics-stabilize.md | 23 + ...le-stable-ids-with-separate-adapter-ids.md | 23 + ...view-suggested-stable-ids-during-import.md | 23 + ...ey-canonical-deck-entities-by-stable-id.md | 23 + .../0032-represent-removals-as-tombstones.md | 23 + ...domain-pure-with-separate-format-codecs.md | 23 + ...034-use-minimal-federated-deck-manifest.md | 55 + ...utral-stable-ids-for-translated-targets.md | 45 + ...del-deck-variants-as-extension-overlays.md | 40 + ...source-importers-for-initial-federation.md | 42 + ...fest-package-metadata-and-target-checks.md | 66 + ...-variables-and-translation-dictionaries.md | 47 + ...040-use-locked-federated-package-inputs.md | 88 + .../0041-continue-as-rust-based-brain-brew.md | 46 + .../docs/reference/decisions/README.md | 64 + .../0001-use-gren-as-primary-language.md | 41 + ...anonical-note-as-single-source-of-truth.md | 39 + ...3-recipe-system-based-on-nix-philosophy.md | 41 + ...ync-using-nucleus-inspired-architecture.md | 44 + ...ment-with-multiple-distribution-options.md | 45 + .../0006-adapter-interface-standardization.md | 49 + ...7-content-based-identification-for-sync.md | 48 + ...08-json-over-protobuf-for-data-exchange.md | 39 + ...cture-through-canonicalnote-composition.md | 166 + ...-level-sync-using-dropbox-nucleus-model.md | 211 + .../reference/decisions/archive/README.md | 5 + .../decisions/archive/_category_.json | 8 + documentation/docs/reference/glossary.md | 85 + documentation/docs/reference/lockfile.md | 79 + documentation/docs/reference/project-scope.md | 56 + .../docs/reference/release-oracle.md | 45 + .../research/iced-and-anki-add-on.md | 101 + documentation/docs/reference/yaml.md | 137 + documentation/docusaurus.config.js | 85 + documentation/package-lock.json | 18383 ++++++++++++++++ documentation/package.json | 21 + documentation/sidebars.js | 73 + documentation/src/css/custom.css | 23 + skills/federated-deck-extensions/SKILL.md | 221 + 82 files changed, 23002 insertions(+), 221 deletions(-) create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 documentation/README.md create mode 100644 documentation/docs/authoring/diff-explain.md create mode 100644 documentation/docs/authoring/extensions.md create mode 100644 documentation/docs/authoring/field-fills.md create mode 100644 documentation/docs/authoring/manifests-targets.md create mode 100644 documentation/docs/authoring/packages-locking.md create mode 100644 documentation/docs/authoring/translations.md create mode 100644 documentation/docs/authoring/verify-export.md create mode 100644 documentation/docs/authoring/workspace.md create mode 100644 documentation/docs/concepts/canonical-deck.md create mode 100644 documentation/docs/concepts/identity.md create mode 100644 documentation/docs/concepts/media.md create mode 100644 documentation/docs/concepts/overlays.md create mode 100644 documentation/docs/concepts/what-is-federation.md create mode 100644 documentation/docs/examples/downstream-package.md create mode 100644 documentation/docs/examples/hardcore-geography.md create mode 100644 documentation/docs/examples/ultimate-geography.md create mode 100644 documentation/docs/getting-started/cli-tour.md create mode 100644 documentation/docs/getting-started/install.md create mode 100644 documentation/docs/getting-started/quickstart.md create mode 100644 documentation/docs/intro.md create mode 100644 documentation/docs/reference/cli.md create mode 100644 documentation/docs/reference/decisions/0011-narrow-scope-to-local-first-deck-federation.md create mode 100644 documentation/docs/reference/decisions/0012-use-canonical-deck-as-federation-format.md create mode 100644 documentation/docs/reference/decisions/0013-use-stable-ids-as-primary-identity.md create mode 100644 documentation/docs/reference/decisions/0014-allow-overlays-to-target-any-deck-entity.md create mode 100644 documentation/docs/reference/decisions/0015-use-ordered-overlay-stack-with-explicit-conflicts.md create mode 100644 documentation/docs/reference/decisions/0016-represent-overlays-as-sparse-canonical-deck-fragments.md create mode 100644 documentation/docs/reference/decisions/0017-require-byte-stable-canonicalized-source-round-trips.md create mode 100644 documentation/docs/reference/decisions/0018-use-single-canonical-deck-file-as-source-of-truth.md create mode 100644 documentation/docs/reference/decisions/0019-use-strict-canonical-yaml-for-canonical-deck-files.md create mode 100644 documentation/docs/reference/decisions/0020-use-rust-for-core-and-cli.md create mode 100644 documentation/docs/reference/decisions/0021-structure-rust-workspace-around-reusable-core.md create mode 100644 documentation/docs/reference/decisions/0022-preserve-anki-compatible-deck-semantics.md create mode 100644 documentation/docs/reference/decisions/0023-exclude-review-state-from-canonical-deck.md create mode 100644 documentation/docs/reference/decisions/0024-store-media-as-external-assets-with-references.md create mode 100644 documentation/docs/reference/decisions/0025-use-strict-validation-by-default.md create mode 100644 documentation/docs/reference/decisions/0026-fail-on-unsupported-adapter-data.md create mode 100644 documentation/docs/reference/decisions/0027-require-expected-base-for-destructive-overlay-changes.md create mode 100644 documentation/docs/reference/decisions/0028-defer-recipe-system-until-cli-semantics-stabilize.md create mode 100644 documentation/docs/reference/decisions/0029-use-human-readable-stable-ids-with-separate-adapter-ids.md create mode 100644 documentation/docs/reference/decisions/0030-review-suggested-stable-ids-during-import.md create mode 100644 documentation/docs/reference/decisions/0031-key-canonical-deck-entities-by-stable-id.md create mode 100644 documentation/docs/reference/decisions/0032-represent-removals-as-tombstones.md create mode 100644 documentation/docs/reference/decisions/0033-keep-core-domain-pure-with-separate-format-codecs.md create mode 100644 documentation/docs/reference/decisions/0034-use-minimal-federated-deck-manifest.md create mode 100644 documentation/docs/reference/decisions/0035-use-language-neutral-stable-ids-for-translated-targets.md create mode 100644 documentation/docs/reference/decisions/0036-model-deck-variants-as-extension-overlays.md create mode 100644 documentation/docs/reference/decisions/0037-do-not-build-legacy-source-importers-for-initial-federation.md create mode 100644 documentation/docs/reference/decisions/0038-use-manifest-package-metadata-and-target-checks.md create mode 100644 documentation/docs/reference/decisions/0039-use-source-variables-and-translation-dictionaries.md create mode 100644 documentation/docs/reference/decisions/0040-use-locked-federated-package-inputs.md create mode 100644 documentation/docs/reference/decisions/0041-continue-as-rust-based-brain-brew.md create mode 100644 documentation/docs/reference/decisions/README.md create mode 100644 documentation/docs/reference/decisions/archive/0001-use-gren-as-primary-language.md create mode 100644 documentation/docs/reference/decisions/archive/0002-canonical-note-as-single-source-of-truth.md create mode 100644 documentation/docs/reference/decisions/archive/0003-recipe-system-based-on-nix-philosophy.md create mode 100644 documentation/docs/reference/decisions/archive/0004-bidirectional-sync-using-nucleus-inspired-architecture.md create mode 100644 documentation/docs/reference/decisions/archive/0005-web-first-deployment-with-multiple-distribution-options.md create mode 100644 documentation/docs/reference/decisions/archive/0006-adapter-interface-standardization.md create mode 100644 documentation/docs/reference/decisions/archive/0007-content-based-identification-for-sync.md create mode 100644 documentation/docs/reference/decisions/archive/0008-json-over-protobuf-for-data-exchange.md create mode 100644 documentation/docs/reference/decisions/archive/0009-federated-deck-architecture-through-canonicalnote-composition.md create mode 100644 documentation/docs/reference/decisions/archive/0010-note-level-sync-using-dropbox-nucleus-model.md create mode 100644 documentation/docs/reference/decisions/archive/README.md create mode 100644 documentation/docs/reference/decisions/archive/_category_.json create mode 100644 documentation/docs/reference/glossary.md create mode 100644 documentation/docs/reference/lockfile.md create mode 100644 documentation/docs/reference/project-scope.md create mode 100644 documentation/docs/reference/release-oracle.md create mode 100644 documentation/docs/reference/research/iced-and-anki-add-on.md create mode 100644 documentation/docs/reference/yaml.md create mode 100644 documentation/docusaurus.config.js create mode 100644 documentation/package-lock.json create mode 100644 documentation/package.json create mode 100644 documentation/sidebars.js create mode 100644 documentation/src/css/custom.css create mode 100644 skills/federated-deck-extensions/SKILL.md diff --git a/.gitignore b/.gitignore index 28dd34c..d3d8b63 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ # Local generated/downloaded caches /.cache/ + +# Local agent task queues +/.frontloop/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d224b00 --- /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. +- `brain-brew-cli`: thin command-line wrapper, 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/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/README.md b/README.md index 16b7f70..4388635 100644 --- a/README.md +++ b/README.md @@ -1,254 +1,91 @@ -# 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 with Nix -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. +Run the CLI directly from this flake: -optional arguments: - -h, --help show this help message and exit +```bash +nix run . -- --help ``` +Build a local binary: -## 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 +nix build .#brainbrew +./result/bin/brainbrew --help ``` -### 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! +Install into your user profile: -### 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 +nix profile install .#brainbrew +brainbrew --help ``` -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 - -###### 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 | - -##### Note: +See [`documentation/docs/getting-started/install.md`](documentation/docs/getting-started/install.md) for 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 interface +``` -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/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 (``, `