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/.envrc b/.envrc new file mode 100644 index 0000000..d56a237 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +eval "$(devenv direnvrc)" +use devenv diff --git a/.frontloop/default/clarify/7500-explore-crowdanki-round-trip-import-workflow.md b/.frontloop/default/clarify/7500-explore-crowdanki-round-trip-import-workflow.md new file mode 100644 index 0000000..d7a4aca --- /dev/null +++ b/.frontloop/default/clarify/7500-explore-crowdanki-round-trip-import-workflow.md @@ -0,0 +1,37 @@ +--- +title: Explore a CrowdAnki round-trip import workflow (pull in-Anki edits back to source) +priority: low +--- + +## Goal + +A maintainer who edits notes directly in Anki can bring those edits home: export via CrowdAnki, run one supported Brain Brew workflow (ideally a single command), and get a reviewable change against the canonical source — restoring, in overlay-native form, what the old Python world's `anki_to_source.yaml` recipe provided. + +## Status: clarify — deliberately deferred + +The loss of the old Anki→source recipe was a **purposeful simplification** during the UG migration, not an oversight. This is a later thing. Do not promote to ready until the core review backlog (media integrity, include preservation, stale resolution, resolver unification) has landed and there is actual maintainer demand for the round trip. + +## Problem + +The primitives exist but are dead-ended: + +- `brainbrew import crowdanki --accept-suggested-ids --out deck.yaml` (`crates/brain-brew-cli/src/commands/import.rs` ~:9) does a full re-import into a fresh deck file — right for initial migration, destructive for round-tripping (would flatten includes, structured messages, media declarations, overlay federation). +- `brainbrew diff --as-overlay` (`crates/brain-brew-cli/src/commands/diff.rs` ~:11) drafts an overlay from a semantic diff — the correct primitive for expressing "what changed in Anki" against a composed target. +- Nothing connects them; UG's CONTRIBUTING documents CrowdAnki in one direction only (smoke-test import into Anki, ~:494). + +The plausible workflow: import the Anki export to a temp canonical deck → diff against the composed English target → emit a reviewable overlay/patch draft → human reviews and applies to source. + +## Questions to settle before ready + +1. **ID alignment.** Import derives suggested StableIds from names/first field. When do they line up with the existing deck's IDs, and what does the workflow do when they don't? (Interacts with the derived-ID-collision fail-closed fix.) +2. **Reverse-mapping lossiness.** The diff runs against the composed target, where includes are materialized and structured messages are rendered. Edits landing in those fields can be detected but not automatically written back to source form. Is "reviewable draft + human step" the contract, or does any subset (plain scalar fields) get an automatic write-back path? (`!image` adoption would shrink this gap for image fields.) +3. **Command shape.** A documented three-step recipe using existing commands vs a convenience command (`brainbrew pull`-style) wrapping import→compose→diff. What does the output artifact look like — overlay draft on stdout, a file, a workbench surface? +4. **Scope of targets.** Old world was English-only. Is the round trip defined only against the base/EN composed target, or per-target (which would imply reverse-mapping through translation dictionaries — much harder, likely out of scope)? +5. **Demand check.** Confirm a real UG maintainer scenario before building; if nobody edits in Anki anymore, close this instead. + +## Acceptance Criteria (draft, to firm up on promotion) + +- A supported, documented, tested workflow (command or recipe) producing a reviewable source-level change from a CrowdAnki export of an existing deck. +- Non-destructive by construction: never overwrites `deck.yaml`/overlays directly; output is a draft a human applies. +- Honest about lossiness: structured-message and include-backed fields are flagged as needing manual handling, not silently mangled. +- UG-side follow-up task (CONTRIBUTING maintainer section) filed once the tool side ships. diff --git a/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md b/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md new file mode 100644 index 0000000..124137f --- /dev/null +++ b/.frontloop/default/done/0010-report-swallowed-message-errors-in-render-deck-variables.md @@ -0,0 +1,37 @@ +--- +title: Report swallowed structured-message errors in render_deck_variables +priority: high +--- + +## Goal + +`render_deck_variables` must fail when structured-message resolution fails after variable substitution, instead of silently returning a deck with broken/empty messages. + +## Problem + +In `crates/brain-brew-core/src/lib.rs` (~line 3623), the success branch does: + +```rust +if errors.is_empty() { + let mut message_errors = Vec::new(); + resolve_structured_messages_with_validation_errors(&mut rendered, &mut message_errors); + Ok(rendered) +} +``` + +`message_errors` is populated by the resolver but never inspected, so any message that fails to resolve post-substitution (e.g. a `ref` to a nonexistent field produced via a variable) is silently dropped. Everywhere else in the pipeline this resolver's errors are surfaced (compose errors, validation errors); this is the only call site that discards them. + +## Acceptance Criteria + +- `render_deck_variables` returns an error when `resolve_structured_messages_with_validation_errors` reports any error. +- The error output identifies the offending path/message, consistent with how compose/validate report the same failures. +- Regression test: a deck whose variable expansion yields an unresolvable structured message causes `render_deck_variables` to fail; the same deck with the variable corrected succeeds. +- `cargo test --workspace` passes. + +## Design Decisions + +- Whether to extend `VariableRenderReport` with the existing validation-error type or add a distinct variant is left to the implementor; prefer whichever keeps error display consistent. + +## Implementation Notes + +- Callers of `render_deck_variables` (e.g. CrowdAnki export path in `crates/brain-brew-formats/src/crowdanki.rs`) already handle the `Err` case, so no caller changes expected beyond error display. diff --git a/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md b/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md new file mode 100644 index 0000000..54a75d8 --- /dev/null +++ b/.frontloop/default/done/0020-fail-closed-on-crowdanki-import-id-collisions.md @@ -0,0 +1,29 @@ +--- +title: Fail closed on CrowdAnki import stable-ID collisions +priority: high +--- + +## Goal + +CrowdAnki import must return an error when two source entities derive the same stable ID, instead of silently keeping only the last one. + +## Problem + +In `crates/brain-brew-formats/src/crowdanki.rs` (`into_deck`, ~lines 790-802), stable IDs are slug-derived (note types from model name, notes from first field value; derivation ~lines 1085-1111). Note types are inserted with `BTreeMap::insert` and notes collected into a `BTreeMap` — both last-wins on duplicate keys. Distinct entities whose names/first fields slugify identically (e.g. "São Tomé" vs "Sao Tome", two notes both starting "Congo") silently vanish from the imported deck, which then validates cleanly. Violates ADR-0010 (fail closed on unsupported adapter data) and ADR-0004 (reviewable stable IDs). Field-level duplicates are already caught by validation (`DuplicateFieldDefinition`); note and note-type level are not. + +## Acceptance Criteria + +- Importing a CrowdAnki export where two notes derive the same stable ID returns a `CrowdAnkiError` naming the colliding ID and identifying both source notes (e.g. their GUIDs and/or first-field values). +- Same for two note models deriving the same stable ID (naming both model names). +- Error message points the user at the suggested-IDs override path (`import_deck_accept_suggested_ids`) as the resolution. +- The `note_type_by_uuid` map is also collision-checked (two models with the same UUID should already be impossible upstream, but the derived-ID map must not mask a collision). +- Happy-path round-trip tests still pass; `cargo test --workspace` passes. + +## Process + +Use TDD / red-green-refactor: write the two failing collision tests in `crates/brain-brew-formats/tests/crowdanki.rs` first (duplicate note slug, duplicate note-model slug), confirm they fail with the current silent last-wins behavior, then implement the collision checks, then refactor. + +## Implementation Notes + +- Collision check is a few lines at each insert site (`BTreeMap::insert` returning `Some` / entry API); most of the work is the tests. +- Keep error style consistent with existing `CrowdAnkiError::Unsupported` messages in the same file. diff --git a/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md b/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md new file mode 100644 index 0000000..608ed14 --- /dev/null +++ b/.frontloop/default/done/0030-cache-workspace-composition-in-workbench-server.md @@ -0,0 +1,38 @@ +--- +title: Cache workspace composition in the Workbench server +priority: high +--- + +## Goal + +Workbench request handlers must stop re-planning and re-composing the workspace from disk on every request; per-request work should be bounded (ADR-0015), keyed on the existing freshness generation. + +## Problem + +In `crates/brain-brew-cli/src/commands/workbench.rs`: + +- `media_path_declared` (~lines 978-1009) runs on every `GET /api/media/` and, per request, re-reads the manifest and fully re-plans + re-composes **every overlay of every target** just to check whether the path is declared media. For UG (~80 targets across two manifests, 319 notes, 546 media files) a single note view (flag + map) triggers hundreds of full-deck compositions. +- `current_manifest` (~line 471) re-reads the manifest file per request; `selected_translation_context` (~lines 1011-1072) re-plans and re-composes the selected target per request. +- All of this blocking file IO and CPU-heavy composition runs inline in `async fn` handlers on Tokio worker threads — zero uses of `spawn_blocking` in the crate. +- Minor: `media_file_candidates` (~lines 945-975) probes `external//...` under every ancestor directory of the manifest root — many stat calls per request; looks like a leftover dev-layout hack. + +The server already tracks a freshness generation that bumps when workspace files change (~lines 3525-3579) — the invalidation hook exists but nothing is cached on it. + +## Acceptance Criteria + +- `media_path_declared` is a lookup into a cached set of declared media paths (union across all targets), built at most once per freshness generation. +- Composed deck / translation-context state for the selected target is cached keyed on the freshness generation (and invalidated by apply/new-language writes, which already bump the generation). +- Manifest is not re-read from disk on requests that hit a valid cache. +- Blocking plan/compose/file work in handlers runs via `tokio::task::spawn_blocking` (or an equivalent dedicated worker), not inline on async workers. +- Behavior is unchanged when files change on disk: after an edit, the next request reflects the new state (existing freshness E2E semantics still pass). +- Existing workbench integration tests in `crates/brain-brew-cli/tests/cli.rs` and the E2E suite pass. Add an integration test asserting media requests succeed after a workspace edit (cache invalidation) and that an undeclared path is still rejected. + +## Design Decisions + +- Single task on purpose: the media-path set and composed-state cache share the same generation-keyed invalidation; implement one caching layer, two consumers. +- Consider tightening `media_file_candidates` to manifest root + media root only, but do not break the documented `--media-root` behavior; if the ancestor probing is load-bearing for some workflow, keep it and note why. + +## Implementation Notes + +- Cache lives on the shared server state struct behind a `tokio::sync::RwLock` (or `arc-swap`); key = generation number. +- `compose_lenient_translation_overlay` (duplicated at `workbench.rs:~4414` and `translations.rs:~1314`) is in the hot path; hoisting it to a shared location can be done here or left to the separate dedup task. diff --git a/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md b/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md new file mode 100644 index 0000000..fb29848 --- /dev/null +++ b/.frontloop/default/done/0040-refresh-ug-fixture-from-upstream-via-sync-script.md @@ -0,0 +1,34 @@ +--- +title: Refresh the UG fixture from upstream via a repeatable sync script +priority: medium +--- + +## Goal + +`fixtures/ultimate-geography/` matches the real ultimate-geography repo (current layout, all 16 languages including Hebrew, both manifests), plus one deliberate, documented delta: the ADR-0012 `languages:`/`translation_profile` catalog. A checked-in script makes the refresh repeatable whenever UG moves again. + +## Problem + +The fixture is a mid-migration snapshot that has drifted from the real repo in both directions: + +- Behind: pre-refactor `source/` include layout (real repo uses `templates//{question,answer}.html`); missing Hebrew (`he.yaml` — the only RTL language, so script direction is currently untested here); missing the hardcore second manifest (the real repo builds two manifests; the hardcore shape earned two high-priority tasks in UG's pr736-review epic and is unrepresented in this repo's tests). +- Ahead: carries the ADR-0012 `languages:`/`translation_profile` catalog (~240 lines of `brainbrew.yaml`) that the real UG doesn't use yet. + +Every "composes the UG fixture byte-identically" acceptance criterion in the queued refactor tasks is only as strong as the fixture's resemblance to production; right now it certifies a deck shape that no longer exists. + +## Acceptance Criteria + +- A script (e.g. `scripts/sync-ug-fixture.sh` or a small Rust/xtask — implementer's choice) that: + - Copies the fixture-relevant files from a UG checkout (path passed as an argument; default may assume `../external/ultimate-geography` but must not hardcode-require it) into `fixtures/ultimate-geography/`: both manifests, all overlays, templates, deck sources, and whatever media/metadata files the fixture tests need. + - Reapplies the translation_profile delta on top. Keep the delta maintainable: either a patch file or a separate YAML fragment the script splices in — NOT hand-editing after each sync. The delta and its rationale (ADR-0012 coverage until UG adopts it upstream — see the UG-side frontloop task) are documented in a README next to the script or fixture. + - Is idempotent: running it twice produces no diff. +- The fixture after refresh: current UG layout, 16 languages including `he.yaml`, both `brainbrew.yaml` and `brainbrew-hardcore.yaml`, plus the translation_profile delta. +- All fixture-dependent tests updated and passing: `cargo test --workspace`. Expect churn in expected outputs (`ultimate_geography_fixture.rs`, overlay/canonical-yaml tests, docs examples that cite fixture facts) — update expectations to the new fixture, but investigate any change that looks semantic rather than layout-driven before accepting it. +- Hardcore manifest is actually exercised: at least one test composes/verifies a hardcore target (new coverage — the shape was previously untested here). +- The sync script's usage is documented (one paragraph: when to run it, what the delta is). + +## Design Decisions + +- The fixture intentionally stays "real UG + translation_profile" rather than splitting into two fixtures; the delta is temporary — a UG-side task exists to adopt translation_profile upstream, after which the delta (and splice step) can be deleted. +- Sync direction is one-way (UG → fixture). The script must never write into the UG checkout. +- If some UG files are irrelevant to tests (e.g. full media binaries), the script may exclude them, but the exclusion list lives in the script, not in tribal knowledge. diff --git a/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md b/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md new file mode 100644 index 0000000..c9c48a6 --- /dev/null +++ b/.frontloop/default/done/0050-unify-translation-resolution-into-single-resolver.md @@ -0,0 +1,37 @@ +--- +title: Unify translation resolution into a single resolver +priority: high +--- + +## Goal + +One implementation of the translation decision procedure (direct / contextual / no_change / stale / missing), consumed by both the apply path and the coverage path, replacing the current hand-synced parallel copies. + +## Problem + +In `crates/brain-brew-core/src/lib.rs` the same ~100-line resolution decision tree is written out longhand at least four times: + +- `TranslationApplyContext::translate_string` (~:1909-2037) — mutating apply path used by compose. +- `TranslationCoverageBuilder::record_string` (~:924-1050) — read-only classification used by verify/reports. +- `translate_optional_string` (~:2039) and `record_optional_string` (~:1052) — near-identical nullable-field twins. +- `has_explicit_string_entry` is duplicated verbatim in both structs (~:894-922 and ~:1879-1907). + +Nothing enforces agreement between the copies. Divergence means `verify` reports coverage that differs from what compose actually produces — breaking the tool's core promise that what verify says is what ships. + +## Acceptance Criteria + +- A single resolver function/type takes (source string, path, dictionary, options) and returns a `TranslationOutcome` enum (e.g. `Direct(value)` / `Contextual { value, context }` / `NoChange` / `Stale(record)` / `Missing`). +- `TranslationApplyContext` and `TranslationCoverageBuilder` are thin consumers mapping outcomes to mutations/errors and report entries respectively; the `_optional` variants collapse into `Option` handling at call sites; `has_explicit_string_entry` exists exactly once. +- Behavior-preserving: all existing tests pass unchanged (33 `overlay_compose` tests, translation CLI tests, UG fixture tests). If the parallel copies turn out to have ALREADY drifted (tests or UG fixture composition reveal a semantic difference between apply and coverage), STOP and report the divergence for a human decision rather than silently picking one side. +- Composing the full ultimate-geography fixture (`fixtures/ultimate-geography`) produces byte-identical output before and after the refactor (capture a before snapshot as evidence). +- `cargo test --workspace` passes. + +## Design Decisions + +- Pure refactor — no semantic changes, no new features. Any tempting behavior fix found along the way becomes a separate task. +- Resolver should be a free function or small struct in core, not a trait hierarchy; keep it as boring as possible. + +## Implementation Notes + +- The translation subsystem spans roughly lib.rs:722-2331; this refactor is a natural precursor to splitting lib.rs into submodules (separate task) — do this one first so the module split moves one resolver, not four copies. +- Guardrails are strong: work red-green against the existing suites; diff the resolution behavior across the 16 UG language overlays as an end-to-end check. diff --git a/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md b/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md new file mode 100644 index 0000000..6c34d14 --- /dev/null +++ b/.frontloop/default/done/0060-unify-yaml-scalar-emission-and-test-byte-stability.md @@ -0,0 +1,38 @@ +--- +title: Unify YAML scalar emission and test the byte-stability promise +priority: high +--- + +## Goal + +One shared scalar-emission implementation for all hand-rolled YAML emitters, with an adversarial test suite that actually pins the ADR-0005 promises: idempotent formatting and lossless round-trip. + +## Problem + +Emission of canonical YAML is hand-rolled (parsing is serde_yaml) per ADR-0005's determinism goal, but: + +- The "can this string be emitted as a plain scalar" predicate is copied three times with different rules: `crates/brain-brew-formats/src/canonical_yaml.rs` ~:1044 (disallows `:`), `manifest.rs` ~:438 (allows `:`), `lockfile.rs` ~:102 (allows `:`). These predicates are correctness-load-bearing (a bare string that YAML re-parses differently — `yes`, `1.0`, trailing `:` — is silent data corruption); a fix to one copy won't reach the others. +- Block-scalar emission (`write_multiline_or_scalar`, canonical_yaml.rs ~:1031) emits `|`/`|-` with no explicit indentation indicator; content with a leading-space first line or trailing whitespace on lines is the classic hand-rolled-emitter corruption case. UG's multiline HTML template fields go through this path. +- `can_emit_plain_scalar` (~:1052) force-single-quotes all non-ASCII, so most content in 15 of UG's 16 language overlays is quoted — deterministic but noisy for the files translators edit (nice-to-have to relax, severable). +- The ADR-0005 promises are barely tested: round-trip tests assert semantic equality; only one byte-level assertion exists (`tests/canonical_yaml.rs` ~:71). No idempotence (`format(format(x)) == format(x)`) or adversarial-content tests. + +## Process + +TDD / red-green-refactor. Write the adversarial test suite FIRST against the current emitters: + +1. Table-driven round-trip tests (`parse(emit(x)) == x`) over hostile strings: leading/trailing whitespace, lines with trailing spaces, first line starting with a space, YAML keyword lookalikes (`yes`, `no`, `null`, `~`, `1.0`, `0x1F`), embedded single/double quotes, `: ` inside strings, `#`, non-ASCII (accented, CJK, Hebrew/RTL), empty string, lone newline. +2. Idempotence tests: format-twice-compare bytes, for unit cases and the full `fixtures/ultimate-geography` deck + overlays + manifest + a lockfile. +3. Any red test is a real pre-existing bug: fix it in the shared implementation (green), then refactor the three emitters onto it. + +## Acceptance Criteria + +- A single shared scalar-emission module in the formats crate (plain-scalar predicate, quoting routine, block-scalar writer) used by canonical_yaml, manifest, and lockfile emitters; the `:` discrepancy is reconciled deliberately (document which rule won and why). +- The adversarial round-trip + idempotence suite above exists and passes. +- Formatting the full ultimate-geography fixture is byte-identical before and after the refactor, EXCEPT where a red test proved the old bytes were corrupt or where the reconciled `:` rule changes quoting — capture and report any such diffs explicitly. +- Optional/severable: relax the plain-scalar rule to allow common non-ASCII word characters only if the round-trip suite proves it safe; skip if in doubt. +- `cargo test --workspace` passes. + +## Design Decisions + +- Do NOT replace the hand-rolled emitter with serde_yaml emission — determinism/byte-stability is the point (ADR-0005). This task consolidates and hardens, not rewrites. +- Emitting an explicit block-scalar indentation indicator (e.g. `|2`) or falling back to quoted style for pathological content are both acceptable; correctness of round-trip beats prettiness. diff --git a/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md b/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md new file mode 100644 index 0000000..58a1a14 --- /dev/null +++ b/.frontloop/default/done/0070-make-fmt-and-format-verify-preserve-source-includes.md @@ -0,0 +1,41 @@ +--- +title: Make fmt and format verification preserve source !include structure +priority: high +--- + +## Goal + +`brainbrew fmt` never changes document structure: `!include` markers survive formatting verbatim. Format verification actually verifies include-bearing files instead of silently passing them. + +## Problem + +Compose/export resolve `!include` correctly on read, and the workbench apply path preserves include structure when writing (`workbench.rs` ~:3681-3752, raw parse keeping tagged scalars, edits routed into the included file). But two paths break the contract: + +1. **`fmt` destructively materializes includes.** `fmt.rs` ~:17-20 reads the file, calls `format_source_at`, writes the result back. `format_source_at` (`crates/brain-brew-cli/src/io.rs` ~:37-41) resolves includes BEFORE formatting — so `brainbrew fmt` on an include-bearing deck (like real UG's, whose templates/descriptions are all externalized via `!include`) inlines every included file's content into the source and severs every include link. A formatter that changes structure is a data-loss bug in the most routine command. +2. **Format verification fails open on includes.** `verify_format_with` (`io.rs` ~:517-537) formats the include-resolved text, then — if the raw file contained `!include` — returns `Ok(())` unconditionally (the comment admits the bytes can't be compared). Canonical-format enforcement therefore silently does not apply to exactly the files UG cares most about; a malformed include-bearing file passes verify forever. Opposite of ADR-0010's fail-closed posture. + +## Process + +TDD / red-green-refactor. Failing tests first: + +1. `fmt` on an include-bearing deck fixture leaves every `!include` marker byte-intact and does not inline included content (currently fails: includes are materialized). +2. `fmt` on an include-bearing file is idempotent: second run produces zero diff. +3. `fmt` still normalizes the non-include parts of an include-bearing file (mis-formatted sibling fields get fixed) — preserving includes must not mean skipping formatting. +4. `verify` format check REJECTS a non-canonical include-bearing file (currently fails: unconditional Ok) and accepts a canonical one. +5. Include resolution behavior in compose/export/read paths is unchanged (existing tests stay green). + +Then implement (green), then refactor `format_source_at`/`verify_format_with` and the emitter onto the shared include-preserving path. + +## Acceptance Criteria + +- The canonical emitters can emit an `!include ` tagged scalar verbatim where one appeared in the source (serde_yaml `Value::Tagged` on the parse side; the workbench apply path shows the raw-parse-preserving approach). +- `format_source_at` formats the RAW document (includes preserved); include resolution remains a read-path concern only. No fmt code path writes materialized include content into a source file. +- `verify_format_with` compares canonical bytes for include-bearing files like any other file; the fail-open early-return and its comment are gone. +- Included files themselves (e.g. `.html`) are opaque content: fmt does not read, format, or touch them. +- Run the real check: fmt on the refreshed UG fixture (or a fixture file using the current UG include layout) round-trips with includes intact. +- `cargo test --workspace` passes. + +## Design Decisions + +- The include-preserving format capability should be ONE implementation shared by fmt, verify, and (if it can adopt it cheaply) the workbench apply serializer — not a fourth include-handling variant. +- Related but out of scope: UG-side task `10-low-review-include-materialization-workaround-in-pr-736-evidence-script.md` can only fully remove its workaround once this lands; note completion there. diff --git a/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md b/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md new file mode 100644 index 0000000..4baaeab --- /dev/null +++ b/.frontloop/default/done/0080-enforce-media-integrity-in-verify-and-export.md @@ -0,0 +1,31 @@ +--- +title: Enforce media integrity in verify and export +priority: high +--- + +## Goal + +The media declaration model becomes load-bearing: verify fails closed on media references with no declaration and on declarations whose sha256 is empty or doesn't match the file; export ships declared media itself so consumers stop hand-copying; a tooling command populates/refreshes hashes. An image update is thereby recorded in source state (hash bump, git-visible), not just in the binary file. + +## Problem + +Verified against the real UG repo (2026-07-03): + +- All 546 declared media entries in UG's `deck.yaml` have `sha256: ''` — the integrity field is populated nowhere and enforced nowhere. A corrupted or silently-swapped media file passes `verify --media-root`. +- 61 files on disk are referenced from field content (hardcore extension overlays: `overlays/extensions/hardcore.yaml`, `hardcore/field-fills.yaml` — e.g. `ug-flag-bali-blur.png`) but declared nowhere. Nothing cross-checks referenced-in-content vs declared, so verify can't see it (ADR-0010 says this should fail closed). +- UG's CI (`integrity-check.yml:44`) and CONTRIBUTING hand-`cp media/*` into export output — the blanket copy is what masks the 61 missing declarations, and it means export is not authoritative for media in the tool's flagship deployment. + +## Acceptance Criteria + +- **Referenced-vs-declared check in verify**: scan composed field content of every target for media references (initially: ``/`[sound:]`-style extraction over rendered fields; exact-match upgrade comes later via the separate `!image` structured-reference task) and fail with a listing of referenced-but-undeclared paths. Also report declared-but-unreferenced as a warning (unused declarations are noise, not corruption). +- **Hash enforcement in verify**: with `--media-root`, a declared entry whose file is missing, whose sha256 is empty, or whose file hash differs from the declared sha256 is an error naming the entry, the path, and both hashes. (Provide a migration flag or staged severity only if UG adoption needs it; default is fail closed.) +- **Hash tooling**: a command (e.g. `brainbrew media hash --manifest ... --media-root ...`) that computes and writes sha256s into the deck source for missing/stale entries, emitting canonical YAML (respects the include-preserving formatter once that task lands). Refreshing after an intentional image edit is this one command. +- **Authoritative export**: `export crowdanki` copies the declared media set for the target into the output media directory itself; document that consumers can drop manual `cp media/*` steps. +- Tests: fixture with an undeclared-but-referenced file (verify fails), a hash mismatch (verify fails), empty hash (verify fails), and a full green path where export output contains exactly the declared media. Run against the UG fixture once refreshed. +- `cargo test --workspace` passes. + +## Design Decisions + +- Overlays can already add/change media (`media_changes`, full ChangeIntent semantics) — no model change needed; the UG-side fix declares the hardcore media in the hardcore extension overlay. This task is enforcement + tooling only. +- The content-scan reference extractor should be one clearly-named function so the future `!image` structured reference can replace its innards without touching verify's logic. +- CrowdAnki `media_files`/export layout must keep matching what Anki imports expect — the authoritative copy replicates what the manual `cp` achieved for declared files, it does not invent a new layout. diff --git a/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md b/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md new file mode 100644 index 0000000..0ceee04 --- /dev/null +++ b/.frontloop/default/done/0090-dedupe-verbatim-copy-paste-sites.md @@ -0,0 +1,30 @@ +--- +title: Dedupe scattered verbatim copy-paste sites +priority: low +--- + +## Goal + +One behavior-preserving sweep that removes the remaining verbatim code duplication, so each of these pieces of logic exists exactly once. (The two load-bearing duplications — translation resolution and YAML scalar emission — have their own dedicated tasks and are OUT of scope here.) + +## Problem + +Four independent copy-paste sites, each a divergence waiting to happen: + +1. **`compose_lenient_translation_overlay`** — ~63 lines byte-identical in `crates/brain-brew-cli/src/commands/workbench.rs` ~:4414 and `crates/brain-brew-cli/src/commands/translations.rs` ~:1314. This defines the "compose without failing on missing translations" semantics both the workbench and the translations CLI rely on; a fix to one copy silently misses the other and the two frontends then disagree about current deck state. +2. **`glob_matches`** — byte-identical in `crates/brain-brew-core/src/lib.rs` ~:2316 and `crates/brain-brew-formats/src/crowdanki.rs` ~:480. Also a hand-rolled recursive backtracker with exponential worst case on patterns with multiple `*`. Not exploitable today (patterns are short and user-authored) but there is no reason for two copies or the exponential body. +3. **`resolve_structured_messages_with_{validation,compose}_errors`** — `crates/brain-brew-core/src/lib.rs` ~:508 and ~:533, identical except which error variant they push. Both also take a full `deck.clone()` snapshot, and the pair runs once per overlay during compose — the largest contributor to compose's clone-per-overlay cost. +4. **Stale-translation warning loop** — the same collect-and-format loop appears three times in `crates/brain-brew-cli/src/commands/verify.rs` (~:114-133, ~:163, ~:190). Consolidation only; the risk is wording/behavior drift between verify output paths. + +## Acceptance Criteria + +- `compose_lenient_translation_overlay` exists exactly once, in a location importable by both `workbench.rs` and `translations.rs` (a shared module in the CLI crate is fine; core is fine too if it fits without dragging CLI concerns in). Note: the workbench composition-cache task also touches this hot path — coordinate if both are in flight. +- `glob_matches` exists exactly once (in core, called or re-exported from formats), and its body is replaced with a linear-time two-pointer match (or the `globset` crate). Behavior pinned by a table-driven test over patterns/inputs covering `*` at start/middle/end, multiple `*`, literal-only, empty pattern, empty input — written BEFORE swapping the body. +- The two `resolve_structured_messages_with_*_errors` functions collapse into one implementation, generic over the error constructor or returning a neutral error type both callers map. If the deck clone can be replaced with borrows of the maps actually needed, do it; if that turns invasive, keep the clone and note it — that half is severable. +- The verify.rs stale-warning loop exists exactly once; the three call sites produce byte-identical output to before (pin with a test or capture before/after on the UG fixture). +- Behavior-preserving throughout: `cargo test --workspace` passes with no test expectation changes (new tests may be added). + +## Design Decisions + +- Explicitly out of scope: the ~250 `.expect("writing to a string cannot fail")` calls on `write!`-to-String — genuinely infallible, idiomatic, leave them alone. +- No semantic changes anywhere; any tempting behavior fix discovered along the way becomes a separate task. diff --git a/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md b/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md new file mode 100644 index 0000000..41180b7 --- /dev/null +++ b/.frontloop/default/done/0100-split-brain-brew-core-lib-into-submodules.md @@ -0,0 +1,38 @@ +--- +title: Split brain-brew-core lib.rs into submodules +priority: medium +--- + +## Goal + +`crates/brain-brew-core/src/lib.rs` (currently one flat ~4,662-line module) is split into focused submodules with the public API unchanged, so the crate's internal boundaries are enforced by the module system instead of by discipline. + +## Problem + +The entire core crate — deck model, overlay compose, the translation subsystem (~1,600 lines, roughly :722-2331), structured messages, validation, variable rendering — lives in a single flat `lib.rs`: + +- No enforced boundaries: everything can reach everything's private items; translation code can poke at compose internals and vice versa. Several already-filed duplications (twin translation resolvers, twin `has_explicit_string_entry`) survived partly because a 4,662-line file hides the same logic existing twice a thousand lines apart. +- Zero unit tests in core: all coverage is black-box in `tests/` (good, but the layout discourages small private-function tests next to a `mod translation`). +- Review friction: every core change diffs the same file; "did this translation change touch compose?" means scrolling, not reading a module path. + +The formats crate already splits by codec — this file is the outlier, not the house style. + +## Sequencing (IMPORTANT) + +Do this AFTER these already-filed tasks land, so the split moves one resolver instead of four copies and the same lines aren't churned twice: + +1. `0050-unify-translation-resolution-into-single-resolver.md` +2. `0090-dedupe-verbatim-copy-paste-sites.md` (at least item 3, the `resolve_structured_messages_with_*_errors` merge) + +## Acceptance Criteria + +- `lib.rs` becomes a thin root: module declarations + `pub use` re-exports. Suggested split (adjust to the natural seams found while moving): `model`, `compose`, `translation`, `messages`, `validate`. +- Pure code motion: no function bodies change, no visibility widens beyond what compilation requires (prefer `pub(crate)` over `pub` for items that were previously private-in-module). +- Public API of the crate is unchanged — downstream crates (`brain-brew-formats`, `brain-brew-cli`) compile without source changes to their imports (re-exports preserve paths). +- `cargo test --workspace` passes with zero test changes. +- Composing the full `fixtures/ultimate-geography` deck produces byte-identical output before and after (trivially true for code motion; capture as evidence anyway). + +## Design Decisions + +- No behavior changes, no dedup, no renames beyond module paths — any improvement noticed while moving code becomes a separate task. +- Keep it to one level of modules; don't design a deep hierarchy speculatively. diff --git a/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md b/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md new file mode 100644 index 0000000..f0d4fd8 --- /dev/null +++ b/.frontloop/default/done/0110-replace-dotted-string-deck-paths-with-typed-deckpath.md @@ -0,0 +1,43 @@ +--- +title: Replace dotted-string deck paths with a typed DeckPath +priority: medium +--- + +## Goal + +Deck-internal addresses ("field `capital` of note `germany`") are represented by a typed `DeckPath` enum with one parser and one printer, instead of `format!`-assembled dotted strings re-parsed by multiple independent hand-written matchers. Serialized YAML forms stay byte-identical. + +## Problem + +In `crates/brain-brew-core/src/lib.rs`, paths like `notes.germany.fields.capital` are built with scattered `format!` calls and re-parsed by at least three independent parsers: + +- `note_field_path_parts` (~:696) — splits and pattern-matches segments +- `note_id_from_translation_path` (~:1353) — a second parser of the same mini-language for the translation subsystem +- `context_parent_candidates` (~:2251) — string-slices dotted prefixes to find contextual-dictionary parents + +Costs: + +- The compiler can't check any of it: a typo'd `format!` segment produces a path that never matches — a silent miss (translation stops applying, coverage never resolves), not an error. +- The path grammar exists only as folklore across the parsers, and nothing keeps them in agreement. +- Escaping is undefined: a `.` inside a note/field StableId (nothing forbids it; IDs are human-authored) mis-splits in every parser. Latent today because UG's IDs are dot-free, but it is a data-dependent correctness hole. + +## Process + +TDD / red-green-refactor: + +1. First pin the grammar: `FromStr`/`Display` round-trip tests for every legal path shape currently in use (enumerate them by auditing all construction and parse sites), plus hostile cases — unknown segment kinds, wrong arity, empty segments, and IDs containing `.`. +2. The dotted-ID case forces the design decision below; make it explicitly, then implement `DeckPath` (green). +3. Migrate call sites: construction `format!`s → constructors; hand parsers → `FromStr` + match. Refactor until no hand-rolled dotted-string parsing of deck paths remains in core. + +## Acceptance Criteria + +- A `DeckPath` enum in core (variants for each path shape actually used: note field, note-model template, etc.) with `Display` emitting the exact current dotted syntax and `FromStr` as the single parser. +- `note_field_path_parts`, `note_id_from_translation_path`, and the prefix-walking in `context_parent_candidates` are expressed via `DeckPath` (deleted or reduced to thin wrappers); no remaining `format!` construction of deck paths outside `Display`. +- Dots-in-IDs decision made and enforced: EITHER reject `.` in StableIds at validation time (preferred if UG data allows — it does today) OR define and test escaping. Document the choice in the code. +- All existing serialized output is byte-identical: composing the full `fixtures/ultimate-geography` deck and formatting all fixture YAML produces identical bytes before and after. +- `cargo test --workspace` passes; existing tests unchanged except where they construct paths as raw strings internally (may switch to constructors). + +## Design Decisions + +- Internal type change only — the on-disk dotted syntax is a stable format and does not change. +- Sequence AFTER `0100-split-brain-brew-core-lib-into-submodules.md` (the type belongs in the `model` module) and after the translation-resolver unification, which touches the same parsing sites. diff --git a/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md b/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md new file mode 100644 index 0000000..58de4dc --- /dev/null +++ b/.frontloop/default/done/0120-make-workbench-apply-atomic-all-or-nothing.md @@ -0,0 +1,42 @@ +--- +title: Make Workbench apply writes atomic and all-or-nothing +priority: medium +--- + +## Goal + +A workbench Apply either fully lands on disk or changes nothing. No torn files, no partially-applied multi-file edits, no interleaved concurrent applies. + +## Problem + +In `crates/brain-brew-cli/src/commands/workbench.rs`, the apply paths — `apply_request_json` (~:744, writes ~:874-887), `apply_staged_source_edits` (~:3666), `apply_new_language_request` (~:1624) — write multiple source files (base deck + overlays) sequentially and in place: + +- A failure mid-sequence (validation error while serializing a later file, disk full, process death) leaves the git-tracked source of truth in a state no single user action produces: base and overlay disagree, and verify may not catch it. +- An in-place write that dies partway leaves truncated YAML; the strict parser then rejects the whole workspace. +- No locking: two concurrent apply requests (or apply racing another handler's read) can interleave. + +Recovery today is "git checkout" — acceptable for disaster, but the workbench should be trustworthy over source files by construction. + +## Process + +TDD / red-green-refactor. Write failing tests first: + +1. An apply whose second output file fails validation/serialization must leave ALL files byte-identical to their pre-apply state (currently fails: first file is already written). +2. Simulated write failure during the write phase (e.g. injectable writer or read-only target file) must leave previously-written targets untouched (temp files may remain; targets must not change). +3. Torn-file protection: no code path writes a target file in place (assert temp+rename via the shared write helper). +4. Two concurrent apply requests are serialized: final state equals one apply followed by the other, never an interleaving. + +Then implement (green), then refactor the three apply paths onto one shared write-transaction helper. + +## Acceptance Criteria + +- All apply outputs are serialized and validated in memory BEFORE any file IO begins. +- Each file is written temp-file-in-same-directory + fsync + rename; a shared helper is the only way apply code touches disk. +- Apply handlers are mutually exclusive (mutex on the workspace state), and an apply bumps the freshness generation exactly once, after the write phase completes. +- On a rename-phase failure, the error response lists exactly which files were updated and which were not. +- Existing workbench integration tests and E2E pass; `cargo test --workspace` passes. + +## Design Decisions + +- Scope is the workbench apply/new-language write paths. If other CLI writers (e.g. `translations --apply` in translations.rs) can share the helper trivially, do so; otherwise leave them for a follow-up. +- No cross-file journal/WAL — temp+rename per file after full up-front serialization is sufficient for a single-user local tool; the failure window between renames is acceptable when reported precisely. diff --git a/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md b/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md new file mode 100644 index 0000000..1212592 --- /dev/null +++ b/.frontloop/default/done/0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md @@ -0,0 +1,48 @@ +--- +title: Migrate Workbench UI to a declarative state-driven architecture +priority: medium +--- + +## Resolution: promoted to the `workbench-leptos-migration` epic (2026-07-06) + +This placeholder is superseded. Per its own guidance ("if it stalls, split into a dedicated epic with the spike as its first task"), it has been promoted to a dedicated epic. The **Phase-0 spike is done**: Iced-on-web renders to a `` (wgpu/tiny-skia), emits no DOM, and cannot express Anki HTML/CSS card previews, `contenteditable` editing, or real `` media — which is exactly why the current code bypasses Iced with ~28 raw `set_inner_html`/`web_sys` ops. **Framework chosen: Leptos** (CSR, Rust→WASM DOM), which keeps ADR-0011's "Rust-native type-safe state/update/view" while gaining the DOM. + +The work is now tracked as six sequential tasks under `.frontloop/workbench-leptos-migration/` (0010 runtime swap → 0020 skeleton → 0030 notes view → 0040 notes workflows → 0050 card/source-string/metadata views → 0060 purge/CI). The ADR-0011 supersession is deferred to that epic's 0060 (lead owns the ADR record). + +Original placeholder text preserved below for reference. + +--- + +## Goal + +Replace the hand-rolled HTML-string/`innerHTML` layer in `crates/brain-brew-workbench-ui` with a real state→view architecture, so the UI renders deterministically from typed state, as ADR-0011 promised. No one uses the workbench yet, so breaking changes are acceptable; correctness and determinism over compatibility. + +## Problem + +ADR-0011 commits to an Iced/WASM state architecture, but the Iced app in `crates/brain-brew-workbench-ui/src/lib.rs` is a vestigial three-panel shell (view/update handle ~3 messages, ~lines 131-207). The entire real workbench (note list/pivot, card detail, source-string editing, staged edits, Apply) is built by `format!`-assembling HTML strings and injecting them via `web_sys` `innerHTML` with manually managed `Closure` event handlers (`publish_note_pivot_panel` ~:271, `note_html` ~:658, `card_detail_html` ~:1133). Concrete costs: + +- `note_html` is one `format!` with ~46 positional arguments (~lines 717-761); several builders share this shape. Positional drift is silent and the compiler cannot help. +- Two hand-rolled HTML escapers disagree: server `html_attribute_escape` escapes `'` (`workbench.rs` ~:3557), UI `escape_html` does not (~lib.rs:4754) — an injection seam in single-quoted attributes. +- E2E tests can only assert on raw DOM markup, so any markup change breaks them (against ADR-0014 intent). +- Stale artifact: UI status text says "loaded from /api/workbench/note-pivot" (~lib.rs:115) while code calls `/note-list` (~:4815). + +## Acceptance Criteria + +- All UI panels render from typed Rust state through the chosen framework's view layer; zero `innerHTML` string injection and zero hand-built HTML `format!` strings remain in `brain-brew-workbench-ui`. +- All event handling goes through the framework's message/event system; no manually managed `web_sys::Closure` listeners remain. +- Escaping is handled by the framework (or one shared audited escaper if any raw HTML rendering of deck content is still required, e.g. card previews — that path must be explicit and documented). +- ADR-0011 is superseded/amended by a new ADR recording the chosen framework and why. +- The existing E2E suite (`brain-brew-workbench-e2e`) passes, updated where markup assertions must change; prefer data-testid/semantic hooks over structural selectors while updating. +- Workbench API surface in the CLI crate is unchanged (this is a UI-crate migration). + +## Design Decisions + +- Phase 0 spike (timeboxed): evaluate whether current Iced WASM/DOM support can express the workbench (forms, text editing, lists, HTML card preview). If yes, fulfill ADR-0011 with Iced. If not, pick a declarative Rust WASM framework (Leptos or Dioxus are the expected candidates) and record the choice in the superseding ADR. +- Migrate panel-by-panel (note list → note pivot → card detail → apply flow), keeping the app shippable and E2E green at each step, rather than a big-bang rewrite. +- Card/answer HTML previews render deck-authored HTML by design; sandbox them (dedicated container, explicit trusted-content boundary) rather than letting deck content share the app's DOM context. + +## Implementation Notes + +- Server endpoints already return JSON (ADR-0015 list/detail shape); the UI migration should not need server changes beyond possibly retiring the legacy full-pivot scaffolding routes. +- Fix the stale status-string endpoint mention as part of the first migrated panel. +- This task is large; if it stalls, split into a dedicated epic with the spike as its first task. diff --git a/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md b/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md new file mode 100644 index 0000000..546e386 --- /dev/null +++ b/.frontloop/default/done/0140-wire-stale-translation-resolution-into-cli-and-workbench.md @@ -0,0 +1,41 @@ +--- +title: Wire stale-translation resolution into the CLI and Workbench +priority: high +--- + +## Goal + +A stale translation record can be resolved through the tool — "confirm still correct" (promote the existing translation under the new source text) or "replace" (provide a new translation, retire the stale record) — in both the translations CLI and the Workbench, completing the ADR-0013 workflow. Hand-editing overlay YAML stops being the only resolution path. + +## Problem + +Detection is thoroughly built: compose flags stale records, verify warns, the translations CLI counts/color-codes five stale coverage categories. But resolution exists nowhere: + +- `TranslationDictionary::resolve_stale_translation(old_source, new_source, context)` (`crates/brain-brew-core/src/lib.rs` ~:3996) — the purpose-built promote mutation, tested in `overlay_compose.rs` (~:490, :496) — has ZERO non-test callers. +- The Workbench, which ADR-0013 explicitly casts as the resolve surface, has no stale-resolution endpoint at all. +- The CLI apply flow detects stale rows and skips them (`translations.rs` ~:1096, "no safe automatic rewrite is applied, skipping") — correct fail-safe, but the guided flow dead-ends at exactly the records needing human judgment. + +Cost for UG: 16 languages × every English wording tweak = a steady drip of stale records, each costing a translator a YAML-archaeology session. The feature's value is capped by its resolution ergonomics. + +## Process + +TDD / red-green-refactor. Failing tests first, then implement, then refactor both surfaces onto shared plumbing: + +1. Core-adjacent: resolving a stale record via the CLI path rewrites the overlay dictionary correctly for both verbs — "confirm" promotes the existing translation under the new source text and removes the stale record; "replace" installs the new translation and removes the stale record. Assert exact canonical YAML output (goes through the canonical emitter, respects include preservation once that lands). +2. Resolution of a contextual stale record preserves its context key (the `Some("notes.note.finland")`-style case already covered in core tests must survive the CLI plumbing). +3. Batch confirm: an English change touching many notes with unchanged meaning can be confirmed in one command invocation; test a multi-record fixture. +4. Refusals: resolving a record that is not stale, or whose new source text doesn't match the current base, is a precise error, not a silent no-op (fail closed per ADR-0010). +5. Workbench: a stale record in the selected translation context is resolvable via an API endpoint for each verb; the write goes through the (queued) atomic-apply machinery and bumps the freshness generation; the next context fetch shows the record resolved. + +## Acceptance Criteria + +- `translations` CLI gains a resolve flow (subcommand or flags — match the existing CLI's conventions) offering confirm/replace, single-record and batch forms; the skip-warning path (~:1096) points users at it. +- Workbench API exposes stale resolution for the selected translation context (both verbs); UI affordance may be minimal (two actions on a stale row) and should land on whichever UI architecture is current — coordinate with `0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md` rather than building elaborate legacy-path UI. +- Both surfaces are thin adapters over `resolve_stale_translation` (and the existing overlay write/serialize plumbing) — no second resolution implementation. +- All dictionary writes produce canonical YAML byte-identical to what `fmt` would produce. +- `cargo test --workspace` passes; E2E extended with one stale-resolve happy path if the workbench UI affordance lands here. + +## Design Decisions + +- Sequencing: the CLI half has no dependencies — do it first. The Workbench half should follow the apply-atomicity task (`0120-make-workbench-apply-atomic-all-or-nothing.md`) and ride the UI migration rather than racing it. +- "Confirm" must NOT silently rewrite translations whose meaning plausibly changed — it is an explicit human verb, never an automatic rewrite (the current skip behavior stays the default for non-interactive apply). diff --git a/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md b/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md new file mode 100644 index 0000000..dfaa52f --- /dev/null +++ b/.frontloop/default/done/0160-add-structured-image-references-and-includable-media-block.md @@ -0,0 +1,40 @@ +--- +title: Add structured !image field references and an includable media block +priority: medium +--- + +## Goal + +Fields reference images structurally (`field.flag: !image ug-flag-zimbabwe.svg`) instead of embedding raw `` HTML, and the media declaration block can live in its own included file (`media: !include media.yaml`). Media references become exactly verifiable, the renderer owns image markup, and the deck source stops carrying ~1,600 lines of media boilerplate inline. + +## Problem + +- Image references are opaque HTML strings in field values. The media-integrity task's referenced-vs-declared verify check must regex-scrape `` out of content — workable but fragile, and the tool can never transform image markup (alt text, path rewriting) or diff image changes structurally. This mirrors the raw-string problem structured messages (ADR-0008 lineage) already solved for flag-similarity: same disease, same cure. +- Survey of the real UG repo (2026-07-03): 602 `` tags across deck + overlays, every single one exactly `` — no attributes, no variation. 221 flag fields + 319 map fields in `deck.yaml`; multi-image only in the hardcore extension (e.g. Bali: blur + normal pair). So a minimal `!image ` covers 100% of production usage, with a sequence form for the multi-image cases. +- `!include` is restricted to scalar content fields (`source_includes.rs` ~:101), so the 546-entry media block cannot be externalized from `deck.yaml` today. + +## Phase 1 — ADR (do this first, get it reviewed before implementing) + +Write ADR-00NN settling, with UG's surveyed usage as the evidence base: + +1. **Reference form**: `!image ` (path matches media declaration `path` and what's on disk — recommended, matches current HTML and stays greppable) vs `!image ` (rename-robust but indirect). Pick one, record why. +2. **Multi-image fields**: YAML sequence of tagged scalars (`field.flag: [!image a.png, !image b.png]` or block-sequence form) — confirm the canonical emitter/parser shape. +3. **Mixed text+image content**: out of scope unless the audit finds real usage (UG appears to have none — verify during the audit); raw HTML remains valid field content, `!image` is additive, not mandatory. +4. **Render contract**: at compose/export, `!image p` renders to exactly `` — byte-identical to today's UG output, so adoption is output-neutral and provable by export diff. +5. **Import reverse-mapping**: CrowdAnki import recognizes fields that are purely one-or-more `` tags and emits `!image` form; anything else stays raw HTML (safe fallback, not an error). +6. **Verify semantics**: `!image` references join the referenced-vs-declared check as exact matches (replacing the regex innards of the extraction function the media-integrity task isolates for this purpose). +7. **Includable media block**: lift the scalar-only `!include` restriction for whitelisted structural positions (at minimum `media:` in a deck) — NOT arbitrary mapping includes. Define interaction with the include-preserving fmt/verify work (tagged-scalar emission is shared machinery — coordinate with task `0070-make-fmt-and-format-verify-preserve-source-includes.md`) and with the `media hash` command (it must write hashes into the included file, through the include). + +## Phase 2 — Implementation + +- Parser + canonical emitter support for `!image` (scalar and sequence) in decks and overlays; compose/export rendering per the ADR; import reverse-mapping; verify exact-match integration. +- `media: !include` per the ADR, working through fmt (structure-preserving), verify, and `media hash`. +- Migration tooling: a one-shot command or script converting pure-`` fields to `!image` (UG has ~600 conversions; must be mechanical and output-neutral). +- Tests: round-trip (parse/emit/idempotence) for both new forms; export byte-equivalence on a fixture before/after migration; import reverse-map; verify exact matching; multi-image sequence case. +- `cargo test --workspace` passes; docs updated (authoring docs + yaml reference). + +## Design Decisions + +- Sequence AFTER the media-integrity task (which works with regex extraction and is not blocked on this) and coordinate with the include-preserving fmt task (shared tagged-scalar emission). +- Adoption in UG is a separate UG-side task to file once this ships (run the migration, externalize the media block, verify output-neutrality by export diff). +- The includable-media-block half (#7) is severable: if it turns out to fight the include-preservation work, split it out rather than stalling `!image`. diff --git a/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md b/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md new file mode 100644 index 0000000..e1a66c8 --- /dev/null +++ b/.frontloop/default/done/0170-add-html-css-content-validation-to-verify.md @@ -0,0 +1,38 @@ +--- +title: Add HTML/CSS content validation to verify +priority: medium +--- + +## Goal + +`verify` validates the HTML and CSS content a deck ships to Anki — template fragments, deck descriptions, styling — natively in Rust, wherever that content lives (inline or `!include`d). Brain Brew is an Anki deck tool; HTML is always part of the product, so the tool owns its validation. UG's Python side-script becomes deletable. + +## Problem + +`verify` checks YAML structure, translations, and media, but never looks inside template/description/styling content. A template with a mismatched tag or a stylesheet with an unbalanced brace passes verify and export, then fails at render time in Anki on a user's device. UG plugged the hole with `scripts/check-source-content.py` (~150 lines of Python: HTML fragment well-formedness via `html.parser`, CSS brace/comment/string balance), wired into `integrity-check.yml:26` and CONTRIBUTING: + +- Protection is UG-only and layout-coupled: the script scans three hardcoded directories (`descriptions/`, `templates/`, `styles/`), so other consumers get nothing and UG's INLINE HTML (field values, non-externalized content) is invisible to it. +- It drags a Python runtime into otherwise pure-Rust/Nix CI — the out-of-band-script pattern this rewrite exists to end. +- It runs too late: CI-time only. The workbench will happily APPLY a malformed template edit today. + +## Process + +Parity first, then port: + +1. Capture the Python script's actual tolerances as a test corpus BEFORE writing Rust: run it against UG's real templates/descriptions/styles plus deliberately broken variants (mismatched tag, unclosed tag, stray ``, unbalanced brace, unterminated comment/string in CSS, `{{Field}}` and `{{cloze:...}}` mustache, void elements like `
`/``, HTML entities). Record accept/reject for each — the Rust check must not silently reject content the script blessed or accept what it caught. +2. Implement the checks in Rust against that corpus (red-green), as a pure function over content strings in core or formats — no filesystem knowledge. +3. Wire into `verify` over the COMPOSED deck (covers inline and included content uniformly, per target), and expose the same function to the workbench apply path so malformed template edits are rejected at write time. + +## Acceptance Criteria + +- A `verify` sub-check validates, for every target: note-model templates (question/answer), deck descriptions, and styling — reporting file/path-precise errors (which template, which target, what's wrong). +- Checks match the side-script's lightweight ambition (this is NOT a spec-grade HTML validator): fragment tag balance tolerant of Anki mustache syntax and void elements; CSS brace/comment/string balance. No heavyweight parser dependency (no html5ever) unless the parity corpus forces it. +- Hand-rolled in Rust, zero Python anywhere; pure function reusable by the workbench apply path (wiring apply-side rejection may land here or ride the apply-atomicity task — either way the function is shared, not duplicated). +- The parity corpus lives in the repo as table-driven tests. +- An escape hatch exists for false positives (per-check severity or an opt-out flag), documented — Anki tolerates a lot of sloppy HTML; the check must not block a deck Anki renders fine without a way out. +- `cargo test --workspace` passes; verify docs updated. + +## Design Decisions + +- Scope creep guard: no CSS property validation, no HTML attribute/semantics linting, no external references — structural well-formedness only, matching what the Python script proved useful. +- UG-side deletion of the script is a separate task in the UG repo, triggered on the next Brain Brew publish. diff --git a/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md b/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md new file mode 100644 index 0000000..096df2b --- /dev/null +++ b/.frontloop/default/done/0180-replace-fixed-sleep-e2e-assertions-with-polling.md @@ -0,0 +1,29 @@ +--- +title: Replace fixed-sleep E2E assertions with condition polling +priority: low +--- + +## Goal + +No E2E assertion depends on wall-clock timing: the loading/stale-state scenario polls for observable conditions using the suite's existing `wait_for_*` helpers, per ADR-0014. + +## Problem + +`crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs` ~:406 and ~:441 inject an artificial 800ms delay into a server response (to exercise the workbench loading/stale-state UI), then do a fixed 950ms sleep before asserting the UI has settled: + +- It races with a 150ms margin: the assertion holds only if the delayed response + render round-trip completes within 150ms of the injected delay expiring. CI load, WebDriver latency, or WASM startup jitter eats that margin → unreproducible flake. +- It is slow even when passing: every run pays the full 950ms unconditionally, twice. +- It violates ADR-0014 in a file whose own conventions (`wait_for_*` polling helpers, used everywhere else) show the rule is known — a standing bad example for the next test author to copy. + +## Acceptance Criteria + +- Both fixed sleeps are gone; the scenario polls for observable conditions with the suite's standard `wait_for_*` helpers and timeout: + - If the scenario must assert the intermediate state (loading/stale indicator visible during the delay), poll for that state FIRST, then poll for the settled state — both are conditions, neither needs a clock. +- No new fixed sleeps anywhere in the E2E crate; a quick sweep confirms these two were the only wall-clock assertions (fix any others found the same way). +- Consider shrinking the 800ms injected delay once nothing sleeps against it — keep it just large enough that the intermediate-state poll can reliably observe the loading state; note the chosen value. +- The scenario still meaningfully exercises the loading/stale-state UI (do not delete the intermediate-state assertion to make polling trivial). +- E2E suite passes (BRAINBREW_E2E_* env per existing docs); `cargo test --workspace` unaffected. + +## Design Decisions + +- Test-only change; no production code, no server changes beyond (optionally) the injected-delay parameter the test already controls. diff --git a/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md b/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md new file mode 100644 index 0000000..757f966 --- /dev/null +++ b/.frontloop/default/done/0190-fix-stale-target-count-in-ug-example-docs.md @@ -0,0 +1,23 @@ +--- +title: Fix stale target count in the ultimate-geography example docs +priority: low +--- + +## Goal + +The ultimate-geography worked example in the docs reflects the real UG repo, and no longer hardcodes a target count that rots as UG grows. + +## Problem + +`documentation/docs/examples/ultimate-geography.md` still claims "56 verified targets" (line 27) and shows `✓ verified 56 targets` sample output (line 44). The real UG repo now builds roughly 80 targets across its two manifests (`brainbrew.yaml` + `brainbrew-hardcore.yaml`, 16 languages including the later-added Hebrew). This page is the only real-world example in the docs — its whole purpose is to be a trustworthy worked example, and stale numbers on it signal "the docs aren't maintained". + +## Acceptance Criteria + +- Re-verify the current numbers first against the live repo (`~/Development/external/ultimate-geography`, or its upstream): run `verify --all-targets` / `targets` per manifest and use the actual counts. Do not trust the "~80" in this task file. +- Update the prose and the sample output. De-specify where possible so the page can't rot the same way again — e.g. "all language targets across both manifests" in prose, and mark the sample-output count as illustrative (or regenerate it as part of the edit and date it). +- While on the page, sweep it for other stale facts against the real repo (language count, manifest names, layout paths — the repo reorganized to `templates//{question,answer}.html`); fix what's cheap, note anything bigger as a follow-up rather than expanding scope. +- Docs build passes (whatever `documentation/` uses to build/lint). + +## Design Decisions + +- Docs-only change; no code, no fixtures. Fixture drift vs the real repo is a separate concern. diff --git a/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md b/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md new file mode 100644 index 0000000..e97360a --- /dev/null +++ b/.frontloop/default/done/0200-mark-lock-package-federation-surface-experimental.md @@ -0,0 +1,28 @@ +--- +title: Mark the lock/package federation surface experimental before the next publish +priority: medium +--- + +## Goal + +The lock/package subsystem (`brainbrew lock`, `FederationLock`, downstream deck packages) is honestly labeled **experimental** across docs and CLI, and explicitly excluded from the stability promise of the upcoming crates.io release — without deleting or destabilizing any of the working, tested code. + +## Problem + +The lock/package federation machinery is fully built and tested (`crates/brain-brew-cli/src/commands/lock.rs` ~885 lines, `crates/brain-brew-formats/src/lockfile.rs` ~238 lines; remote package fetch, gzipped NAR unpack, NAR-style hash pinning; ~41 references in the CLI test suite) and prominently documented (`documentation/docs/reference/lockfile.md`, `examples/downstream-package.md`, featured in `intro.md` and `concepts/what-is-federation.md`). It has **zero real consumers**: ultimate-geography — the only production deck — has no lock file; no downstream package exists anywhere. + +Publishing the next version with this surface presented as a stable pillar freezes a lockfile format and CLI contract that no real usage has pressure-tested. The decision (2026-07-03 review, issue 20) was to **relabel**, not trim: keep the code and the vision, drop the implicit stability commitment. + +## Acceptance Criteria + +- Every doc page presenting the lock/package feature (`reference/lockfile.md`, `examples/downstream-package.md`, `intro.md`, `concepts/what-is-federation.md`, plus mentions in `getting-started/cli-tour.md`, `reference/project-scope.md`, `reference/releasing.md` — re-grep for the full list, don't trust this one) carries a clear, consistent experimental notice: the feature works, but the lockfile format and `lock` CLI surface may change incompatibly in any release until a real downstream consumer stabilizes them. +- `brainbrew lock --help` (and any subcommand help text) states the experimental status in one line. +- The stability/versioning story is stated once, centrally (`releasing.md` or wherever the release policy lives): what IS covered by the next release's compatibility promise (canonical YAML format, deck/overlay semantics, core CLI verbs) and that lock/package is explicitly outside it. +- No code behavior changes; `cargo test --workspace` still passes untouched. +- Zero deletions: all lock/package code, tests, and docs remain — this is labeling, not trimming. + +## Design Decisions + +- Sequencing: MUST land before the next crates.io publish (the whole point is what that release promises). Pairs with the UG-side version-pinning task only in timing, not content. +- Tombstone/patch/personal overlay kinds are NOT part of this task — they were reviewed and judged cheap, coherent parts of the overlay algebra needing no relabel (review issue 20, tiers 2–3). +- If a real downstream consumer appears later, removing the experimental notices is the closing act of whatever task onboards it. diff --git a/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md b/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md new file mode 100644 index 0000000..d2950bb --- /dev/null +++ b/.frontloop/default/ready/0150-add-cross-language-matrix-view-to-workbench.md @@ -0,0 +1,32 @@ +--- +title: Add a cross-language matrix view to the Workbench +priority: medium +--- + +## Goal + +For a selected note (or source string), the Workbench shows all languages side by side in one table — resolved value plus translation status per language — restoring the cross-language visibility the old CSV world had for free (one row per fact, one column per language) and the overlay architecture deliberately traded away without a replacement. + +## Problem + +All three workbench pivots (`note-pivot`, `source-string-pivot`, `card-pivot` — `workbench.rs` ~:1302-1318) take a single `language`/`target`/`overlay` selection; nothing in the tool shows one note across languages. Comparing 16 languages of `field.capital` for one note today means opening 16 overlay files or re-selecting the workbench context 16 times. Daily UG moments this blocks: + +- Reviewing a new-language PR (e.g. Hebrew) next to sibling languages. +- After an English source edit: which of the 16 languages are resolved vs still stale vs missing — an inherently cross-language question the tool can only answer one language at a time. +- Terminology-consistency checks across related languages (da/no/sv, es/pt). + +## Acceptance Criteria + +- A workbench API endpoint returning, for a selected note (and optionally a single field or source string), a matrix: one row per language target, with the composed/resolved value and a status per cell (translated / stale / missing / no_change — reuse the existing coverage categories, don't invent a parallel taxonomy). +- The matrix is built from the SAME resolution logic compose/verify use (via the unified resolver once `0050-unify-translation-resolution-into-single-resolver.md` lands) — what the matrix says must be what ships. +- Composing all language targets per request rides the generation-keyed composition cache (`0030-cache-workspace-composition-in-workbench-server.md` is a hard dependency — without it this view is ~16 full compositions per click). +- A workbench UI panel renders the matrix with staleness/missing visually flagged; built on the migrated declarative UI (`0130-migrate-workbench-ui-to-declarative-state-driven-architecture.md`), not as new `format!`-HTML on the legacy path. +- Optional/severable: a CLI twin (e.g. `translations matrix --note X [--field Y]`) emitting the same data as aligned text or TSV for terminal-based PR review. +- Integration test on the UG fixture: matrix for one note returns a row per language with correct statuses (include a stale and a missing case); E2E covers rendering one matrix. +- `cargo test --workspace` passes. + +## Design Decisions + +- Read-only view in this task. Editing cells in place, or resolving stale records from the matrix, are natural follow-ups that ride the stale-resolution task (`0140-wire-stale-translation-resolution-into-cli-and-workbench.md`) — link them, don't build them here. +- Sequencing: after the composition cache (hard), after/with the UI migration (strong preference), after the resolver unification (correctness of the status column). +- Row set = language targets from the manifest; the view must not hardcode UG's language list or assume every language covers every note (missing IS a status, not an error). diff --git a/.frontloop/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md b/.frontloop/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md new file mode 100644 index 0000000..6abd2a1 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0010-runtime-swap-remove-iced-boot-dom-layer-from-leptos.md @@ -0,0 +1,49 @@ +--- +title: Runtime swap — remove Iced, boot the existing DOM layer from Leptos +priority: high +--- + +## Goal + +Remove Iced entirely from `crates/brain-brew-workbench-ui` and replace it with a minimal Leptos (CSR) bootstrap, keeping the existing imperative DOM layer (`publish_*` functions, event `Closure`s, localStorage helpers, generation counters) byte-for-byte functional. Runtime swap, not a rewrite: after this stage the app behaves identically and all 13 E2E scenarios stay green. **This is the de-risk milestone — delegate first, judge, and integrate before any later stage.** + +## Background (verified) + +`run()` (lib.rs:29) starts `iced::application(WorkbenchApp::new, update, view)`. The Iced layer does only this: on start, `Task::perform(fetch_workspace())` + `Task::perform(fetch_note_pivot())`; on completion, `publish_workspace_probe("loaded", …)` / `publish_note_pivot_panel(&pivot)` (or error variants; `NotePivotLoaded(Err)` with `is_stale_workbench_request` is swallowed). `Message::RefreshWorkspace` is never dispatched from the DOM — all interactivity is self-contained `web_sys`. The Iced `view()` renders a decorative canvas shell no E2E test observes; it may disappear. + +## Work items + +1. `Cargo.toml`: delete both `iced` deps (wasm32 + non-wasm32 sections). Add `leptos` 0.8.x `features = ["csr"]` (pin the current 0.8 patch at impl time) under the wasm32 target (or unconditional if it builds natively cleanly — native `devenv shell test` build must stay green). Keep `gloo-net`, `wasm-bindgen`, `wasm-bindgen-futures`, `web-sys`, `serde_json`. Update crate `description` (currently "Iced/WASM frontend") to Leptos. +2. `src/main.rs`: replace `fn main() -> iced::Result {…}` with a Leptos CSR entry: on wasm32, `leptos::mount::mount_to_body(App)` (+ optional console_error_panic_hook); on non-wasm32, `fn main() {}` so `--all-targets` builds. +3. `src/lib.rs`: delete `run()`, `theme()`, `WorkbenchApp`, `Message`, the Iced `view()`, all `iced::` imports. Add a minimal wasm32-gated `App` component that renders nothing visible of its own and, on mount, does exactly what `WorkbenchApp::new` + `update` did (set probe loading; fetch `/api/workspace` → `publish_workspace_probe`; fetch note pivot → `publish_note_pivot_panel` / swallow stale / `publish_note_pivot_error`). Preserve exact status strings — probe text is asserted. Keep `WorkspaceSummary`/`from_workspace_json` public and unchanged (`tests/workspace_summary.rs` must stay green unmodified). +4. Do NOT restructure/rename/"improve" any `publish_*` fn, event wiring, generation counter, or localStorage helper this stage. Mechanical deletion of dead Iced-only code is fine; behavior changes are not. +5. Do not modify `index.html`. `mount_to_body` appends after the static `#brainbrew-workbench-e2e` div; probe updates continue via the existing `publish_workspace_probe` path. +6. Regenerate embedded release assets: `devenv shell workbench-ui-embed`, include the refreshed `crates/brain-brew-cli/assets/workbench` in the payload (the one sanctioned write outside the UI crate — purges Iced/wgpu from shipped assets). + +## Acceptance Criteria + +- `rg -i "iced|wgpu|tiny-skia" crates/brain-brew-workbench-ui/ Cargo.lock` shows no iced/wgpu/tiny-skia entries attributable to this crate (Cargo.lock loses the iced tree entirely). +- All validation gates green, including full **13/13** `devenv shell e2e`. Zero E2E test-file edits. +- `tests/workspace_summary.rs` unmodified and green. +- `crates/brain-brew-cli/assets/workbench` regenerated via workbench-ui-embed. +- Report: exact Leptos version pinned; whether wasm-bindgen moved off the locked 0.2.125; any Trunk/devenv friction (this stage exists to surface it). + +## What green demonstrates + +Leptos 0.8 CSR builds under nix-pinned Trunk 0.21.14 / wasm-bindgen-cli with the existing `index.html` `data-bin` entry and no Trunk.toml; `mount_to_body` coexists with the static boot anchor and the `set_inner_html` layer; the Iced/wgpu tree is fully excised; and all 13 scenarios survive the swap. If any fails, we spent one Run, not six. + +## Delegation + +- Agentleman Run `agm-run-20260706142040-dilnlr` (workspace handle `wb-leptos-0010`), delegated 2026-07-06, `workspace: true`, maxFixAttempts 3. +- Automated checks wired: `devenv shell test`, `devenv shell workbench-ui-build`, `devenv shell e2e`. +- On done: apply the Fable judge gate against these acceptance criteria before integrating; do not accept an unjudged payload. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Fable judge verdict **ACCEPT** — all 9 acceptance criteria verified against the actual diff; imperative DOM layer untouched (only dead native no-op stubs deleted); 13/13 e2e confirmed in the run transcript; wasm 3.69MB→386KB; zero iced/wgpu strings; exactly 9 changed files; server/e2e-oracle/devenv.nix untouched. Non-blocking notes: added `console_error_panic_hook` (standard, wasm32-only); removed a now-dead Iced-internal status string (never DOM-asserted). Leptos 0.8.20 pinned; wasm-bindgen held at 0.2.125 (no devenv.nix bump needed). + +Integrated into the `default` workspace as jj change `pwtunoykqqky` (commit `0bb…`). Undo: `jj op restore b4d04e758cd5`. + +## Risk + +Toolchain compat is existential. If the trunk build fails on wasm-bindgen version skew (Cargo.lock 0.2.125 vs pinned CLI), the fix is a `devenv.nix` pin bump — **parent-owned; surface it, do not hack around it in the delegate.** diff --git a/.frontloop/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md b/.frontloop/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md new file mode 100644 index 0000000..96a2ff0 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0020-leptos-owned-panel-skeleton.md @@ -0,0 +1,36 @@ +--- +title: Leptos-owned panel skeleton — view switcher, view sections, probe, error surface +priority: high +--- + +## Goal + +Make Leptos own the structural DOM the legacy code rebuilds wholesale via `set_inner_html`: `#workbench-dom-panel`, the `#workbench-view-switch` nav and its `.workbench-view-button`s, the four `.workbench-view[data-view]` sections (`notes`/`cards`/`source-strings`/`metadata`, ids `view-panel-notes` etc., lib.rs ~368–427; switch builder ~438), workspace probe updates, and the top-level error display (`publish_note_pivot_error` / `.workbench-error`). View interiors stay legacy: each section exposes a stable inner container that the existing `publish_*` functions render into. + +Depends on: 0010 (Leptos bootstrap in place). + +## Work items + +1. Introduce the reactive spine: signals for `selected_view` (Notes/Cards/SourceStrings/Metadata), workspace summary, top-level error/status. Replace `WORKBENCH_SELECTION_GENERATION` consumers only where they guard *view switching*; leave per-detail generation counters for later stages. Preserve stale-selection semantics exactly — a late response from a superseded selection is dropped, not rendered (E2E `workbench_ignores_stale_language_reload_responses` asserts the stale node is absent from `#workbench-dom-panel`). +2. Re-scope the legacy publish functions minimally so each renders only its view's interior into its section container instead of regenerating the whole panel + switcher. Do not change the HTML they emit inside sections. +3. Keep view activation lazy: switching to a view triggers that view's legacy fetch/publish on first activation; non-active views must not fetch (E2E instruments `window.fetch`, `assert_no_secondary_pivot_fetches`). Preserve `.active`/`[hidden]`/`aria-current="page"` on sections and buttons. +4. The switcher must render OUTSIDE all `.workbench-view` sections (E2E checks the nested case finds nothing). +5. Move probe updates to a Leptos effect if convenient, keeping identical `data-status` transitions/text; keeping the web_sys impl is also fine. +6. Reuse `static/workbench.css` as-is; add no stylesheet unless a class is genuinely missing (report additions). + +## Delegation + +- Agentleman Run `agm-run-20260706152604-7rinyr` (workspace `wb-leptos-0020`), delegated 2026-07-06 via `agm run` + a session-scoped `jjw`→`ajj` shim (the MCP delegate tool is broken by the rename — see [[agentleman-integration-via-ajj]]). Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate, then integrate with `ajj stack wb-leptos-0020 --repo … --yes` + `jj workspace update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Fable judge **ACCEPT**. Leptos now owns `#workbench-dom-panel`, `#workbench-view-switch` + `.workbench-view-button`s, the four `.workbench-view` sections (stable interior containers `#note-pivot-panel`/`#card-pivot-panel`/`#source-string-pivot-panel`/`#optional-metadata-panel`), and a signal-driven `.workbench-error` surface; legacy `publish_*` fill interiors with byte-identical HTML. 13/13 e2e (transcript-evidenced). Single file changed (`lib.rs`, +337/−96). Probe intentionally stays the static `#brainbrew-workbench-e2e` div in index.html (index.html is no-go). Integrated as jj change `skpqtnzu` (commit `66c…`). Undo: `jj op restore f5dc34a6e268`. + +Carry-forward: ~60 lines of dead view-switch code (`workbench_view_switch_html`, `register_view_switch_handlers`, `attach_view_switch_handler`, `activate_workbench_view`) left under `#[allow(dead_code)]` — delete in 0060. + +## Acceptance Criteria + +- All validation gates green; **13/13** E2E; zero E2E test edits (if the Leptos DOM legitimately cannot reproduce a pinned selector, STOP and report rather than editing the test). +- Diff shows the whole-panel `set_inner_html` rebuild replaced by a Leptos-rendered skeleton + per-section legacy publishes. +- Report which publish functions were re-scoped. diff --git a/.frontloop/workbench-leptos-migration/done/0030-componentize-notes-view.md b/.frontloop/workbench-leptos-migration/done/0030-componentize-notes-view.md new file mode 100644 index 0000000..a277173 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0030-componentize-notes-view.md @@ -0,0 +1,38 @@ +--- +title: Componentize the notes view — nav, detail, contenteditable fields, card previews, media +priority: high +--- + +## Goal + +Rewrite the notes-view interior as Leptos components, deleting the corresponding legacy string-HTML builders and event closures: note navigation (windowed `.note-navigation-row`, `note-navigation-more`, `navigation-page-summary`), single note detail (`.note-card`, `#note-detail-panel`, `.field-editor` rows), contenteditable field editors (target-translation + source-field, `set_contenteditable`, ids `card-translation-input-*` / `card-source-input-*`), Anki card HTML previews (`note_html` and friends), and media rendering. Staged-edit writes go through a new `staging` module (signals backed by localStorage) preserving the existing key schema exactly. Migrate `NOTE_DETAIL_GENERATION` from `thread_local!` to a signal/`StoredValue` request token with identical drop-stale semantics. Apply box, new-language panel, and multi-pane comparison stay legacy (0040) — keep their publish paths working against the new structure. + +Depends on: 0020. + +## Delegation + +- Agentleman Run `agm-run-20260706155534-tel4o2` (workspace `wb-leptos-0030`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate, then `ajj stack wb-leptos-0030 --repo … --yes` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT (after 1 iterate), integrated + +Notes-view interior componentized to Leptos; new `staging.rs` (signals + localStorage, exact key schema); `NOTE_DETAIL_GENERATION`→`NOTE_DETAIL_TOKEN_SIGNAL`; contenteditable keyed per note+field, never written back to the focused input; 9 legacy note-view functions deleted. Files: `lib.rs` + new `staging.rs`. + +**Iterate:** first Fable pass caught a silent bug — new staging added `context_path` to notes-view contextual edits (legacy handlers never did), which would drift Apply's YAML (field-path context vs consolidated note-level). Fixed via `agm continue` (change `luzywtovqzxo` "preserve note contextual staging shape"): notes view now stages `{kind,path,source,value,mode}`; card/source-string views keep their own legitimate context_path. Re-judged ACCEPT against original criteria. 13/13 e2e post-fix. + +Integrated as changes `xwznzyntnrnk` (migrate) + `luzywtovqzxo` (fix). Undo: `jj op restore a0225891d131`. + +## Critical constraints + +- **contenteditable + reactivity**: never reactively bind element text to the same signal the `input` event writes — re-rendering a focused contenteditable resets the cursor and breaks the typing E2E. Pattern: render initial content once per (note, field) key (key the element on note/field identity so navigation recreates it); handle `on:input` by reading `event_target` textContent into the staging signal; never write that signal back into the focused element. Verify manually via `devenv shell workbench-ui-watch` that typing mid-word keeps the cursor. +- **Media**: `` `src` must be exactly the `/api/media/` URLs the server emits today (rewriting logic ~lib.rs:3801 — port, don't reinterpret). E2E fetches each `src` asserting HTTP status + content-type and checks natural dimensions; a Trunk-mangled/relative URL fails. +- **Row budgets**: preserve windowing — `.note-navigation-row` count and `#note-detail-panel .field-editor tbody tr` count within `WORKBENCH_NAVIGATION_ROW_BUDGET`/`WORKBENCH_DETAIL_ROW_BUDGET`; at most one `.note-card`. Do not render full lists and hide with CSS. +- **localStorage**: staging module reads/writes the exact keys the legacy helpers produce (prefixes + `translation::`/`source::`) — port key-derivation verbatim; E2E refreshes the page expecting staged edits to survive, and the (still-legacy) apply flow reads these keys. +- Preserve stale-note-detail: selecting note B while note A's detail fetch is in flight drops A's response. + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_app_shell_loads_workspace_metadata`, `workbench_ignores_stale_language_reload_responses`, `workbench_edits_target_translation_persists_refresh_and_applies_yaml`, `workbench_edits_source_field_persists_refresh_and_creates_stale_translation`, and the three `workbench_ultimate_geography_*` / `workbench_loads_ug_like_repeated_source_smoke_path` (media + manifest + row budgets). +- Legacy note-view HTML builders and their `Closure` wiring deleted, not shadowed. +- Zero E2E test edits without explicit reported justification. +- Report: how contenteditable is keyed; how the generation token replaced `NOTE_DETAIL_GENERATION`. diff --git a/.frontloop/workbench-leptos-migration/done/0040-componentize-notes-workflows.md b/.frontloop/workbench-leptos-migration/done/0040-componentize-notes-workflows.md new file mode 100644 index 0000000..670854e --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0040-componentize-notes-workflows.md @@ -0,0 +1,31 @@ +--- +title: Componentize notes-view workflows — apply box, apply preview, new-language, multi-pane +priority: high +--- + +## Goal + +Rewrite the remaining notes-view surfaces as Leptos components, deleting their legacy builders/closures: the apply box (`.apply-box`; POST `/api/workbench/apply-preview` + `/api/workbench/apply`, lib.rs ~4454), the new-language panel (`.new-language-panel`; GET `new-language-preview`, POST `new-language`, ~3440), and the multi-pane secondary comparison (`publish_secondary_target_pane`, `.pane-layout-panel`, GET `comparison-pane`). These read staged edits from the 0030 staging module and clear/refresh state after apply exactly as the legacy flow does (post-apply refresh must NOT start a new user selection — see comment ~lib.rs:4787; preserve that). + +Depends on: 0030 (staging module + note components). + +## Delegation + +- Agentleman Run `agm-run-20260706170848-1la4br` (workspace `wb-leptos-0040`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- On done: Fable judge gate (watch apply/preview/new-language request-JSON parity — highest silent-drift risk), then `ajj stack wb-leptos-0040 --repo … --yes` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Apply box, apply-preview, new-language scaffold, and multi-pane comparison componentized to Leptos. Apply/apply-preview/new-language request JSON verified byte-identical to legacy builders (Fable checked field-for-field — the highest silent-drift risk); staged edits collected via `staging::collect_staged_edits_for_prefixes(active_storage_prefixes(pivot))` (active pivot + secondary panes, scope unchanged); post-apply clear/refresh guard preserved. 17 legacy builders/closures deleted; only `lib.rs` changed; 13/13 e2e. Integrated as change `qulrqznup` "port remaining notes workflow panels to leptos". Undo: `jj op restore 2b691c858d05`. + +## Constraints + +- Request/response payloads to apply/apply-preview/new-language stay identical to what the server expects — port the JSON construction, don't redesign it. +- After a successful apply, staged localStorage entries are cleared the same way. +- Grouped cross-file apply and mixed source+target flows are the most ordering-sensitive: staged source edits must take effect before dependent target rows are computed client-side — port the `effective_source` overlay logic faithfully (~lib.rs:1705/1817/3972/4097). + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_new_language_scaffold_creates_editable_language`, `workbench_multi_pane_layout_applies_grouped_changes_across_files`, `workbench_mixed_source_and_target_browser_apply_uses_new_source`, and re-confirm `workbench_edits_target_translation_persists_refresh_and_applies_yaml` (apply path now componentized). +- Legacy builders for these panels deleted. +- Zero E2E test edits without reported justification. diff --git a/.frontloop/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md b/.frontloop/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md new file mode 100644 index 0000000..ddf6721 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0050-componentize-card-source-string-metadata-views.md @@ -0,0 +1,39 @@ +--- +title: Componentize card, source-string, and metadata views +priority: high +--- + +## Goal + +Rewrite the three remaining view interiors as Leptos components and delete their legacy counterparts: + +- **Card view**: `publish_card_list_panel`, `publish_card_detail_panel`, `publish_card_pivot_panel`; `#card-pivot-panel`, `.card-row`, card filters; endpoints `card-list`/`card-pivot`. +- **Source-string view**: `publish_source_string_list_panel`, `_detail_panel`, `_pivot_panel`; `#source-string-pivot-panel`, `.source-string-row`, `source-string-contextual-input-*` contenteditable; endpoints `source-string-list`/`source-string-pivot`. +- **Metadata view**: `publish_optional_metadata_panel`, the optional-metadata checklist; endpoints `metadata`/`metadata-list`. + +Migrate `CARD_DETAIL_GENERATION` and `SOURCE_STRING_DETAIL_GENERATION` to the same signal-token pattern from 0030. Card/source-string field editing goes through the shared staging module (same key schema). Follow the same contenteditable, row-budget, and lazy-activation constraints as 0030 (these views must not fetch until first activated; row counts within `WORKBENCH_NAVIGATION_ROW_BUDGET`). + +Also componentize the **global language/target/overlay controls** (`#workbench-global-controls`, filled today by legacy `set_inner_html` at lib.rs ~1633) so that after 0050 the only remaining raw-HTML injection is trusted card/answer preview bodies (rendered via Leptos `inner_html`), leaving 0060 a true purge. If including the global controls makes this stage too large, complete the three views first and report so the controls can be split out. + +Depends on: 0030 (staging module + token pattern), 0040. + +## Delegation + +- Agentleman Run `agm-run-20260706173111-tl5vqe` (workspace `wb-leptos-0050`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- Note: card & source-string staged translations LEGITIMATELY include `context_path` (unlike the notes view) — do NOT strip it. On done: Fable judge, then `ajj stack` + `update-stale`. + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated + +Card, source-string, metadata view interiors AND global controls componentized to Leptos. `CARD_DETAIL_GENERATION`/`SOURCE_STRING_DETAIL_GENERATION` → `CARD_/SOURCE_STRING_DETAIL_TOKEN_SIGNAL` RwSignal tokens. **All `set_inner_html` drained** — 5 remaining `inner_html` sites are trusted deck-authored preview bodies only. `context_path` split correct (card/source-string keep it, notes don't; `staging.rs` byte-unchanged). Lazy activation + pagination preserved. Only `lib.rs` changed; 13/13 e2e (delegate self-corrected a transient 12/1 mid-run). Integrated as change `okolynrzwyoo` "port remaining pivots to leptos". Undo: `jj op restore cc42438c8867`. + +The entire Workbench UI now renders from Leptos. 0060 is a true purge. + +## Acceptance Criteria + +- All gates green; **13/13** E2E — especially `workbench_card_pivot_navigates_and_edits_card_field`, `workbench_source_string_pivot_stages_direct_translation`, `workbench_optional_metadata_checklist_edits_separately`, plus re-run confidence on the notes suite (shared staging module now has three consumers). +- All three views' legacy builders and closures deleted. +- Zero E2E test edits without reported justification. + +## Note + +Largest remaining chunk. Views are structurally similar (list/detail/pivot each) and share the established staging module + token pattern. If it stalls, complete and validate view-by-view and report honestly rather than delivering a broken mixture — the orchestrator can `agm continue` the remainder. diff --git a/.frontloop/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md b/.frontloop/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md new file mode 100644 index 0000000..78f9a34 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/done/0060-purge-legacy-remnants-trim-deps-full-ci.md @@ -0,0 +1,36 @@ +--- +title: Purge legacy remnants, trim dependencies, embed refresh, full CI +priority: medium +--- + +## Outcome (2026-07-06): DONE, judged ACCEPT, integrated — EPIC COMPLETE + +All `thread_local!` removed (selection/note-pivot/detail generations moved into a `WorkbenchSignals` `RwSignal` holder in a `OnceLock`; drop-stale guard + generation semantics verified preserved). `wasm-bindgen-futures` dropped (all 12 `spawn_local` sites → `leptos::task::spawn_local`); `wasm-bindgen`/`gloo-net`/`web-sys` kept as still-used. Dead CSS (`.card-filters`, `.error`) removed. `inner_html` only on trusted deck-preview bodies. `staging.rs` byte-unchanged. `cli.md` + `workbench.md` updated to Leptos; ADRs + research doc left historical. Embedded assets regenerated. **Full `devenv shell ci` green, 13/13 e2e.** Integrated as change `rzvlzorrkynm` "purge leptos migration leftovers". Undo: `jj op restore 1ad53fae7690`. + +**Follow-up for the lead:** supersede ADR-0011 (which committed to Iced) with a new ADR recording the Leptos decision. Delegate recommended it but (correctly) did not write it. + +## Goal + +Finish the migration. Remove every remaining legacy artifact: unused `thread_local!` cells, `set_inner_html`-based helpers, dead `Closure` wiring, orphaned localStorage helper duplicates, and `#[cfg]` scaffolding left from the strangler phases. Trim `Cargo.toml`: drop `web-sys` features no longer referenced (audit each of Document/Element/Event/EventTarget/HtmlInputElement/HtmlSelectElement/Node/NodeList/Storage/Window against actual usage — Leptos re-exports much of this); drop `wasm-bindgen`/`wasm-bindgen-futures` as direct deps if nothing references them directly; keep `gloo-net` only if still the fetch layer. Sanity-pass `static/workbench.css` for selectors no longer matching any rendered DOM — remove only provably dead rules, leave anything E2E references. Update stale in-crate comments/docs still describing Iced or `set_inner_html`. + +Depends on: 0050 (all views componentized). + +## Delegation + +- Agentleman Run `agm-run-20260706175839-nvgybb` (workspace `wb-leptos-0060`), delegated 2026-07-06 via `agm run` + session-scoped `jjw`→`ajj` shim. Checks: `devenv shell test` / `workbench-ui-build` / `e2e`. +- Sanctioned out-of-crate writes: regenerate `crates/brain-brew-cli/assets/workbench`; factual Iced→Leptos wording in `documentation/docs/reference/cli.md` + `workbench.md` only (ADRs + research doc left historical; delegate recommends ADR-0011 supersession for the lead). On done: Fable judge, then `ajj stack` + `update-stale`. + +Known carry-forward from 0020: delete the dead view-switch code left under `#[allow(dead_code)]` — `workbench_view_switch_html`, `register_view_switch_handlers`, `attach_view_switch_handler`, `activate_workbench_view` (~60 lines). Also audit for additive-but-inert DOM surface introduced during componentization (`data-app-status`, `data-workspace-loaded`, `#workbench-global-controls`, `.workbench-view-interior` with no CSS) — adopt or remove. + +## Narrow out-of-crate exception + +This is the one stage allowed to touch files outside the UI crate, narrowly: regenerate `crates/brain-brew-cli/assets/workbench` via `devenv shell workbench-ui-embed`; and if repo docs/ADRs under `documentation/` describe the workbench frontend as Iced, update those descriptions factually. **No new ADR** — the lead owns the ADR record (ADR-0011 supersession); report what you found if an ADR change seems needed rather than writing one. + +## Acceptance Criteria + +- `rg -in "iced|set_inner_html" crates/brain-brew-workbench-ui/src/` returns nothing — except any deliberate, justified raw-HTML injection for trusted server-provided card-template previews (if Anki preview rendering legitimately needs Leptos `inner_html`, that is expected; report each site). +- No `thread_local!` in the crate. +- Full `devenv shell ci` green (fmt:check + workspace tests + clippy `-D warnings` + complete **13/13** e2e). +- Embedded assets regenerated and included in the payload. +- `tests/workspace_summary.rs` still unmodified and green. +- Report: final dependency list, deleted-line count, CSS rules removed, doc files touched. diff --git a/.frontloop/workbench-leptos-migration/epic.md b/.frontloop/workbench-leptos-migration/epic.md new file mode 100644 index 0000000..e8f7655 --- /dev/null +++ b/.frontloop/workbench-leptos-migration/epic.md @@ -0,0 +1,47 @@ +--- +title: workbench leptos migration +slug: workbench-leptos-migration +status: active +created_at: 2026-07-06 +completed_at: +--- + +## Goal + +Replace the Iced/canvas frontend in `crates/brain-brew-workbench-ui` with **Leptos** (CSR, Rust→WASM DOM), preserving behavioral parity as judged by the 13 thirtyfour E2E scenarios in `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs`, with **zero changes to the server, its `/api/*` endpoints, or the Trunk build/serve/embed pipeline**. + +This epic is the promotion of `default/ready/0130-migrate-workbench-ui-to-declarative-state-driven-architecture` per that task's own guidance ("if it stalls, split into a dedicated epic with the spike as its first task"). The Phase-0 spike is **done**: Iced-on-web renders its widget tree to a `` (wgpu/tiny-skia), emits no DOM, and cannot host Anki HTML/CSS card previews, `contenteditable` editing, or real `` media — which is why the current code bypasses Iced entirely with ~28 raw `set_inner_html`/`web_sys` operations. ADR-0011's actual value ("Rust-native type-safe state/update/view in the browser") is preserved by Leptos while gaining the DOM. **Framework decision: Leptos (final).** + +## Why the staging works (key finding) + +The Iced runtime layer is only ~100 lines: `update()` fires two initial fetches and forwards results to `publish_*`; every interactive DOM closure is self-contained `web_sys` that never dispatches back into Iced. So Stage 1 is a **runtime swap** (Iced out, Leptos bootstrap in, legacy DOM layer retained) that keeps all 13 E2E green immediately, and each later stage is a **strangler-pattern** port of one DOM region — the parity oracle gates every integration. Main is never left with a red suite. + +## Fixed points / hard no-go areas + +- Server + all `/api/*` endpoints (`crates/brain-brew-cli/src/commands/workbench.rs`). Frontend-only. The server already exposes every list/detail/pivot/apply endpoint the UI consumes — no server work. +- `crates/brain-brew-workbench-e2e/tests/workbench_smoke.rs` — the **13** scenarios are the acceptance oracle. No test edits without explicit, reported justification. +- `index.html` boot anchor (`#brainbrew-workbench-e2e`), `data-trunk` links, dev-assets (`target/workbench-ui`), release embed (`crates/brain-brew-cli/assets/workbench` via `include_dir!`), and the nix-pinned Trunk pipeline. +- localStorage staged-edit key schema; the DOM contract E2E relies on (row budgets, no nested view switcher, no fetch from inactive pivots, media `src`/dimensions/HTTP status). + +## Validation floor (every task) + +`devenv shell fmt` (then fmt:check clean) · `devenv shell test` · `devenv shell clippy` (`-D warnings`, excl. e2e crate) · `devenv shell workbench-ui-build` · `devenv shell e2e` (all **13/13** green). CLI package/binary is `brainbrew`. + +## Stages (strictly sequential: 0010 → 0020 → 0030 → 0040 → 0050 → 0060) + +- **0010** Runtime swap — remove Iced, boot the existing DOM layer from Leptos (de-risk milestone; delegate first) +- **0020** Leptos-owned panel skeleton — view switcher, view sections, probe, error surface +- **0030** Componentize notes view — nav, detail, contenteditable fields, card previews, media +- **0040** Componentize notes workflows — apply box/preview, new-language panel, multi-pane comparison +- **0050** Componentize card, source-string, metadata views +- **0060** Purge legacy remnants, trim deps, embed refresh, full CI + +Delivery: each stage is a self-contained agentleman Run (`workspace: true`), judged against its acceptance criteria (judge inspects actual E2E evidence), integrated into Main before the next is delegated. + +## Open risks for the lead + +1. **Leptos/Trunk/wasm-bindgen nix-pin compat** is the existential risk — Stage 0010 surfaces it first. If Leptos 0.8 drags `wasm-bindgen` past the pinned CLI, the fix is a `devenv.nix` pin bump (parent-owned, not delegated). +2. **contenteditable cursor-jump** threatens the typing-flow tests — Stage 0030 mandates the uncontrolled-element pattern. +3. **Stale-response guards** ported as explicit generation tokens (not Leptos `Resource` auto-cancel) — a test asserts absence of stale DOM. +4. Task statement originally said 14 scenarios; the file has **13**. +5. Untracked `.frontloop/default/` and repo-root `default/` dirs are unrelated; keep out of Workspace payloads. diff --git a/.github/workflows/integrity-check.yml b/.github/workflows/integrity-check.yml deleted file mode 100644 index 5cbe257..0000000 --- a/.github/workflows/integrity-check.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Python application - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - - name: Set up Python 3.7 - uses: actions/setup-python@v2 - with: - python-version: 3.7 - - - name: Install dependencies - run: | - python3 -m pip install --upgrade pipenv - pipenv install --dev - - - name: Run tests - run: | - pipenv run unit_tests - - - name: Build Yamale Recipe - run: | - pipenv run build_yamale - - - name: Check Yamale Recipe for changes - run: git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run 'python scripts/yamale_build.py'" && exit 1) - diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml new file mode 100644 index 0000000..1c50c05 --- /dev/null +++ b/.github/workflows/package-smoke.yml @@ -0,0 +1,21 @@ +name: Package Smoke + +on: + pull_request: + push: + +jobs: + cargo-install-smoke: + name: cargo install smoke + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - name: Check crates.io package metadata + run: scripts/check_cratesio_metadata.py + - name: Install packaged CLI without Nix + run: | + cargo install --path crates/brain-brew-cli --locked --root "$RUNNER_TEMP/brainbrew-install" + echo "$RUNNER_TEMP/brainbrew-install/bin" >> "$GITHUB_PATH" + - name: Smoke test installed CLI + run: scripts/release_smoke.sh brainbrew diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4befab8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,343 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.4/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v6 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v6 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v6 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + publish-homebrew-formula: + needs: + - plan + - host + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLAN: ${{ needs.plan.outputs.val }} + GITHUB_USER: "axo bot" + GITHUB_EMAIL: "admin+bot@axo.dev" + if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: true + repository: "jeprecated/homebrew-tap" + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # So we have access to the formula + - name: Fetch homebrew formulae + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: Formula/ + merge-multiple: true + # This is extra complex because you can make your Formula name not match your app name + # so we need to find releases with a *.rb file, and publish with that filename. + - name: Commit formula files + run: | + git config --global user.name "${GITHUB_USER}" + git config --global user.email "${GITHUB_EMAIL}" + + for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do + filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output) + name=$(echo "$filename" | sed "s/\.rb$//") + version=$(echo "$release" | jq .app_version --raw-output) + + export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH" + brew update + # We avoid reformatting user-provided data such as the app description and homepage. + brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true + + git add "Formula/${filename}" + git commit -m "${name} ${version}" + done + git push + + announce: + needs: + - plan + - host + - publish-homebrew-formula + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore index 1506eb0..35b2249 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,31 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class +# Rust +/target/ -# C extensions -*.so +# Devenv local overrides/state +/.devenv/ +devenv.local.nix +devenv.local.yaml -# 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/.sd/aggregates.yaml b/.sd/aggregates.yaml new file mode 100644 index 0000000..891479f --- /dev/null +++ b/.sd/aggregates.yaml @@ -0,0 +1,66 @@ +version: 1 + +tasks: + release/crates/all: + desc: Publish brain-brew-core, brain-brew-formats, then brainbrew to crates.io + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} all{{confirmArgs}} + + release/crates/core: + desc: Publish brain-brew-core to crates.io + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} core{{confirmArgs}} + + release/crates/formats: + desc: Publish brain-brew-formats to crates.io after brain-brew-core is indexed + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} formats{{confirmArgs}} + + release/crates/cli: + desc: Publish brainbrew to crates.io after brain-brew-formats is indexed + danger: true + defaultMode: dry-run + modes: + dry-run: + danger: false + values: + releaseMode: dry-run + confirmArgs: "" + publish: + values: + releaseMode: publish + confirmArgs: " --yes" + run: scripts/publish_crates.sh {{releaseMode}} cli{{confirmArgs}} diff --git a/.sd/tasks.yaml b/.sd/tasks.yaml new file mode 100644 index 0000000..4a00063 --- /dev/null +++ b/.sd/tasks.yaml @@ -0,0 +1,72 @@ +version: 1 + +project: + id: brain-brew + name: Brain Brew + aliases: + - brainbrew + workArea: Deck federation + visibility: public-code / CLI workspace + dir: . + +tasks: + check: + desc: Run the standard Brain Brew validation gate + run: devenv shell ci + + build/debug: + desc: Build the brainbrew CLI debug binary + run: devenv shell cargo build -p brainbrew + + build/release: + desc: Build the brainbrew CLI release binary + run: devenv shell cargo build -p brainbrew --release + + run/help: + desc: Run the brainbrew CLI help through cargo + run: devenv shell brainbrew --help + + run/ug/targets: + desc: List Ultimate Geography manifest targets + run: devenv shell brainbrew targets --manifest fixtures/ultimate-geography/brainbrew.yaml + + run/ug/compose/en-standard: + desc: Compose the Ultimate Geography en-standard target to target/sd/ug-en-standard.yaml + run: | + set -euo pipefail + mkdir -p target/sd + devenv shell brainbrew compose --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard --out target/sd/ug-en-standard.yaml + + run/ug/export/en-standard: + desc: Export the Ultimate Geography en-standard CrowdAnki deck to target/sd/crowdanki/en-standard + run: devenv shell brainbrew export crowdanki --manifest fixtures/ultimate-geography/brainbrew.yaml --target en-standard --out target/sd/crowdanki/en-standard + + run/ug/verify/all: + desc: Verify every Ultimate Geography manifest target + run: devenv shell brainbrew verify --manifest fixtures/ultimate-geography/brainbrew.yaml --all-targets + + workbench/ug: + desc: Build Workbench dev assets and serve the Ultimate Geography manifest; pass -- --no-open or -- --port as needed + run: | + set -euo pipefail + if [ "${1:-}" = "--" ]; then + shift + fi + devenv shell workbench-ui-build + media_args=() + default_media_root="../../external/ultimate-geography/media" + media_root="${BRAINBREW_UG_MEDIA_ROOT:-$default_media_root}" + if [ -d "$media_root" ]; then + media_args=(--media-root "$media_root") + else + echo "warning: UG media root not found at $media_root; set BRAINBREW_UG_MEDIA_ROOT to override" >&2 + fi + devenv shell brainbrew workbench serve --manifest fixtures/ultimate-geography/brainbrew.yaml --dev-assets target/workbench-ui "${media_args[@]}" "$@" + +passthrough: + cargo: + desc: Run cargo in the Brain Brew workspace + run: cargo "$@" + devenv: + desc: Run devenv in the Brain Brew workspace + run: devenv "$@" diff --git a/.sd/workspace.yaml b/.sd/workspace.yaml new file mode 100644 index 0000000..15fb746 --- /dev/null +++ b/.sd/workspace.yaml @@ -0,0 +1,8 @@ +version: 1 + +workspace: + id: brain-brew + name: Brain Brew + +discover: + - .sd/tasks.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fe58698 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# Agent Guidance + +## Project Aim + +Brain Brew is a Rust-based, local-first deck federation and round-trip engine for shared Anki-compatible decks. The first milestone is not a web app, SaaS, live sync tool, legacy Python recipe compatibility, or full Ultimate Geography clone. + +Read these before making design changes: + +- `CONTEXT.md` — domain glossary +- `documentation/docs/reference/project-scope.md` — current scope and architecture boundaries +- `documentation/docs/reference/decisions/README.md` — active ADR index + +Use the project skill `skills/federated-deck-extensions/SKILL.md` whenever creating, reviewing, or refactoring Federated Deck source, translation overlays, extension overlays, field fills, or UG-style variant targets. It captures the variable-first/shared-extension workflow and common mistakes to avoid. + +## Development Method + +Use TDD with Red-Green-Refactor: + +1. Add a failing test for the next behavior. +2. Implement the smallest change that passes. +3. Refactor with tests still green. + +Scaffolding can exist without tests, but domain behavior, format behavior, adapter behavior, and CLI behavior should enter through a failing test. + +## Crate Boundaries + +- `brain-brew-core`: pure domain only. No YAML, CrowdAnki, filesystem, terminal, or CLI dependencies. +- `brain-brew-formats`: reusable YAML/CrowdAnki codecs over core types. +- `brainbrew`: thin command-line package in `crates/brain-brew-cli`, filesystem access, prompts, and report rendering. + +## Commands + +Use Devenv: + +```bash +devenv shell fmt +devenv shell test +devenv shell clippy +devenv shell ci +``` + +Run `devenv test` before committing meaningful code changes. + +## Version Control + +This repo uses Jujutsu. Use `jj status`, `jj diff`, and `jj commit`; do not use direct `git` workflow commands. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..01959c0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## Unreleased + +- CrowdAnki import now fails closed on additional unsupported or ambiguous data, including `bafmt`/`bqfmt`/`did` fields and media stable-ID collisions, while preserving media-file parity. +- Canonical YAML emission is safer for block scalars, CRLF-sensitive content, and map keys, with validated/quoted keys and hard failures for un-emittable key text. +- Overlay field operations are fill-blank-only, and full `note:`/`note_type:` bodies now fail closed unless they are explicit `add` changes. +- Package verification detects drift in local path sources, and path locks are stored as portable paths relative to `brainbrew.lock`. +- Translation coverage now surfaces untranslated structured-message `format` glue strings, and stale-translation resolution removes shadowed stale records cleanly. +- `validate --json`, `explain --json`, `diff --json`, and `targets --json` now emit machine-parseable failure envelopes on stdout with non-zero exits, empty stderr, and structured `error.errors[]` details where available. + +## v1.0.0-alpha.1 + +Initial Rust-based Brain Brew preview release. + +- Adds the `brainbrew` CLI for Canonical Deck validation, formatting, composition, semantic diffing, and CrowdAnki import/export. +- Adds Federated Deck manifests, named targets, package-qualified composition, package locks, and CI-friendly `verify` checks. +- Adds media reference validation and release-oracle comparison support for parity reviews. +- Includes Ultimate Geography-style fixtures used to validate translations, variants, and Hardcore Geography extension overlays. +- Ships prebuilt release archives, shell and PowerShell installers, and a Homebrew formula via `cargo-dist`. +- Publishes `brain-brew-core`, `brain-brew-formats`, and `brainbrew` to crates.io so Rust users can install with `cargo install brainbrew --version 1.0.0-alpha.1 --locked`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..735ea0a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,269 @@ +# 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 + +**Deck Workbench**: +An ergonomic authoring and review interface for working with a Federated Deck workspace without treating the interface as canonical storage. +_Avoid_: web app source of truth, generated HTML report, translator-only app + +**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 + +**Source Language**: +The language of the base deck text used as the reference for translation work. +_Avoid_: hard-coded English assumption, target language + +**Target Language**: +A language produced from the source language by a Translation Overlay. +_Avoid_: separate translated deck, independent deck language + +**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 for faithful target-language text: direct source text, contextual source text, explicit no-change decisions for reviewed unchanged text, source variables, and adapter IDs, with non-blank source keys acting as implicit expected bases. +_Avoid_: CSV importer, global localization database, target adaptation storage + +**Target Adaptation**: +A path-scoped target-language value that intentionally diverges from, explains, or supplements the source while declaring the expected source value it adapts. +_Avoid_: faithful translation, extension field fill + +**Stale Translation**: +A translation review item that applies a prior target text to changed source text while warning that the translation needs review. +_Avoid_: stale key only, automatic silent migration + +**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 + +**Language Catalog**: +A named collection of source and target languages available in a Federated Deck, connecting target languages to their Translation Overlays and Build Targets. +_Avoid_: target naming convention, hard-coded language list + +**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 **Deck Workbench** helps people review or author a **Federated Deck** while preserving source files as canonical storage. +- 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 contain **Stale Translations** when source text changes invalidate existing target-language decisions. +- 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**. +- A **Language Catalog** names the **Source Language** and **Target Languages** available in a **Federated Deck**. +- A **Target Language** is produced by applying a **Translation Overlay** to source-language content. +- 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. +- "webview" and "Iced server" were used for an interactive editing surface; resolved: the product concept is a **Deck Workbench**, and it must not become a separate source of truth. +- "language" could mean either source text language or translated output language; resolved: use **Source Language** for the base text and **Target Language** for languages produced by **Translation Overlays**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5700b5f..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ - -### Install Brain Brew package - -https://pypi.org/project/Brain-Brew/ - -```shell -pipenv install brain-brew -``` - -### Run Local Version - -Fork/Clone this repo onto your computer, then in a different repository you wish to run Brain Brew you can point it to this version in a 2 ways: - -#### Install development folder for live updates - -Point your installation to this folder. Run the following (change the path to match yours): - -```shell -pipenv install -e ../brain-brew -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew", editable = true} -``` - -#### Install a locally built package - -Build Brain Brew using the `scripts/build.bash` script. This will generate dist and build folders. Install the generated wheel by running: - -``` -pip install ../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl -``` - -This should result in your Pipfile updating to: - -``` -[packages] -brain-brew = {file = "../brain-brew/dist/Brain_Brew-0.3.11-py3-none-any.whl"} -``` - -Change to match the wheel version number, which is set in `brain_brew/front_matter.py` if you wish to change it. - - diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5e57822 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3762 @@ +# 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 = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brain-brew-core" +version = "1.0.0-alpha.1" + +[[package]] +name = "brain-brew-formats" +version = "1.0.0-alpha.1" +dependencies = [ + "brain-brew-core", + "serde", + "serde_json", + "serde_yaml", + "sha2", +] + +[[package]] +name = "brain-brew-workbench-e2e" +version = "1.0.0-alpha.1" +dependencies = [ + "anyhow", + "tempfile", + "thirtyfour", + "tokio", +] + +[[package]] +name = "brain-brew-workbench-ui" +version = "1.0.0-alpha.1" +dependencies = [ + "console_error_panic_hook", + "gloo-net", + "leptos", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "brainbrew" +version = "1.0.0-alpha.1" +dependencies = [ + "axum", + "base64", + "brain-brew-core", + "brain-brew-formats", + "crossterm", + "flate2", + "include_dir", + "nix-nar", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tar", + "tempfile", + "tokio", + "tower-http", + "ureq", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[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", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml", + "winnow", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb" +dependencies = [ + "convert_case 0.11.0", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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 = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[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 = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[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 = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + +[[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 = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[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", + "zlib-rs", +] + +[[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 = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix 1.1.4", + "tokio", + "windows-sys 0.61.2", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[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 = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hydration_context" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96" +dependencies = [ + "futures", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[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 = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[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 = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "leptos" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "getrandom 0.4.2", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.18", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "or_poisoned", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.11.0", + "convert_case_extras", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[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 = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[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 = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[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 2.0.18", +] + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[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 = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reactive_graph" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382" +dependencies = [ + "guardian", + "indexmap", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0" +dependencies = [ + "convert_case 0.11.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[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 = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "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 0.12.1", + "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 = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[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 = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[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 = "server_fn" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812" +dependencies = [ + "base64", + "bytes", + "const-str", + "const_format", + "futures", + "gloo-net", + "http", + "js-sys", + "or_poisoned", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.18", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f" +dependencies = [ + "const_format", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn", +] + +[[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 = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringmatch" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aadc0801d92f0cdc26127c67c4b8766284f52a5ba22894f285e3101fa57d05d" +dependencies = [ + "regex", +] + +[[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 = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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 = "tachys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "next_tuple", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[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 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thirtyfour" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c23ea3ddea759604ee20091e361347531cbdd5e4575435dc87e55ad7bcae5775" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "const_format", + "dirs", + "flate2", + "fs4", + "futures-util", + "http", + "indexmap", + "pastey", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "stringmatch", + "tar", + "thirtyfour-macros", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "zip", +] + +[[package]] +name = "thirtyfour-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf0ffc3ba4368e99597bd6afd83f4ff6febad66d9ae541ab46e697d32285fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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 = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[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 = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm_split_helpers" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8" +dependencies = [ + "base16", + "quote", + "sha2", + "syn", +] + +[[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 = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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 = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[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 1.1.4", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[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 = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6cf0237 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +members = [ + "crates/brain-brew-core", + "crates/brain-brew-formats", + "crates/brain-brew-cli", + "crates/brain-brew-workbench-ui", + "crates/brain-brew-workbench-e2e", +] +resolver = "3" + +[workspace.package] +version = "1.0.0-alpha.1" +edition = "2024" +rust-version = "1.94" +license = "Unlicense" +authors = ["Brain Brew contributors"] +repository = "https://github.com/jeprecated/brain-brew" + +[workspace.dependencies] +brain-brew-core = { path = "crates/brain-brew-core", version = "=1.0.0-alpha.1" } +brain-brew-formats = { path = "crates/brain-brew-formats", version = "=1.0.0-alpha.1" } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4d17a07..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include brain_brew/schemas/recipe.yaml diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 3f71eac..0000000 --- a/Pipfile +++ /dev/null @@ -1,24 +0,0 @@ -[[source]] -name = "Brain Brew" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] -pytest = "==5.4.1" -twine = "*" -coverage = "==4.5.4" -typing-extensions = "==3.10.0.0" - -[packages] -"ruamel.yaml" = "==0.16.10" -yamale = "==3.0.8" - -[requires] -python_version = "3.7" - -[scripts] -build_yamale = "python scripts/yamale_build.py" -check_for_changes = ''' - git diff --quiet -- || (echo "::error file=yamale,line=0,col=0::You need to run `python scripts/yamale_build.py`" && exit 1) -''' -unit_tests = "py.test" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 5415fc1..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,645 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "0d4532926edfd30fdf44d37fea09d682ba1b3bab705b425f29745ec3cee0f7ac" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "Brain Brew", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "pyyaml": { - "hashes": [ - "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", - "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", - "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", - "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", - "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", - "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", - "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", - "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", - "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", - "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", - "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", - "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", - "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", - "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", - "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", - "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", - "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", - "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", - "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", - "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", - "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", - "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", - "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", - "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", - "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", - "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", - "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", - "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", - "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", - "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", - "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", - "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", - "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", - "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", - "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", - "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", - "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", - "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", - "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", - "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", - "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", - "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", - "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", - "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", - "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", - "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", - "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", - "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", - "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", - "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", - "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" - ], - "markers": "python_version >= '3.6'", - "version": "==6.0.1" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:0962fd7999e064c4865f96fb1e23079075f4a2a14849bcdc5cdba53a24f9759b", - "sha256:099c644a778bf72ffa00524f78dd0b6476bca94a1da344130f4bf3381ce5b954" - ], - "index": "Brain Brew", - "version": "==0.16.10" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d", - "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001", - "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462", - "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9", - "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe", - "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b", - "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b", - "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615", - "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62", - "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15", - "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b", - "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1", - "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9", - "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675", - "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899", - "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7", - "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7", - "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312", - "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa", - "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91", - "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b", - "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6", - "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3", - "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334", - "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5", - "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3", - "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe", - "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c", - "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed", - "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337", - "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880", - "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f", - "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d", - "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248", - "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d", - "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf", - "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512", - "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069", - "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb", - "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942", - "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d", - "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31", - "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92", - "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5", - "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28", - "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d", - "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1", - "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2", - "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875", - "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.8" - }, - "yamale": { - "hashes": [ - "sha256:1468f90f5019a82a77ff3f101bb001930765ef4b5e8d7fe658c1d2967e775f9a", - "sha256:9e9d6946d2f68926822d0df400dafb5e75b34bc7f482237393db29e697d5bbad" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.6'", - "version": "==3.0.8" - } - }, - "develop": { - "attrs": { - "hashes": [ - "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", - "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" - ], - "markers": "python_version >= '3.7'", - "version": "==24.2.0" - }, - "bleach": { - "hashes": [ - "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", - "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" - ], - "markers": "python_version >= '3.7'", - "version": "==6.0.0" - }, - "certifi": { - "hashes": [ - "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", - "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" - ], - "markers": "python_version >= '3.6'", - "version": "==2024.7.4" - }, - "cffi": { - "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "markers": "platform_python_implementation != 'PyPy'", - "version": "==1.15.1" - }, - "charset-normalizer": { - "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" - }, - "commonmark": { - "hashes": [ - "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60", - "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9" - ], - "version": "==0.9.1" - }, - "coverage": { - "hashes": [ - "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", - "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", - "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", - "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", - "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", - "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", - "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", - "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", - "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", - "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", - "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", - "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", - "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", - "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", - "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", - "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", - "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", - "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", - "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", - "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", - "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", - "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", - "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", - "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", - "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", - "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", - "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", - "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", - "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", - "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", - "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", - "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" - ], - "index": "Brain Brew", - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", - "version": "==4.5.4" - }, - "cryptography": { - "hashes": [ - "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709", - "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069", - "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2", - "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b", - "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e", - "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70", - "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778", - "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22", - "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895", - "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf", - "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431", - "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f", - "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947", - "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74", - "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc", - "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66", - "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66", - "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf", - "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f", - "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5", - "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e", - "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f", - "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55", - "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1", - "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47", - "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5", - "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0" - ], - "markers": "python_version >= '3.7'", - "version": "==43.0.0" - }, - "docutils": { - "hashes": [ - "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", - "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" - ], - "markers": "python_version >= '3.7'", - "version": "==0.20.1" - }, - "idna": { - "hashes": [ - "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", - "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" - ], - "markers": "python_version >= '3.6'", - "version": "==3.8" - }, - "importlib-metadata": { - "hashes": [ - "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", - "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5" - ], - "markers": "python_version < '3.8'", - "version": "==6.7.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", - "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a" - ], - "markers": "python_version < '3.9'", - "version": "==5.12.0" - }, - "jaraco.classes": { - "hashes": [ - "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", - "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" - ], - "markers": "python_version >= '3.7'", - "version": "==3.2.3" - }, - "jeepney": { - "hashes": [ - "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", - "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" - ], - "markers": "sys_platform == 'linux'", - "version": "==0.8.0" - }, - "keyring": { - "hashes": [ - "sha256:3d44a48fa9a254f6c72879d7c88604831ebdaac6ecb0b214308b02953502c510", - "sha256:bc402c5e501053098bcbd149c4ddbf8e36c6809e572c2d098d4961e88d4c270d" - ], - "markers": "python_version >= '3.7'", - "version": "==24.1.1" - }, - "more-itertools": { - "hashes": [ - "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", - "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" - ], - "markers": "python_version >= '3.7'", - "version": "==9.1.0" - }, - "packaging": { - "hashes": [ - "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", - "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" - ], - "markers": "python_version >= '3.7'", - "version": "==24.0" - }, - "pkginfo": { - "hashes": [ - "sha256:5df73835398d10db79f8eecd5cd86b1f6d29317589ea70796994d49399af6297", - "sha256:889a6da2ed7ffc58ab5b900d888ddce90bce912f2d2de1dc1c26f4cb9fe65097" - ], - "markers": "python_version >= '3.6'", - "version": "==1.10.0" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.11.0" - }, - "pycparser": { - "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" - ], - "version": "==2.21" - }, - "pygments": { - "hashes": [ - "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", - "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" - ], - "markers": "python_version >= '3.7'", - "version": "==2.17.2" - }, - "pytest": { - "hashes": [ - "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", - "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.5'", - "version": "==5.4.1" - }, - "readme-renderer": { - "hashes": [ - "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", - "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" - ], - "markers": "python_version >= '3.7'", - "version": "==37.3" - }, - "requests": { - "hashes": [ - "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", - "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" - ], - "markers": "python_version >= '3.7'", - "version": "==2.31.0" - }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, - "rfc3986": { - "hashes": [ - "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", - "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "rich": { - "hashes": [ - "sha256:3fba9dd15ebe048e2795a02ac19baee79dc12cc50b074ef70f2958cd651b59a9", - "sha256:ce5c714e984a2d185399e4e1dd1f8b2feacb7cecfc576f1522425643a36a57ea" - ], - "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", - "version": "==12.0.1" - }, - "secretstorage": { - "hashes": [ - "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", - "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" - ], - "markers": "sys_platform == 'linux'", - "version": "==3.3.3" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "twine": { - "hashes": [ - "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", - "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" - ], - "index": "Brain Brew", - "markers": "python_version >= '3.7'", - "version": "==4.0.2" - }, - "typing-extensions": { - "hashes": [ - "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497", - "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342", - "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84" - ], - "index": "Brain Brew", - "version": "==3.10.0.0" - }, - "urllib3": { - "hashes": [ - "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", - "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.7" - }, - "wcwidth": { - "hashes": [ - "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", - "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" - ], - "version": "==0.2.13" - }, - "webencodings": { - "hashes": [ - "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", - "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" - ], - "version": "==0.5.1" - }, - "zipp": { - "hashes": [ - "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", - "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" - ], - "markers": "python_version >= '3.7'", - "version": "==3.15.0" - } - } -} diff --git a/README.md b/README.md index 16b7f70..b55acd3 100644 --- a/README.md +++ b/README.md @@ -1,254 +1,109 @@ -# 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 + +A Federated Deck workspace contains a base Canonical Deck, overlays, and a `brainbrew.yaml` manifest declaring reproducible build targets. + +Common commands: ```bash -brainbrew init [Your CrowdAnki Export Folder] +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 ``` -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 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. -See [the starter repo][BrainBrewStarter] for a step-by-step guide for all of this. +## Install the CLI -# Usage +For normal deck users and contributors, install the released `brainbrew` CLI from crates.io or a GitHub Release. You do not need Nix to edit or verify a Federated Deck workspace, and downstream projects such as Ultimate Geography can link to the release version they recommend. -Brain Brew runs from the command line and takes a *Recipe.yaml* file to run. +Rust users can install the published crate: ```bash -brainbrew run source_to_anki.yaml +cargo install brainbrew --version 1.0.0-alpha.1 --locked +brainbrew --version ``` -Full usage help text: -```bash -Brain Brew vx.y.z -usage: brainbrew [-h] {run,init} ... - -Manage Flashcards by transforming them to various types. +The easiest no-Rust install path is the GitHub Release installer: -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. - -optional arguments: - -h, --help show this help message and exit +```bash +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/jeprecated/brain-brew/releases/download/v1.0.0-alpha.1/brainbrew-installer.sh \ + | sh +brainbrew --version ``` +Homebrew users can install from the tap once the preview release is published: -## Recipes - -These are the instructions for how Brain Brew will ~~build~~ *brew* your data into another format. - -What's YAML? See the current spec [here](http://www.yaml.org/spec/1.2/spec.html). - -Run a recipe with `--verify` or `-v` to confirm your recipe is valid, without actually running it. -A dry run of sorts. - -### Tasks - -A recipe is made of many individual tasks, which do specific functions. -Full detailed list coming soon™️, but see the [Yamale recipe schema](https://github.com/jeprecated/brain-brew/blob/master/brain_brew/schemas/recipe.yaml) -(local file: `brain_brew/schemas/recipe.yaml`) in the meantime :+1: - - - - -[//]: <> (Yamale) - -# The Why - -Brain Brew was made in an effort to solve some of the following issues with current collaboration of Anki Flashcards: - -#### Sharing Personal Information or Copyrighted Material - -Have some personal notes on your cards? Used some images randomly taken from the internet? -That usually means you cannot share your deck entirely, without having to go to the effort of removing the offending material and/or managing two separate copies. - -#### Having to Pick Between Source Control or Anki Editing - -Putting your cards into a source control system brings a lot of benefits. -You can see any changes that occur, go back in time should an mistake be discovered, and collaborate with others. - -However the current tools for managing Anki cards in source control -(such as [Anki-DM](https://github.com/OnkelTem/anki-dm), [GenAnki](https://github.com/kerrickstaley/genanki), -and [Remote Decks](https://github.com/c-okelly/anki-remote-decks)) are only one way. -You generate cards from a csv into a file that can *only be imported* into Anki. -There is no way to export them back, meaning a user must manually copy their changes over, or simple not edit their cards anywhere other than in source control. - -This robs the user of two important work flows: -1. Editing/fixing cards in Anki as you review them (on desktop or mobile) -1. The plethora of Anki add-ons that already exist that are amazingly useful. E.g: Image Occlusion, Morphman, AwesomeTTS. - -A user should not have to pick between these fantastic work flows and the usage of source control to structure, manage, and share their cards. - -#### Lack of Formatting Choice - -Csvs are great for editing data, but can only go so far by themselves. Having all the data inside one csv leaves a lot to be desired and can result in eventual problems. -When one gets as many columns as *this* (from [Ultimate Geography](https://github.com/axelboc/anki-ultimate-geography/)) then it becomes a nightmare to manage: - -|guid|Country|Country:de|Country:es|Country:fr|Country:nb|"Country info"|"Country info:de"|"Country info:es"|"Country info:fr"|"Country info:nb"|Capital|Capital:de|Capital:es|Capital:fr|Capital:nb|"Capital info"|"Capital info:de"|"Capital info:es"|"Capital info:fr"|"Capital info:nb"|"Capital hint"|"Capital hint:de"|"Capital hint:es"|"Capital hint:fr"|"Capital hint:nb"|Flag|"Flag similarity"|"Flag similarity:de"|"Flag similarity:es"|"Flag similarity:fr"|"Flag similarity:nb"|Map|tags| -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -|crr.AfnVRi|England|England|Inglaterra|Angleterre|England|"Constituent country of the United Kingdom."|"Landesteil des Vereinigten Königreichs."|"Nación constitutiva del Reino Unido."|"Nation constitutive du Royaume-Uni."|"Land som utgjør en del av Storbritannia."|London|London|Londres|Londres|London| | | | | |"Not a sovereign country"|"Kein souveräner Staat"|"No es un país soberano"|"Pas une nation souveraine"|"Ikke selvstendig land"|""| | | | | |""|UG::Europe| -"h"|"Ireland (orange and green flipped, wider)"|"Irland (Orange und Grün vertauscht, breiter)"|"Irlanda (naranja y verde intercambiados, más ancha)"|"Irlande (orange et vert inversés, plus large)"|"Ireland (byttet plass på oransje og grønt, bredere)"|""|"UG::Africa UG::Sovereign_State UG::West_Africa" - -Then there's having too many rows in one csv for it to be properly managed. - - -# Features of Brain Brew -### Multi-directional Card Syncing -Make changes in your source file and sync those into your Anki collection. - -Make changes inside Anki and pull those back into the source. - -Any user of your shared deck can make a change inside Anki and at some later point export their deck (or just part of it) using CrowdAnki. -Then the source file can be updated with their changes and a new CrowdAnki Export for all users to import can be generated with one run of Brain Brew. - -### Modular Configuration Files -Yaml config files are what drive the conversion of Brain Brew, allowing users to easily change the functionality as they wish. - - - -```Yaml -- generate_guids_in_csv: - source: src/data/words.csv - columns: [ guid ] - -- build_parts: - - note_model_from_yaml_part: - part_id: LL Word - file: src/note_models/LL Word.yaml - - - headers_from_yaml_part: - part_id: default header - file: src/headers/default.yaml - override: # Optional - deck_description_html_file: src/headers/desc.html - - - media_group_from_folder: - part_id: all_media - source: src/media - recursive: true # Optional - - - notes_from_csvs: - part_id: english-to-danish - - note_model_mappings: - - note_models: - - LL Word - columns_to_fields: # Optional - guid: guid - tags: tags - - english: English - danish: Word - picture: Picture - danish audio: Pronunciation (Recording and/or IPA) - - file_mappings: - - file: src/data/words.csv - note_model: LL Word - sort_by_columns: [english] # Optional - reverse_sort: no # Optional +```bash +brew install jeprecated/tap/brainbrew ``` -### Personal Fields -Deck managers can set specific fields to be "Personal", meaning they will not overwrite an existing value on import. - -Working version currently exists, but full PR coming soon to CrowdAnki! - -### Extensibility and Open Source -Free for all to use, modify, or sell this product. - -Further source types are relatively easy to add due to the flexible nature of the backend -Instead of creating a Csv <-> CrowdAnki converter Brain Brew first goes through a middle layer called "Deck Parts". -These consist of Notes, Headers, Note Models, and Media files. - -Each new source type to be added to Brain Brew (such as Markdown) need only be able to convert from Deck Parts <-> itself, and suddenly it can convert to and from all existing source types! +To test the exact GitHub tag instead of the crates.io package, install directly from the pinned release tag: -### Smart Csvs - -Csvs only update the rows which have changed. -Meaning a user can import *a subset* of their cards which have changed and still update the source file without deleting the cards they did not include. - -##### Csv Splitting / Derivatives - -Split data into multiple csvs so that your data is neatly organised however you like. - -The two following csv files contain information about England, but split into different csv files: - -###### data-main.csv - -| guid | country | flag | map | tags | -| ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | - -###### data-capital.csv -| country | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | -| England | London | London | Londres | Londres | London | - -Brain Brew can be told that `data-capital` is a derivative of `data-main` in the build config file as such: - -```yaml -- file: src/data/data-main.csv # <---- Main - note_model: Ultimate Geography - derivatives: - - file: src/data/data-country.csv - - file: src/data/data-country-info.csv - - file: src/data/data-capital.csv # <---- Capital - - file: src/data/data-capital-info.csv - - file: src/data/data-capital-hint.csv - # note_model: different_note_model - # derivatives: - # - file: derivative-of-a-derivative.csv - # derivatives: - # - file: infinite-nesting.csv - - file: src/data/data-flag-similarity.csv +```bash +cargo install --git https://github.com/jeprecated/brain-brew --tag v1.0.0-alpha.1 brainbrew --locked ``` -When run Brain Brew will perform the following steps for each derivative: -1. Finds which columns in the derivative csv match the main (only `country` in this case) -1. Go through each row in the derivative and find the row with matching values in the main file -1. Add in the extra columns (`capital` in each language) to that matching row in the main file +Nix remains available as an optional reproducible build/install path for contributors and CI: -###### Resulting csv data -| guid | country | flag | map | tags | capital | capital de | capital es | capital fr | capital nb | -| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | -| "e+/O]%*qfk | England | | | UG::Europe | London | London | Londres | Londres | London | +```bash +nix run . -- --help +nix build .#brainbrew +``` -##### Note: +See [`documentation/docs/getting-started/install.md`](documentation/docs/getting-started/install.md) for all install options and an edit/export loop for trying changes against a Federated Deck workspace. -1. **Derivatives can also have derivatives**. +## Workspace -1. **Csv splitting works in both directions**, to and from csv. +```text +crates/ + brain-brew-core/ Pure domain model, validation, composition, semantic diffing + brain-brew-formats/ Reusable YAML and CrowdAnki codecs + brain-brew-cli/ Thin `brainbrew` command-line package +``` -1. **Derivatives can be given a Note Model**, which overrides their parent's note model for all the matched rows. +## Development -See the [Brain Brew Starter Project][BrainBrewStarter] for an example of Csv Derivatives working. +This project uses Devenv. With direnv, `.envrc` loads the environment automatically; otherwise run commands through `devenv shell`: +```bash +devenv shell fmt +devenv shell test +devenv shell clippy +devenv shell ci +devenv test +``` +Useful docs: -[BrainBrewStarter]: https://github.com/jeprecated/brain-brew-starter +- Agent guidance: [`AGENTS.md`](AGENTS.md) +- Documentation site source: [`documentation/`](documentation/) +- Start here: [`documentation/docs/intro.md`](documentation/docs/intro.md) +- Domain glossary: [`documentation/docs/reference/glossary.md`](documentation/docs/reference/glossary.md) +- Project scope: [`documentation/docs/reference/project-scope.md`](documentation/docs/reference/project-scope.md) +- Active ADRs: [`documentation/docs/reference/decisions/`](documentation/docs/reference/decisions/) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 1dbfcb7..0000000 --- a/TODO.md +++ /dev/null @@ -1,9 +0,0 @@ -# Todos - -- [ ] Better error messages - - No stack trace, unless they add -v -- [ ] Save note model to yaml - - Respect the current positions of the child files - - Be able to do it for multiple note models at a time, while checking their shared components are the same -- [ ] Save headers build task - - Remove the save_to_file diff --git a/brain_brew/__init__.py b/brain_brew/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/__init__.py b/brain_brew/build_tasks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/__init__.py b/brain_brew/build_tasks/crowd_anki/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py b/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py deleted file mode 100644 index b9d99e2..0000000 --- a/brain_brew/build_tasks/crowd_anki/crowd_anki_generate.py +++ /dev/null @@ -1,81 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper - - -@dataclass -class CrowdAnkiGenerate(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - folder: str() - headers: str() - notes: include('{NotesToCrowdAnki.task_name()}') - note_models: include('{NoteModelsToCrowdAnki.task_name()}') - media: include('{MediaGroupToCrowdAnki.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesToCrowdAnki, NoteModelsToCrowdAnki, MediaGroupToCrowdAnki} - - @dataclass - class Representation(RepresentationBase): - folder: str - notes: dict - note_models: dict - headers: dict - media: Optional[dict] = field(default_factory=lambda: dict()) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowd_anki_export=CrowdAnkiExport.create_or_get(rep.folder), - notes_transform=NotesToCrowdAnki.from_repr(rep.notes), - note_model_transform=NoteModelsToCrowdAnki.from_repr(rep.note_models), - headers_transform=HeadersToCrowdAnki.from_repr(rep.headers), - media_transform=MediaGroupToCrowdAnki.from_repr(rep.media) if rep.media else None - ) - - rep: Representation - crowd_anki_export: CrowdAnkiExport - notes_transform: NotesToCrowdAnki - note_model_transform: NoteModelsToCrowdAnki - headers_transform: HeadersToCrowdAnki - media_transform: Optional[MediaGroupToCrowdAnki] - - def execute(self): - headers = self.headers_transform.execute() - ca_wrapper = CrowdAnkiJsonWrapper(headers) - - note_models: List[dict] = self.note_model_transform.execute() - - nm_name_to_id: dict = {model.part_id: model.part.id for model in self.note_model_transform.note_models} - notes = self.notes_transform.execute(nm_name_to_id) - - media_files: Set[MediaFile] = set() - if self.media_transform: - media_files = self.media_transform.execute(self.crowd_anki_export.media_loc) - - ca_wrapper.media_files = sorted([m.filename for m in media_files]) - ca_wrapper.name = self.headers_transform.headers.name - ca_wrapper.note_models = note_models - ca_wrapper.notes = notes - - # Set to CrowdAnkiExport - self.crowd_anki_export.write_to_files(ca_wrapper.data) diff --git a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py b/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py deleted file mode 100644 index 3f36ba7..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_from_crowdanki.py +++ /dev/null @@ -1,69 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES, \ - CA_CHILDREN, CA_TYPE -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.headers import Headers - -headers_skip_keys = [CA_NOTE_MODELS, CA_NOTES, CA_MEDIA_FILES] -headers_default_values = { - CA_TYPE: "Deck", - CA_CHILDREN: [], -} - - -@dataclass -class HeadersFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'headers_from_crowd_anki' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - headers = Headers(self.crowd_anki_to_headers(ca_wrapper.data)) - - return PartHolder.override_or_create(self.part_id, self.save_to_file, headers) - - @staticmethod - def crowd_anki_to_headers(ca_data: dict): - return {key: value for key, value in ca_data.items() - if key not in headers_skip_keys and key not in headers_default_values.keys()} diff --git a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py deleted file mode 100644 index 9a85a7e..0000000 --- a/brain_brew/build_tasks/crowd_anki/headers_to_crowd_anki.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import headers_default_values -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersToCrowdAnki: - @dataclass - class Representation(RepresentationBase): - part_id: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support single string being passed in - - return cls( - rep=rep, - headers=PartHolder.from_file_manager(rep.part_id).part - ) - - rep: Representation - headers: Headers - - def execute(self) -> dict: - headers = self.headers_to_crowd_anki(self.headers.data_without_name) - - return headers - - @staticmethod - def headers_to_crowd_anki(headers_data: dict): - return {**headers_default_values, **headers_data} - diff --git a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py deleted file mode 100644 index 29cf421..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_from_crowd_anki.py +++ /dev/null @@ -1,39 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromCrowdAnki(MediaGroupFromFolder): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_crowd_anki" - - @classmethod - def from_repr(cls, data: Union[MediaGroupFromFolder.Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - cae: CrowdAnkiExport = CrowdAnkiExport.create_or_get(rep.source) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(cae.media_loc, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - ) - ) - - rep: MediaGroupFromFolder.Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py deleted file mode 100644 index 705d3a0..0000000 --- a/brain_brew/build_tasks/crowd_anki/media_group_to_crowd_anki.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Union, List, Set - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class MediaGroupToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'media_group_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)) - ) - - rep: Representation - parts: List[MediaGroup] - - def execute(self, ca_media_folder: str) -> Set[MediaFile]: - return save_media_groups_to_location(self.parts, ca_media_folder, True, False) diff --git a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py deleted file mode 100644 index 583ad5f..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_model_single_from_crowd_anki.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelSingleFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - model_name: str - save_to_file: Optional[str] - - def execute(self): - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - if self.model_name not in note_models_dict: - raise ReferenceError(f"Missing Note Model '{self.model_name}' in CrowdAnki file") - - part = NoteModel.from_crowdanki(note_models_dict[self.model_name]) - return PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py deleted file mode 100644 index 17e2950..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_all_from_crowd_anki.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsAllFromCrowdAnki(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_all_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: str() - ''' - - @dataclass - class Representation(RepresentationBase): - source: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source) - ) - - rep: Representation - ca_export: CrowdAnkiExport - - def execute(self) -> List[PartHolder[NoteModel]]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - - note_models_dict = {model.get('name'): model for model in ca_wrapper.note_models} - - parts = [] - for name, model in note_models_dict.items(): - parts.append(PartHolder.override_or_create(name, None, NoteModel.from_crowdanki(model))) - - logging.info(f"Found {len(parts)} note model{'s' if len(parts) > 1 else ''} in CrowdAnki Export: '" - + "', '".join(note_models_dict.keys()) + "'") - - return parts diff --git a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py deleted file mode 100644 index a08e613..0000000 --- a/brain_brew/build_tasks/crowd_anki/note_models_to_crowd_anki.py +++ /dev/null @@ -1,91 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsToCrowdAnki(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(include('{cls.NoteModelListItem.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {cls.NoteModelListItem} - - @dataclass - class NoteModelListItem(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_models_to_crowd_anki_item' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - # TODO: fields: Optional[List[str]] - # TODO: templates: Optional[List[str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, str]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(part_id=data) # Support string - - return cls( - rep=rep, - part_to_read=rep.part_id - ) - - def get_note_model(self) -> PartHolder[NoteModel]: - self.part = PartHolder.from_file_manager(self.part_to_read) - return self.part # Todo: add filters in here - - rep: Representation - part: PartHolder[NoteModel] = field(init=False) - part_to_read: str - - @dataclass - class Representation(RepresentationBase): - parts: List[Union[dict, str]] - - @classmethod - def from_repr(cls, data: Union[Representation, dict, List[str]]): - rep: cls.Representation - if isinstance(data, cls.Representation): - rep = data - elif isinstance(data, dict): - rep = cls.Representation.from_dict(data) - else: - rep = cls.Representation(parts=data) # Support list of Note Models - - note_model_items = list(map(cls.NoteModelListItem.from_repr, rep.parts)) - return cls( - rep=rep, - note_models=[nm.get_note_model() for nm in note_model_items] - ) - - rep: Representation - note_models: List[PartHolder[NoteModel]] - - def execute(self) -> List[dict]: - return [model.part.encode_as_crowdanki() for model in self.note_models] diff --git a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py deleted file mode 100644 index d38fc27..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_from_crowd_anki.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Union, Optional, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.json.crowd_anki_export import CrowdAnkiExport -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper, CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note - - -@dataclass -class NotesFromCrowdAnki(SharedBaseNotes, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - ca_export=CrowdAnkiExport.create_or_get(rep.source), - part_id=rep.part_id, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - ca_export: CrowdAnkiExport - sort_order: Optional[List[str]] - reverse_sort: Optional[bool] - save_to_file: Optional[str] - - def execute(self) -> PartHolder[Notes]: - ca_wrapper: CrowdAnkiJsonWrapper = self.ca_export.json_data - if ca_wrapper.children: - logging.warning("Child Decks / Sub-decks are not currently supported.") - - ca_models = self.ca_export.note_models - ca_notes = ca_wrapper.notes - - nm_id_to_name: dict = {model.id: model.name for model in ca_models} - note_list = [self.ca_note_to_note(note, nm_id_to_name) for note in ca_notes] - - notes = Notes.from_list_of_notes(note_list) # TODO: pass in sort method - return PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def ca_note_to_note(note: dict, nm_id_to_name: dict) -> Note: - wrapper = CrowdAnkiNoteWrapper(note) - - return Note( - note_model=nm_id_to_name[wrapper.note_model], - tags=wrapper.tags, - guid=wrapper.guid, - fields=wrapper.fields, - flags=wrapper.flags - ) diff --git a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py b/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py deleted file mode 100644 index 3a249ce..0000000 --- a/brain_brew/build_tasks/crowd_anki/notes_to_crowd_anki.py +++ /dev/null @@ -1,96 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.build_tasks.crowd_anki.shared_base_notes import SharedBaseNotes -from brain_brew.build_tasks.overrides.notes_override import NotesOverride -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiNoteWrapper -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.utils import blank_str_if_none - - -@dataclass -class NotesToCrowdAnki(YamlRepr, SharedBaseNotes): - @classmethod - def task_name(cls) -> str: - return r'notes_to_crowd_anki' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('{NotesOverride.task_name()}', required=False) - case_insensitive_sort: bool(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NotesOverride} - - @dataclass - class Representation(RepresentationBase): - part_id: str - additional_items_to_add: Optional[dict] = field(default_factory=lambda: None) - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[dict] = field(default_factory=lambda: None) - case_insensitive_sort: Optional[bool] = field(default_factory=lambda: None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.part_id).part, - sort_order=SharedBaseNotes._get_sort_order(rep.sort_order), - reverse_sort=SharedBaseNotes._get_reverse_sort(rep.reverse_sort), - additional_items_to_add=rep.additional_items_to_add or {}, - override=NotesOverride.from_repr(rep.override) if rep.override else None, - case_insensitive_sort=rep.case_insensitive_sort or True - ) - - rep: Representation - notes: Notes - additional_items_to_add: dict - sort_order: Optional[List[str]] = field(default_factory=lambda: None) - reverse_sort: Optional[bool] = field(default_factory=lambda: None) - override: Optional[NotesOverride] = field(default_factory=lambda: None) - case_insensitive_sort: bool = field(default=True) - - def execute(self, nm_name_to_id: dict) -> List[dict]: - - notes = self.notes.get_sorted_notes_copy( - sort_by_keys=self.sort_order, - reverse_sort=self.reverse_sort, - case_insensitive_sort=self.case_insensitive_sort - ) - - if self.override: - notes = [self.override.override(note) for note in notes] - - note_dicts = [self.note_to_ca_note(note, nm_name_to_id, self.additional_items_to_add) for note in notes] - - return note_dicts - - @staticmethod - def note_to_ca_note(note: Note, nm_name_to_id: dict, additional_items_to_add: dict) -> dict: - wrapper = CrowdAnkiNoteWrapper({ - "__type__": "Note", - "data": "" - }) - - for key, value in additional_items_to_add.items(): - wrapper.data[key] = blank_str_if_none(value) - - wrapper.fields = note.fields - wrapper.flags = note.flags - wrapper.guid = note.guid - wrapper.note_model = nm_name_to_id[note.note_model] - wrapper.tags = note.tags - - return wrapper.data diff --git a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py b/brain_brew/build_tasks/crowd_anki/shared_base_notes.py deleted file mode 100644 index 9ad016f..0000000 --- a/brain_brew/build_tasks/crowd_anki/shared_base_notes.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union, List - - -@dataclass -class SharedBaseNotes: - @staticmethod - def _get_sort_order(sort_order: Optional[Union[str, List[str]]]): - if isinstance(sort_order, list): - return sort_order - elif isinstance(sort_order, str): - return [sort_order] - return [] - - @staticmethod - def _get_reverse_sort(reverse_sort: Optional[bool]): - return reverse_sort or False - - # sort_order: Optional[List[str]] - # reverse_sort: Optional[bool] diff --git a/brain_brew/build_tasks/csvs/__init__.py b/brain_brew/build_tasks/csvs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/csvs/csvs_generate.py b/brain_brew/build_tasks/csvs/csvs_generate.py deleted file mode 100644 index 3cdfba1..0000000 --- a/brain_brew/build_tasks/csvs/csvs_generate.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import dataclass -import logging -from typing import List, Dict, Union - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Notes, Note -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import join_tags - - -@dataclass -class CsvsGenerate(SharedBaseCsvs, TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'generate_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_csvs?' - - @classmethod - def yamale_schema(cls) -> str: # TODO: Use NotesOverride here, just as in NotesToCrowdAnki - return f'''\ - notes: str() - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - notes: str - - def encode(self): - return { - "notes": self.notes, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - notes=PartHolder.from_file_manager(rep.notes), - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - notes: PartHolder[Notes] # TODO: Accept Multiple Note Parts - - def execute(self): - self.verify_contents() - - notes: List[Note] = self.notes.part.get_sorted_notes_copy( - sort_by_keys=[], - reverse_sort=False, - case_insensitive_sort=True - ) - self.verify_notes_match_note_model_mappings(notes) - - if not self.file_mappings[0].csv_file.column_headers: - logging.warning("Empty top level csv found. Populating headers automatically.") - model_name = self.file_mappings[0].note_model - self.file_mappings[0].csv_file.set_data_from_superset({}, column_header_override=list(f.value for f in self.note_model_mappings[model_name].columns_manually_mapped)) - - for fm in self.file_mappings: - csv_data: List[dict] = [self.note_to_csv_row(note, self.note_model_mappings) for note in notes - if note.note_model in fm.get_used_note_model_names()] - rows_by_guid = {row["guid"]: row for row in csv_data} - - fm.compile_data() - fm.set_relevant_data(rows_by_guid) - fm.write_file_on_close() - - def verify_notes_match_note_model_mappings(self, notes: List[Note]): - note_models_used = {note.note_model for note in notes} - errors = [TypeError(f"Unknown note model type '{model}' in deck part '{self.notes.part_id}'. " - f"Add mapping for that model.") - for model in note_models_used if model not in self.note_model_mappings.keys()] - - if errors: - raise Exception(errors) - - @staticmethod - def note_to_csv_row(note: Note, note_model_mappings: Dict[str, NoteModelMapping]) -> dict: - nm_name = note.note_model - row = note_model_mappings[nm_name].note_models[nm_name].part.zip_field_to_data(note.fields) - row["guid"] = note.guid - row["tags"] = join_tags(note.tags) - # TODO: Flags? - - return note_model_mappings[nm_name].note_fields_map_to_csv_row(row) diff --git a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py b/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py deleted file mode 100644 index 493a3e1..0000000 --- a/brain_brew/build_tasks/csvs/generate_guids_in_csvs.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.utils import single_item_to_list, generate_anki_guid - - -@dataclass -class GenerateGuidsInCsvs(TopLevelBuildTask): - execute_immediately = True - - @classmethod - def task_name(cls) -> str: - return r'generate_guids_in_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'generate_guids_in_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - source: Union[str, List[str]] - columns: Union[str, List[str]] - delimiter: Optional[str] = field(default=None) - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and all(CsvFile.delimiter_matches_file_type(value, f) for f in single_item_to_list(self.source)): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv_files = [CsvFile.create_or_get(csv) for csv in single_item_to_list(rep.source)] - for c in csv_files: - c.set_delimiter(rep.delimiter) - c.read_file() - return cls( - rep=rep, - sources=csv_files, - columns=rep.columns - ) - - rep: Representation - sources: List[CsvFile] - columns: List[str] - - def execute(self): - logging.info("Attempting to generate Guids") - - errors = [] - - # Make sure the columns exist on all - for source in self.sources: - missing = [c for c in self.columns if c not in source.column_headers] - if any(missing): - errors.append(f"Csv '{source.file_location}' does not contain all the specified columns: {missing}") - - if errors: - raise KeyError(errors) - - for source in self.sources: - guids_generated = 0 - data = source.get_data() - for row in data: - for column_name in row.keys(): - if column_name in self.columns and not row[column_name]: - row[column_name] = generate_anki_guid() - guids_generated += 1 - if guids_generated > 0: - logging.info(f"Generated {guids_generated} guids in csv '{source.file_location}'") - source.set_data(data) - source.write_file() - - logging.info("Generate guids complete") diff --git a/brain_brew/build_tasks/csvs/notes_from_csvs.py b/brain_brew/build_tasks/csvs/notes_from_csvs.py deleted file mode 100644 index bd2beb4..0000000 --- a/brain_brew/build_tasks/csvs/notes_from_csvs.py +++ /dev/null @@ -1,92 +0,0 @@ -from dataclasses import dataclass, field -from typing import Dict, List, Union, Optional - -from brain_brew.build_tasks.csvs.shared_base_csvs import SharedBaseCsvs -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.yaml.notes import Note, Notes -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import split_tags - - -@dataclass -class NotesFromCsvs(SharedBaseCsvs, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'notes_from_csvs' - - @classmethod - def task_regex(cls) -> str: - return r'notes_from_csvs?' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('{NoteModelMapping.task_name()}')) - file_mappings: list(include('{FileMapping.task_name()}')) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {NoteModelMapping, FileMapping} - - @dataclass - class Representation(SharedBaseCsvs.Representation): - part_id: str - save_to_file: Optional[str] = field(default=None) - - def encode(self): - return { - "part_id": self.part_id, - "save_to_file": self.save_to_file, - "file_mappings": [fm.encode() for fm in self.file_mappings], - "note_model_mappings": [nmm.encode() for nmm in self.note_model_mappings] - } - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - save_to_file=rep.save_to_file, - file_mappings=rep.get_file_mappings(), - note_model_mappings={k: v for nm in rep.note_model_mappings for k, v in cls.map_nmm(nm).items()} - ) - - rep: Representation - part_id: str - save_to_file: Optional[str] - - def execute(self): - self.verify_contents() - - csv_data_by_guid: Dict[str, dict] = {} - for csv_map in self.file_mappings: - csv_map.compile_data() - csv_data_by_guid = {**csv_data_by_guid, **csv_map.compiled_data} - csv_map.write_file_on_close() - csv_rows: List[dict] = list(csv_data_by_guid.values()) - - notes_part: List[Note] = [self.csv_row_to_note(row, self.note_model_mappings) for row in csv_rows] - - notes = Notes.from_list_of_notes(notes_part) - PartHolder.override_or_create(self.part_id, self.save_to_file, notes) - - @staticmethod - def csv_row_to_note(row: dict, note_model_mappings: Dict[str, NoteModelMapping]) -> Note: - note_model_name = row["note_model"] # TODO: Use object - row_nm: NoteModelMapping = note_model_mappings[note_model_name] - - filtered_fields = row_nm.csv_row_map_to_note_fields(row) - - guid = filtered_fields.pop("guid") - tags = split_tags(filtered_fields.pop("tags")) - flags = filtered_fields.pop("flags") if "flags" in filtered_fields else 0 - - fields = row_nm.field_values_in_note_model_order(note_model_name, filtered_fields) - - return Note(guid=guid, tags=tags, note_model=note_model_name, fields=fields, flags=flags) diff --git a/brain_brew/build_tasks/csvs/shared_base_csvs.py b/brain_brew/build_tasks/csvs/shared_base_csvs.py deleted file mode 100644 index 916b61d..0000000 --- a/brain_brew/build_tasks/csvs/shared_base_csvs.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import List, Dict - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping - - -@dataclass -class SharedBaseCsvs: - @dataclass(init=False) - class Representation(RepresentationBase): - file_mappings: List[FileMapping.Representation] - note_model_mappings: List[NoteModelMapping.Representation] - - def __init__(self, file_mappings, note_model_mappings): - self.file_mappings = list(map(FileMapping.Representation.from_dict, file_mappings)) - self.note_model_mappings = list(map(NoteModelMapping.Representation.from_dict, note_model_mappings)) - - def get_file_mappings(self) -> List[FileMapping]: - return list(map(FileMapping.from_repr, self.file_mappings)) - - file_mappings: List[FileMapping] - note_model_mappings: Dict[str, NoteModelMapping] - - @classmethod - def map_nmm(cls, nmm_to_map): - nmm = NoteModelMapping.from_repr(nmm_to_map) - return nmm.get_note_model_mapping_dict() - - def verify_contents(self): - errors = [] - - for nm in self.note_model_mappings.values(): - try: - nm.verify_contents() - except KeyError as e: - errors.append(e) - - # Check all referenced note models have a mapping - for csv_map in self.file_mappings: - for nm in csv_map.get_used_note_model_names(): - if nm not in self.note_model_mappings.keys(): - errors.append(f"Missing Note Model Map for {nm}") - - # Check each of the Csvs (or their derivatives) contain all the necessary columns for their stated note model - for cfm in self.file_mappings: - note_model_names = cfm.get_used_note_model_names() - available_columns = cfm.get_available_columns() - - referenced_note_models_maps = [value for key, value in self.note_model_mappings.items() - if key in note_model_names] - for nm_map in referenced_note_models_maps: - for holder in nm_map.note_models.values(): - if holder.part.name in note_model_names: - missing_columns = [col for col in holder.part.field_names_lowercase if - col not in nm_map.csv_headers_map_to_note_fields(available_columns)] - if missing_columns: - logging.warning(f"Csvs are missing columns from {holder.part_id} {missing_columns}") - - if errors: - raise Exception(errors) diff --git a/brain_brew/build_tasks/deck_parts/__init__.py b/brain_brew/build_tasks/deck_parts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/deck_parts/from_yaml_part.py b/brain_brew/build_tasks/deck_parts/from_yaml_part.py deleted file mode 100644 index a87b54d..0000000 --- a/brain_brew/build_tasks/deck_parts/from_yaml_part.py +++ /dev/null @@ -1,61 +0,0 @@ -from abc import ABCMeta -from dataclasses import dataclass -from typing import Union - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.representation.yaml.notes import Notes -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class FromYamlPartBase(BuildPartTask, metaclass=ABCMeta): - part_type = None - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=cls.part_type.from_yaml_file(rep.file)) - ) - - def execute(self): - pass - - rep: Representation - part: YamlObject - - -@dataclass -class NotesFromYamlPart(FromYamlPartBase): - @classmethod - def task_name(cls) -> str: - return r'notes_from_yaml_part' - - part_type = Notes - - -@dataclass -class MediaGroupFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'media_group_from_yaml_part' - - part_type = MediaGroup diff --git a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py deleted file mode 100644 index b58c16f..0000000 --- a/brain_brew/build_tasks/deck_parts/headers_from_yaml_part.py +++ /dev/null @@ -1,67 +0,0 @@ -from dataclasses import dataclass, field -from typing import Union, Optional - -from brain_brew.build_tasks.overrides.headers_override import HeadersOverride -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersFromYamlPart(BuildPartTask): - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - override: include('{HeadersOverride.task_name()}', required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {HeadersOverride} - - @classmethod - def task_name(cls) -> str: - return r'headers_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'headers?_from_yaml_part' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - override: Optional[dict] = field(default_factory=lambda: None) - - def encode(self): - d = { - "part_id": self.part_id, - "file": self.file - } - if self.override: - d.setdefault("override", self.override) - return d - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - headers=PartHolder.override_or_create( - part_id=rep.part_id, - save_to_file=None, - part=Headers.from_yaml_file(rep.file) - ).part, - override=HeadersOverride.from_repr(rep.override) if rep.override else None - ) - - rep: Representation - headers: Headers - override: Optional[HeadersOverride] - - def execute(self): - if self.override: - self.headers = self.override.override(self.headers) diff --git a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py b/brain_brew/build_tasks/deck_parts/media_group_from_folder.py deleted file mode 100644 index 4df8644..0000000 --- a/brain_brew/build_tasks/deck_parts/media_group_from_folder.py +++ /dev/null @@ -1,58 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.create_media_group_from_location import create_media_group_from_location - - -@dataclass -class MediaGroupFromFolder(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r"media_group_from_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - source: str - filter_blacklist_from_parts: List[str] = field(default_factory=list) - filter_whitelist_from_parts: List[str] = field(default_factory=list) - recursive: Optional[bool] = field(default=True) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part=create_media_group_from_location( - part_id=rep.part_id, - save_to_file=rep.save_to_file, - media_group=MediaGroup.from_directory(rep.source, rep.recursive), - groups_to_blacklist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_blacklist_from_parts)), - groups_to_whitelist=list(holder.part for holder in - map(PartHolder.from_file_manager, rep.filter_whitelist_from_parts)) - # match criteria - ) - ) - - rep: Representation - part: MediaGroup - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py b/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py deleted file mode 100644 index cff5da2..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_html_parts.py +++ /dev/null @@ -1,78 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union, List - -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template - - -@dataclass -class NoteModelFromHTMLParts(BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_model_from_html_parts' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - model_id: str() - css_file: str() - fields: list(include('{Field.task_name()}')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - ''' - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field} - - @dataclass - class Representation(RepresentationBase): - part_id: str - model_id: str - css_file: str - fields: List[dict] - templates: List[dict] - model_name: Optional[str] = field(default=None) - save_to_file: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - part_id=rep.part_id, - model_id=rep.model_id, - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=True), - fields=list(map(Field.from_dict, rep.fields)), - templates=list(holder.part for holder in map(PartHolder.from_file_manager, rep.templates)), - model_name=rep.model_name or rep.part_id, - save_to_file=rep.save_to_file - ) - - rep: Representation - part_id: str - model_id: str - css: str - fields: List[Field] - templates: List[Template] - model_name: str - save_to_file: Optional[str] - - def execute(self): - part = NoteModel( - name=self.model_name, - id=self.model_id, - css=self.css, - fields=self.fields, - templates=self.templates, - required_fields_per_template=[] - ) - - PartHolder.override_or_create(self.part_id, self.save_to_file, part) diff --git a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py b/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py deleted file mode 100644 index bdf5679..0000000 --- a/brain_brew/build_tasks/deck_parts/note_model_from_yaml_part.py +++ /dev/null @@ -1,45 +0,0 @@ -from dataclasses import dataclass -from typing import Union - -from brain_brew.build_tasks.deck_parts.from_yaml_part import FromYamlPartBase -from brain_brew.commands.run_recipe.build_task import BuildPartTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel - - -@dataclass -class NoteModelsFromYamlPart(FromYamlPartBase, BuildPartTask): - @classmethod - def task_name(cls) -> str: - return r'note_models_from_yaml_part' - - @classmethod - def task_regex(cls) -> str: - return r'note_models?_from_yaml_part' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - part_id: str() - file: str() - ''' - - @dataclass - class Representation(RepresentationBase): - part_id: str - file: str - # TODO: Overrides - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - - return cls( - rep=rep, - part=PartHolder.override_or_create( - part_id=rep.part_id, save_to_file=None, part=NoteModel.from_yaml_file(rep.file)) - ) - - def execute(self): - pass diff --git a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py b/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py deleted file mode 100644 index 4b1d707..0000000 --- a/brain_brew/build_tasks/deck_parts/save_media_group_to_folder.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.transformers.save_media_group_to_location import save_media_groups_to_location - - -@dataclass -class SaveMediaGroupsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_media_groups_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_media_groups?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_folder: Optional[bool] = field(default=None) - recursive: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_folder=rep.clear_folder or False, - recursive=rep.recursive or False - ) - - rep: Representation - parts: List[MediaGroup] - folder: str - clear_folder: bool - recursive: bool - - def execute(self): - save_media_groups_to_location(self.parts, self.folder, self.clear_folder, self.recursive) diff --git a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py b/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py deleted file mode 100644 index a05ab04..0000000 --- a/brain_brew/build_tasks/deck_parts/save_note_models_to_folder.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union, Optional, Dict - -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.transformers.save_note_model_to_location import save_note_model_to_location - - -@dataclass -class SaveNoteModelsToFolder(TopLevelBuildTask): - @classmethod - def task_name(cls) -> str: - return r'save_note_models_to_folder' - - @classmethod - def task_regex(cls) -> str: - return r"save_note_models?_to_folder" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - parts: List[str] - folder: str - clear_existing: Optional[bool] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - parts=list(holder.part for holder in map(PartHolder.from_file_manager, rep.parts)), - folder=rep.folder, - clear_existing=rep.clear_existing or False, - ) - - rep: Representation - parts: List[NoteModel] - folder: str - clear_existing: bool - - def execute(self) -> Dict[str, str]: - model_yaml_files: Dict[str, str] = {} - for model in self.parts: - model_yaml_files.setdefault( - model.name, - save_note_model_to_location(model, self.folder, self.clear_existing) - ) - return model_yaml_files diff --git a/brain_brew/build_tasks/overrides/__init__.py b/brain_brew/build_tasks/overrides/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/build_tasks/overrides/headers_override.py b/brain_brew/build_tasks/overrides/headers_override.py deleted file mode 100644 index 3f0b436..0000000 --- a/brain_brew/build_tasks/overrides/headers_override.py +++ /dev/null @@ -1,55 +0,0 @@ -from dataclasses import dataclass, field -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.headers import Headers - - -@dataclass -class HeadersOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"headers_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - crowdanki_uuid: Optional[str] = field(default=None) - deck_description_html_file: Optional[str] = field(default=None) - name: Optional[str] = field(default=None) - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - crowdanki_uuid=rep.crowdanki_uuid, - deck_desc_html_file=HTMLFile.create_or_get(rep.deck_description_html_file), - name=rep.name - ) - - rep: Representation - crowdanki_uuid: Optional[str] - deck_desc_html_file: Optional[HTMLFile] - name: Optional[str] - - def override(self, header: Headers): - if self.deck_desc_html_file: - header.description = self.deck_desc_html_file.get_data(deep_copy=True) - - if self.crowdanki_uuid: - header.crowdanki_uuid = self.crowdanki_uuid - - if self.name: - header.name = self.name - - return header diff --git a/brain_brew/build_tasks/overrides/notes_override.py b/brain_brew/build_tasks/overrides/notes_override.py deleted file mode 100644 index 8ce4604..0000000 --- a/brain_brew/build_tasks/overrides/notes_override.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.notes import Note - - -@dataclass -class NotesOverride(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"notes_override" - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_model: str(required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_model: Optional[str] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - note_model=rep.note_model - ) - - rep: Representation - note_model: Optional[str] - - def override(self, note: Note): - if self.note_model: - note.note_model = self.note_model - - return note diff --git a/brain_brew/commands/__init__.py b/brain_brew/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/argument_reader.py b/brain_brew/commands/argument_reader.py deleted file mode 100644 index 88d1371..0000000 --- a/brain_brew/commands/argument_reader.py +++ /dev/null @@ -1,117 +0,0 @@ -from enum import Enum - -import sys -from argparse import ArgumentParser - -from brain_brew.front_matter import latest_version_number -from brain_brew.commands.init_repo.init_repo import InitRepo -from brain_brew.commands.run_recipe.run_recipe import RunRecipe -from brain_brew.interfaces.command import Command - - -class Commands(Enum): - RUN_RECIPE = "run" - INIT_REPO = "init" - - -class BBArgumentReader(ArgumentParser): - def __init__(self, test_mode=False): - super().__init__( - prog="brainbrew", - description='Manage Flashcards by transforming them to various types.' - ) - - self._set_parser_arguments() - - if not test_mode and len(sys.argv) == 1: - self.print_help(sys.stderr) - sys.exit(1) - - def _set_parser_arguments(self): - - subparsers = self.add_subparsers(parser_class=ArgumentParser, help='Commands that can be run', dest="command") - - parser_run = subparsers.add_parser( - Commands.RUN_RECIPE.value, - help="Run a recipe file. This will convert some data to another format, based on the instructions in the recipe file." - ) - parser_run.add_argument( - "recipe", - metavar="recipe", - type=str, - help="Yaml file to use as the recipe" - ) - parser_run.add_argument( - "--verify", "-v", - action="store_true", - dest="verify_only", - default=False, - help="Only verify the recipe contents, without running it." - ) - - parser_init = subparsers.add_parser( - Commands.INIT_REPO.value, - help="Initialise a Brain Brew repository, using a CrowdAnki export as the base data." - ) - parser_init.add_argument( - "crowdanki_folder", - metavar="crowdanki_folder", - type=str, - help="The folder that stores the CrowdAnki files to build this repo from" - ) - parser_init.add_argument( - '--delimiter', - dest='delimiter', - action='store', - help="Set the delimiter for Csv files to specific character", - type=str - ) - parser_init.add_argument( - "--delimitertab", "--tab", - action="store_true", - dest="delimiter_tab", - default=False, - help="Use tabs as the delimiter for Csv files" - ) - - def get_parsed(self, override_args=None) -> Command: - parsed_args = self.parse_args(args=override_args) - - if parsed_args.command == Commands.RUN_RECIPE.value: - # Required - recipe = self.error_if_blank(parsed_args.recipe) - - # Optional - verify_only = parsed_args.verify_only - - return RunRecipe( - recipe_file_name=recipe, - verify_only=verify_only - ) - - if parsed_args.command == Commands.INIT_REPO.value: - # Required - crowdanki_folder = parsed_args.crowdanki_folder - delimiter = parsed_args.delimiter - delimiter_tab = parsed_args.delimiter_tab - - return InitRepo( - crowdanki_folder=crowdanki_folder, - delimiter="\t" if delimiter_tab else delimiter - ) - - raise KeyError("Unknown Command") - - def error_if_blank(self, arg): - if arg == "" or arg is None: - self.error("Required argument missing") - return arg - - def error(self, message): - sys.stderr.write('error: %s\n' % message) - self.print_help() - sys.exit(2) - - def print_help(self, message=None): - print(f"Brain Brew v{latest_version_number()}") - super().print_help(message) diff --git a/brain_brew/commands/init_repo/__init__.py b/brain_brew/commands/init_repo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/init_repo/init_repo.py b/brain_brew/commands/init_repo/init_repo.py deleted file mode 100644 index 3963376..0000000 --- a/brain_brew/commands/init_repo/init_repo.py +++ /dev/null @@ -1,208 +0,0 @@ -import os -from dataclasses import dataclass -from typing import List - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.headers_to_crowd_anki import HeadersToCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_to_crowd_anki import MediaGroupToCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_to_crowd_anki import NoteModelsToCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_to_crowd_anki import NotesToCrowdAnki -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import TopLevelBuildTask, BuildPartTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.interfaces.command import Command -from brain_brew.representation.generic.csv_file import CsvFile -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.transformers.file_mapping import FileMapping -from brain_brew.transformers.note_model_mapping import NoteModelMapping -from brain_brew.utils import create_path_if_not_exists, filename_from_full_path, folder_name_from_full_path - -RECIPE_MEDIA = "deck_media" -RECIPE_HEADERS = "deck_headers" -RECIPE_NOTES = "deck_notes" - -LOC_RECIPES = "recipes/" -LOC_BUILD = "build/" -LOC_DATA = "src/data/" -LOC_HEADERS = "src/headers/" -LOC_NOTE_MODELS = "src/note_models/" -LOC_MEDIA = "src/media/" - - -@dataclass -class InitRepo(Command): - crowdanki_folder: str - delimiter: str - - def execute(self): - self.setup_repo_structure() - - # Create the Deck Parts used - headers_ca, note_models_all_ca, notes_ca, media_group_ca = self.parts_from_crowdanki(self.crowdanki_folder) - - headers = headers_ca.execute().part - headers_name = LOC_HEADERS + "header1.yaml" - headers.dump_to_yaml(headers_name) - # TODO: desc file - - note_models = [m.part for m in note_models_all_ca.execute()] - - notes = notes_ca.execute().part - used_note_models_in_notes = notes.get_all_known_note_model_names() - - media_group_ca.execute() - - note_model_mappings = [NoteModelMapping.Representation([model.name for model in note_models])] - file_mappings: List[FileMapping.Representation] = [] - - csv_files = [] - - for model in note_models: - if model.name in used_note_models_in_notes: - csv_file_path = os.path.join(LOC_DATA, CsvFile.to_filename_csv(model.name, self.delimiter)) - column_headers = ["guid"] + model.field_names_lowercase + ["tags"] - CsvFile.create_file_with_headers(csv_file_path, column_headers, delimiter=self.delimiter) - - file_mappings.append(FileMapping.Representation( - file=csv_file_path, - note_model=model.name, - delimiter=self.delimiter - )) - - csv_files.append(csv_file_path) - - deck_path = os.path.join(LOC_BUILD, folder_name_from_full_path(self.crowdanki_folder)) - - # Generate the Source files that will be kept in the repo - save_note_models_to_folder = SaveNoteModelsToFolder.from_repr(SaveNoteModelsToFolder.Representation( - [m.name for m in note_models], LOC_NOTE_MODELS, True - )) - model_name_to_file_dict = save_note_models_to_folder.execute() - - save_media_to_folder = SaveMediaGroupsToFolder.from_repr(SaveMediaGroupsToFolder.Representation( - parts=[RECIPE_MEDIA], folder=LOC_MEDIA, recursive=True, clear_folder=True - )) - save_media_to_folder.execute() - - generate_csvs = CsvsGenerate.from_repr({ - 'notes': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - generate_csvs.execute() - - # Create Recipes - - # Anki to Source - headers_recipe, note_models_all_recipe, notes_recipe, media_group_recipe = self.parts_from_crowdanki(deck_path) - - build_part_tasks: List[BuildPartTask] = [ - headers_recipe, - notes_recipe, - note_models_all_recipe, - media_group_recipe, - ] - dp_builder = PartsBuilder(build_part_tasks) - - top_level_tasks: List[TopLevelBuildTask] = [dp_builder, save_media_to_folder, generate_csvs] - self.create_yaml_from_top_level(top_level_tasks, os.path.join(LOC_RECIPES, "anki_to_source")) - - # Source to Anki - note_models_from_yaml = [ - NoteModelsFromYamlPart.from_repr(NoteModelsFromYamlPart.Representation(name, file)) - for name, file in model_name_to_file_dict.items() - ] - - media_group_from_folder = MediaGroupFromFolder.from_repr(MediaGroupFromFolder.Representation( - part_id=RECIPE_MEDIA, source=LOC_MEDIA, recursive=True - )) - - headers_from_yaml = HeadersFromYamlPart.from_repr(HeadersFromYamlPart.Representation( - part_id=RECIPE_HEADERS, file=headers_name - )) - - notes_from_csv = NotesFromCsvs.from_repr({ - 'part_id': RECIPE_NOTES, - 'note_model_mappings': note_model_mappings, - 'file_mappings': file_mappings - }) - - build_part_tasks: List[BuildPartTask] = note_models_from_yaml + [ - headers_from_yaml, - notes_from_csv, - media_group_from_folder, - ] - dp_builder = PartsBuilder(build_part_tasks) - - generate_guids_in_csv = GenerateGuidsInCsvs.from_repr(GenerateGuidsInCsvs.Representation( - source=csv_files, columns=["guid"], delimiter=self.delimiter - )) - - generate_crowdanki = CrowdAnkiGenerate.from_repr(CrowdAnkiGenerate.Representation( - folder=deck_path, - notes=NotesToCrowdAnki.Representation( - part_id=RECIPE_NOTES - ).encode(), - headers=RECIPE_HEADERS, - media=MediaGroupToCrowdAnki.Representation( - parts=[RECIPE_MEDIA] - ).encode(), - note_models=NoteModelsToCrowdAnki.Representation( - parts=[NoteModelsToCrowdAnki.NoteModelListItem.Representation(name).encode() - for name, file in model_name_to_file_dict.items()] - ).encode() - )) - - top_level_tasks: List[TopLevelBuildTask] = [generate_guids_in_csv, dp_builder, generate_crowdanki] - source_to_anki_path = os.path.join(LOC_RECIPES, "source_to_anki.yaml") - self.create_yaml_from_top_level(top_level_tasks, source_to_anki_path) - - print(f"\nRepo Init complete. You should now run `brainbrew run {source_to_anki_path}`") - - @staticmethod - def create_yaml_from_top_level(top_tasks: List[TopLevelBuildTask], filepath: str): - tl_builder = TopLevelBuilder(top_tasks) - - encoded_top_level_tasks = tl_builder.encode() - # print(encoded_top_level_tasks) - - model_yaml_file_name = YamlObject.to_filename_yaml(filepath) - YamlObject.dump_to_yaml_file(model_yaml_file_name, encoded_top_level_tasks) - - @staticmethod - def parts_from_crowdanki(folder: str): - headers_ca = HeadersFromCrowdAnki.from_repr(HeadersFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_HEADERS - )) - note_models_all_ca = NoteModelsAllFromCrowdAnki.from_repr(NoteModelsAllFromCrowdAnki.Representation( - source=folder - )) - notes_ca = NotesFromCrowdAnki.from_repr(NotesFromCrowdAnki.Representation( - source=folder, part_id=RECIPE_NOTES - )) - media_group_ca = MediaGroupFromCrowdAnki.from_repr(MediaGroupFromFolder.Representation( - source=folder, part_id=RECIPE_MEDIA - )) - return headers_ca, note_models_all_ca, notes_ca, media_group_ca - - @staticmethod - def setup_repo_structure(): - create_path_if_not_exists(LOC_RECIPES) - create_path_if_not_exists(LOC_BUILD) - create_path_if_not_exists(LOC_DATA) - create_path_if_not_exists(LOC_HEADERS) - create_path_if_not_exists(LOC_NOTE_MODELS) - create_path_if_not_exists(LOC_MEDIA) diff --git a/brain_brew/commands/run_recipe/__init__.py b/brain_brew/commands/run_recipe/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/commands/run_recipe/build_task.py b/brain_brew/commands/run_recipe/build_task.py deleted file mode 100644 index a95d128..0000000 --- a/brain_brew/commands/run_recipe/build_task.py +++ /dev/null @@ -1,45 +0,0 @@ -from abc import ABCMeta, abstractmethod -from typing import Dict, Type, Set, Optional - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class BuildTask(YamlRepr, object, metaclass=ABCMeta): - execute_immediately: bool = False - accepts_list_of_self: bool = True - rep: Optional[RepresentationBase] - - def encode_rep(self) -> Dict[str, any]: - return self.rep.encode() - - @abstractmethod - def execute(self): - pass - - @classmethod - def task_regex(cls) -> str: - return cls.task_name() - - @classmethod - def get_all_task_regex(cls, subclasses: Set[Type['BuildTask']]) -> Dict[str, Type['BuildTask']]: - task_regex_matches: Dict[str, Type[BuildTask]] = {} - - for sc in subclasses: - if sc.task_regex in task_regex_matches: - raise KeyError(f"Multiple instances of task regex '{sc.task_regex}'") - elif sc.task_regex == "" or sc.task_regex is None: - raise KeyError(f"Unknown task regex in {sc.__name__}") - - task_regex_matches.setdefault(sc.task_regex(), sc) - - # logging.debug(f"Known build tasks: {known_build_tasks}") - return task_regex_matches - - -class TopLevelBuildTask(BuildTask, metaclass=ABCMeta): - pass - - -class BuildPartTask(BuildTask, metaclass=ABCMeta): - execute_immediately: bool = True diff --git a/brain_brew/commands/run_recipe/parts_builder.py b/brain_brew/commands/run_recipe/parts_builder.py deleted file mode 100644 index 0b3fa23..0000000 --- a/brain_brew/commands/run_recipe/parts_builder.py +++ /dev/null @@ -1,64 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.headers_from_crowdanki import HeadersFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.media_group_from_crowd_anki import MediaGroupFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_model_single_from_crowd_anki import NoteModelSingleFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.note_models_all_from_crowd_anki import NoteModelsAllFromCrowdAnki -from brain_brew.build_tasks.crowd_anki.notes_from_crowd_anki import NotesFromCrowdAnki -from brain_brew.build_tasks.csvs.notes_from_csvs import NotesFromCsvs -from brain_brew.build_tasks.deck_parts.from_yaml_part import NotesFromYamlPart, MediaGroupFromYamlPart -from brain_brew.build_tasks.deck_parts.note_model_from_yaml_part import NoteModelsFromYamlPart -from brain_brew.build_tasks.deck_parts.headers_from_yaml_part import HeadersFromYamlPart -from brain_brew.build_tasks.deck_parts.media_group_from_folder import MediaGroupFromFolder -from brain_brew.build_tasks.deck_parts.note_model_from_html_parts import NoteModelFromHTMLParts -from brain_brew.commands.run_recipe.build_task import BuildTask, BuildPartTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder - - -@dataclass -class PartsBuilder(RecipeBuilder, TopLevelBuildTask): - tasks: List[BuildPartTask] - accepts_list_of_self: bool = False - - @classmethod - def task_name(cls) -> str: - return r'build_parts' - - @classmethod - def task_regex(cls) -> str: - return r'build_parts?' - - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - return BuildPartTask.get_all_task_regex(cls.yamale_dependencies()) - - @classmethod - def from_repr(cls, data: List[dict]): - if not isinstance(data, list): - raise TypeError(f"PartsBuilder needs a list") - return cls.from_list(data) - - def encode(self) -> dict: - pass - - def encode_rep(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_schema(cls) -> str: - return cls.build_yamale_root_node(cls.yamale_dependencies()) - - @classmethod - def yamale_dependencies(cls) -> Set[Type[BuildPartTask]]: - return { - NotesFromCsvs, - NotesFromYamlPart, HeadersFromYamlPart, NoteModelsFromYamlPart, MediaGroupFromYamlPart, - MediaGroupFromFolder, - NoteModelFromHTMLParts, NoteModelsFromYamlPart, NoteModelSingleFromCrowdAnki, NoteModelsAllFromCrowdAnki, - HeadersFromCrowdAnki, MediaGroupFromCrowdAnki, NotesFromCrowdAnki - } diff --git a/brain_brew/commands/run_recipe/recipe_builder.py b/brain_brew/commands/run_recipe/recipe_builder.py deleted file mode 100644 index a4d2f87..0000000 --- a/brain_brew/commands/run_recipe/recipe_builder.py +++ /dev/null @@ -1,82 +0,0 @@ -import re -from abc import ABCMeta, abstractmethod -from dataclasses import dataclass -from textwrap import indent -from typing import Dict, List, Type, Set - -from brain_brew.commands.run_recipe.build_task import BuildTask -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class RecipeBuilder(YamlObject, metaclass=ABCMeta): - tasks: List[BuildTask] - - def tasks_to_encoded(self) -> list: - return [{task.task_name(): task.encode_rep()} for task in self.tasks] - - @classmethod - def from_list(cls, data: List[dict]): - tasks = cls.read_tasks(data) - return cls( - tasks=tasks - ) - - @classmethod - @abstractmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - pass - - @classmethod - def build_yamale_root_node(cls, subclasses: Set[Type['BuildTask']]) -> str: - task_list = [] - for c in sorted(subclasses, key=lambda x: x.task_name()): - task_command = f"any(include('{c.task_name()}'), list(include('{c.task_name()}')))"\ - if c.accepts_list_of_self else f"include('{c.task_name()}')" - task_list.append(f"map({task_command}, key=regex('{c.task_regex()}', ignore_case=True))") - - final_tasks: str = "list(\n" + indent(",\n".join(task_list), ' ') + "\n)\n" - - return final_tasks - - @classmethod - def read_tasks(cls, tasks: List[dict]) -> list: - task_regex_matches = cls.known_task_dict() - build_tasks = [] - - def find_matching_task(task_n): - for regex, task_to_run in task_regex_matches.items(): - if re.match(regex, task_n, re.RegexFlag.IGNORECASE): - return task_to_run - return None - - # Tasks - for task in tasks: - task_keys = list(task.keys()) - if len(task_keys) != 1: - raise KeyError(f"Task should only contain 1 entry, but contains {task_keys} instead. " - f"Missing list separator '-'?", task) - - task_name = task_keys[0] - task_arguments = task[task_keys[0]] - - matching_task = find_matching_task(task_name) - if matching_task is not None: - if matching_task.accepts_list_of_self and isinstance(task_arguments, list): - task_or_tasks = [matching_task.from_repr(t_arg) for t_arg in task_arguments] - else: - task_or_tasks = [matching_task.from_repr(task_arguments)] - - for inner_task in task_or_tasks: - build_tasks.append(inner_task) - if inner_task.execute_immediately: - inner_task.execute() - else: - raise KeyError(f"Unknown task '{task_name}'") # TODO: check this first on all and return all errors - - return build_tasks - - def execute(self): - for task in self.tasks: - if not task.execute_immediately: - task.execute() diff --git a/brain_brew/commands/run_recipe/run_recipe.py b/brain_brew/commands/run_recipe/run_recipe.py deleted file mode 100644 index 0ed18f7..0000000 --- a/brain_brew/commands/run_recipe/run_recipe.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from brain_brew.interfaces.command import Command -from brain_brew.commands.run_recipe.top_level_builder import TopLevelBuilder -from brain_brew.configuration.yaml_verifier import YamlVerifier - - -@dataclass -class RunRecipe(Command): - recipe_file_name: str - verify_only: bool - - def execute(self): - # Parse Build Config File - YamlVerifier() - recipe = TopLevelBuilder.parse_and_read(self.recipe_file_name, self.verify_only) - - if not self.verify_only: - recipe.execute() diff --git a/brain_brew/commands/run_recipe/top_level_builder.py b/brain_brew/commands/run_recipe/top_level_builder.py deleted file mode 100644 index 4650f54..0000000 --- a/brain_brew/commands/run_recipe/top_level_builder.py +++ /dev/null @@ -1,87 +0,0 @@ -from textwrap import indent, dedent -from typing import Dict, Type, List, Set - -from brain_brew.build_tasks.crowd_anki.crowd_anki_generate import CrowdAnkiGenerate -from brain_brew.build_tasks.csvs.csvs_generate import CsvsGenerate -from brain_brew.build_tasks.csvs.generate_guids_in_csvs import GenerateGuidsInCsvs -from brain_brew.build_tasks.deck_parts.save_media_group_to_folder import SaveMediaGroupsToFolder -from brain_brew.build_tasks.deck_parts.save_note_models_to_folder import SaveNoteModelsToFolder -from brain_brew.commands.run_recipe.build_task import BuildTask, TopLevelBuildTask -from brain_brew.commands.run_recipe.parts_builder import PartsBuilder -from brain_brew.commands.run_recipe.recipe_builder import RecipeBuilder -from brain_brew.interfaces.yamale_verifyable import YamlRepr - - -class TopLevelBuilder(YamlRepr, RecipeBuilder): - @classmethod - def known_task_dict(cls) -> Dict[str, Type[BuildTask]]: - values = TopLevelBuildTask.get_all_task_regex(cls.yamale_dependencies()) - return values - - @classmethod - def build_yamale(cls): - separator = '\n---\n' - top_level = cls.yamale_dependencies() - - builder: List[str] = [cls.build_yamale_root_node(top_level), separator] - - def to_sorted_yamale_string(lines: Set[Type[BuildTask]]): - return [f'''{line.task_name()}:\n{indent(dedent(line.yamale_schema()), ' ')}''' - for line in sorted(lines, key=lambda x: x.task_name())] - - # Schema - builder += to_sorted_yamale_string(top_level) - - builder.append(separator) - - # Dependencies - def resolve_dependencies(deps: Set[Type[BuildTask]]) -> Set[Type[BuildTask]]: - result = set() - for d in deps: - result.add(d) - result = result.union(resolve_dependencies(d.yamale_dependencies())) - return result - - children = resolve_dependencies(cls.yamale_dependencies()) - builder += to_sorted_yamale_string({dep for dep in children if dep not in top_level}) - - return '\n'.join(builder) - - @classmethod - def parse_and_read(cls, filename, verify_only: bool) -> 'TopLevelBuilder': - recipe_data = cls.read_to_dict(filename) - - from brain_brew.configuration.yaml_verifier import YamlVerifier - YamlVerifier.get_instance().verify_recipe(filename) - - if verify_only: - return None - - return cls.from_list(recipe_data) - - @classmethod - def task_name(cls) -> str: - pass - - @classmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def from_repr(cls, data: dict): - pass - - def encode(self) -> list: - return self.tasks_to_encoded() - - @classmethod - def from_yaml_file(cls, filename: str): - pass - - @classmethod - def yamale_dependencies(cls) -> Set[Type[TopLevelBuildTask]]: - return { - PartsBuilder, - CrowdAnkiGenerate, CsvsGenerate, - GenerateGuidsInCsvs, SaveMediaGroupsToFolder, SaveNoteModelsToFolder - } diff --git a/brain_brew/configuration/__init__.py b/brain_brew/configuration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/configuration/anki_field.py b/brain_brew/configuration/anki_field.py deleted file mode 100644 index 878e0ac..0000000 --- a/brain_brew/configuration/anki_field.py +++ /dev/null @@ -1,16 +0,0 @@ -class AnkiField: - name: str - anki_name: str - default_value: any - - def __init__(self, anki_name, name=None, default_value=None): - self.anki_name = anki_name - self.name = name if name is not None else anki_name - self.default_value = default_value - - def append_name_if_differs(self, dict_to_add_to: dict, value): - if value != self.default_value: - dict_to_add_to.setdefault(self.name, value) - - def does_differ(self, value): - return value != self.default_value diff --git a/brain_brew/configuration/file_manager.py b/brain_brew/configuration/file_manager.py deleted file mode 100644 index f99e31f..0000000 --- a/brain_brew/configuration/file_manager.py +++ /dev/null @@ -1,58 +0,0 @@ -from typing import Dict, Union - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.yaml.yaml_object import YamlObject - - -class FileManager: - __instance = None - - known_files_dict: Dict[str, SourceFile] - known_parts: Dict[str, PartHolder[YamlObject]] - - def __init__(self): - if FileManager.__instance is None: - FileManager.__instance = self - else: - raise Exception("Multiple FileManagers created") - - self.known_files_dict = {} - self.known_parts = {} - - @staticmethod - def get_instance() -> 'FileManager': - return FileManager.__instance - - @staticmethod - def clear_instance(): - if FileManager.__instance: - FileManager.__instance = None - - # Source Files - - def register_file(self, full_path, file): - if full_path in self.known_files_dict: - raise FileExistsError(f"File already known to FileManager, cannot be registered twice: {full_path}") - self.known_files_dict.setdefault(full_path, file) - - def file_if_exists(self, file_location) -> Union[SourceFile, None]: - if file_location in self.known_files_dict.keys(): - return self.known_files_dict[file_location] - return None - - # Deck Parts - - def register_part(self, dp: PartHolder) -> PartHolder: - if dp.part_id in self.known_parts: - raise KeyError(f"Cannot use same name '{dp.part_id}' for multiple Deck Parts") - self.known_parts.setdefault(dp.part_id, dp) - return dp - - def get_part_if_exists(self, dp_name) -> Union[PartHolder[YamlObject], None]: - return self.known_parts.get(dp_name) - - def get_part(self, name: str): - if name not in self.known_parts: - raise KeyError(f"Cannot find Deck Part '{name}'") - return self.known_parts[name] diff --git a/brain_brew/configuration/part_holder.py b/brain_brew/configuration/part_holder.py deleted file mode 100644 index 5140849..0000000 --- a/brain_brew/configuration/part_holder.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -from dataclasses import dataclass -from typing import Optional, TypeVar, Generic - -T = TypeVar('T') - - -@dataclass -class PartHolder(Generic[T]): - part_id: str - save_to_file: Optional[str] - part: T - - file_manager = None - - @classmethod - def get_file_manager(cls): - if not cls.file_manager: - from brain_brew.configuration.file_manager import FileManager - cls.file_manager = FileManager.get_instance() - return cls.file_manager - - @classmethod - def from_file_manager(cls, part_id: str) -> T: - return cls.get_file_manager().get_part(part_id) - - @classmethod - def override_or_create(cls, part_id: str, save_to_file: Optional[str], part: T): - fm = cls.get_file_manager() - - dp = fm.get_part_if_exists(part_id) - if dp is None: - dp = fm.register_part(PartHolder(part_id, save_to_file, part)) - else: - logging.warning(f"Overwriting existing Deck Part '{part_id}'") - dp.part = part - dp.save_to_file = save_to_file - - dp.write_to_file() - - return dp - - def write_to_file(self): - if self.save_to_file is not None: - self.part.dump_to_yaml(self.save_to_file) diff --git a/brain_brew/configuration/representation_base.py b/brain_brew/configuration/representation_base.py deleted file mode 100644 index 08453c9..0000000 --- a/brain_brew/configuration/representation_base.py +++ /dev/null @@ -1,28 +0,0 @@ -import inspect -import logging - - -class RepresentationBase: - @classmethod - def from_dict(cls, data: dict): - expected_values = { - k: v for k, v in data.items() - if k in inspect.signature(cls).parameters - } - - if len(expected_values) != len(data): - logging.warning(f"Unexpected values found when creating '{cls.__name__}': " - f"{[k for k, v in data.items() if k not in list(expected_values.keys())]}" - "\n!!! Please report this error if it seems strange") - - return cls(**expected_values) - - def encode(self): - return {key: value for key, value in self.__dict__.items() if self.encode_filter(key, value)} - - def encode_filter(self, key, value): - if value is None: - return False - if not value: - return False - return True diff --git a/brain_brew/configuration/yaml_verifier.py b/brain_brew/configuration/yaml_verifier.py deleted file mode 100644 index 89f1bbf..0000000 --- a/brain_brew/configuration/yaml_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging -import os - -import yamale -from yamale import YamaleError -from yamale.schema import Schema -from yamale.validators import DefaultValidators - -validators = DefaultValidators.copy() - - -class YamlVerifier: - __instance = None - recipe_schema: Schema - - def __init__(self): - if YamlVerifier.__instance is None: - YamlVerifier.__instance = self - else: - raise Exception("Multiple YamlVerifiers created") - - path = os.path.join(os.path.dirname(__file__), "../schemas/recipe.yaml") - self.recipe_schema = yamale.make_schema(path, parser='ruamel', validators=validators) - - @staticmethod - def get_instance() -> 'YamlVerifier': - return YamlVerifier.__instance - - def verify_recipe(self, filename): - data = yamale.make_data(filename) - try: - yamale.validate(self.recipe_schema, data) - except YamaleError as e: - print('Validation failed!\n') - for result in e.results: - print("Error validating data '%s' with '%s'\n\t" % (result.data, result.schema)) - for error in result.errors: - print('\t%s' % error) - exit(1) - logging.info(f"Builder file {filename} is ✔ good") diff --git a/brain_brew/front_matter.py b/brain_brew/front_matter.py deleted file mode 100644 index e7b0e27..0000000 --- a/brain_brew/front_matter.py +++ /dev/null @@ -1,2 +0,0 @@ -def latest_version_number(): - return "0.3.11" diff --git a/brain_brew/interfaces/__init__.py b/brain_brew/interfaces/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/interfaces/command.py b/brain_brew/interfaces/command.py deleted file mode 100644 index 3c25a23..0000000 --- a/brain_brew/interfaces/command.py +++ /dev/null @@ -1,7 +0,0 @@ -from abc import ABC, abstractmethod - - -class Command(ABC): - @abstractmethod - def execute(self): - pass diff --git a/brain_brew/interfaces/media_container.py b/brain_brew/interfaces/media_container.py deleted file mode 100644 index 11c7acc..0000000 --- a/brain_brew/interfaces/media_container.py +++ /dev/null @@ -1,8 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Set - - -class MediaContainer(ABC): - @abstractmethod - def get_all_media_references(self) -> Set[str]: - pass diff --git a/brain_brew/interfaces/yamale_verifyable.py b/brain_brew/interfaces/yamale_verifyable.py deleted file mode 100644 index fdf99eb..0000000 --- a/brain_brew/interfaces/yamale_verifyable.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod - - -class YamlRepr(ABC): - @classmethod - @abstractmethod - def task_name(cls) -> str: - pass - - @classmethod - @abstractmethod - def yamale_schema(cls) -> str: - pass - - @classmethod - def yamale_dependencies(cls) -> set: - return set() - - @classmethod - @abstractmethod - def from_repr(cls, data: dict): - pass diff --git a/brain_brew/main.py b/brain_brew/main.py deleted file mode 100644 index 5a36fd8..0000000 --- a/brain_brew/main.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from brain_brew.commands.argument_reader import BBArgumentReader -# sys.path.append(os.path.join(os.path.dirname(__file__), "dist")) -# sys.path.append(os.path.dirname(__file__)) -from brain_brew.configuration.file_manager import FileManager - - -def main(): - logging.basicConfig(level=logging.DEBUG) - - # Read in Arguments - argument_reader = BBArgumentReader() - command = argument_reader.get_parsed() - - # Create Singleton FileManager - FileManager() - - command.execute() - - -if __name__ == "__main__": - main() diff --git a/brain_brew/representation/__init__.py b/brain_brew/representation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/__init__.py b/brain_brew/representation/generic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/generic/csv_file.py b/brain_brew/representation/generic/csv_file.py deleted file mode 100644 index 1f0633d..0000000 --- a/brain_brew/representation/generic/csv_file.py +++ /dev/null @@ -1,116 +0,0 @@ -import csv -from pathlib import Path -import re -import logging -from enum import Enum -from typing import List, Optional - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import create_path_if_not_exists, list_of_str_to_lowercase, sort_dict - -_encoding = "utf-8" - - -class CsvKeys(Enum): - GUID = "guid" - TAGS = "tags" - - -class CsvFile(SourceFile): - file_location: str = "" - _data: List[dict] = [] - column_headers: list = [] - delimiter: str = ',' - - def __init__(self, file, delimiter=None): - self.file_location = file - self.set_delimiter(delimiter) - - def set_delimiter(self, delimiter: str): - if delimiter: - self.delimiter = delimiter - elif re.match(r'.*\.tsv', self.file_location, re.RegexFlag.IGNORECASE): - self.delimiter = '\t' - - @classmethod - def from_file_loc(cls, file_loc) -> 'CsvFile': - return cls(file_loc) - - def read_file(self, create_if_not_exists: Optional[bool] = True): - self._data = [] - - if create_if_not_exists: - create_path_if_not_exists(self.file_location) - Path(self.file_location).touch() - - with open(self.file_location, mode='r', newline='', encoding=_encoding) as csv_file: - csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter) - - self.column_headers = list_of_str_to_lowercase(csv_reader.fieldnames) - - for row in csv_reader: - self._data.append({key.lower(): row[key] for key in row}) - - def write_file(self): - logging.info(f"Writing to Csv '{self.file_location}'") - with open(self.file_location, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=self.column_headers, lineterminator='\n', delimiter=self.delimiter) - - csv_writer.writeheader() - - for row in self._data: - csv_writer.writerow(row) - - def set_data(self, data_override): - self._data = data_override - self.column_headers = list(data_override[0].keys()) if data_override else [] - - def set_data_from_superset(self, superset: List[dict], column_header_override=None): - if column_header_override: - self.column_headers = column_header_override - - data_to_set: List[dict] = [] - for row in superset: - if not all(column in row for column in self.column_headers): - continue - new_row = {} - for column in self.column_headers: - new_row[column] = row[column] - data_to_set.append(new_row) - - self._data = data_to_set - - - def get_data(self, deep_copy=False) -> List[dict]: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def to_filename_csv(filename: str, delimiter: str = None) -> str: - if not re.match(r'.*\.(csv|tsv)', filename, re.RegexFlag.IGNORECASE): - if delimiter == '\t': - return filename + '.tsv' - else: - return filename + ".csv" - return filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_csv(location) - - def sort_data(self, sort_by_keys, reverse_sort, case_insensitive_sort): - self._data = sort_dict(self._data, sort_by_keys, reverse_sort, case_insensitive_sort) - - @classmethod - def create_file_with_headers(cls, filepath: str, headers: List[str], delimiter: str = None): - with open(filepath, mode='w+', newline='', encoding=_encoding) as csv_file: - csv_writer = csv.DictWriter(csv_file, fieldnames=headers, lineterminator='\n', delimiter=delimiter or ",") - - csv_writer.writeheader() - - @staticmethod - def delimiter_matches_file_type(delimiter: str, filename: str) -> bool: - if delimiter == '\t' and re.match(r'.*\.tsv', filename, re.RegexFlag.IGNORECASE): - return True - if delimiter == ',' and re.match(r'.*\.csv', filename, re.RegexFlag.IGNORECASE): - return True - return False diff --git a/brain_brew/representation/generic/html_file.py b/brain_brew/representation/generic/html_file.py deleted file mode 100644 index 7db12e3..0000000 --- a/brain_brew/representation/generic/html_file.py +++ /dev/null @@ -1,38 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.generic.source_file import SourceFile - -_encoding = "utf-8" - -@dataclass -class HTMLFile(SourceFile): - file_location: str - _data: str - - def __init__(self, file): - self.file_location = file - self.read_file() - - @classmethod - def from_file_loc(cls, file_loc) -> 'HTMLFile': - return cls(file_loc) - - def read_file(self): - r = open(self.file_location, 'r', encoding=_encoding) - self._data = r.read() - - def get_data(self, deep_copy=False) -> str: - return self.get_deep_copy(self._data) if deep_copy else self._data - - @staticmethod - def write_file(file_location, data): - with open(file_location, "w+", encoding=_encoding) as file: - file.write(data) - - @staticmethod - def to_filename_html(filename: str) -> str: - return filename + ".html" if not filename.endswith(".html") else filename - - @classmethod - def formatted_file_location(cls, location): - return cls.to_filename_html(location) diff --git a/brain_brew/representation/generic/media_file.py b/brain_brew/representation/generic/media_file.py deleted file mode 100644 index d1e71e9..0000000 --- a/brain_brew/representation/generic/media_file.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import shutil -from dataclasses import dataclass, field - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.utils import filename_from_full_path - - -@dataclass -class MediaFile(SourceFile): - file_path: str - filename: str = field(init=False) - - def __post_init__(self): - self.filename = filename_from_full_path(self.file_path) - - @classmethod - def from_file_loc(cls, file_loc) -> 'MediaFile': - return cls(file_loc) - - def __repr__(self): - return f"MediaFile({self.file_path})" - - def __hash__(self): - return hash(self.__repr__()) - - def copy_self_to_target(self, target: str): - shutil.copy2(self.file_path, target) - - def delete_self(self): - os.remove(self.file_path) diff --git a/brain_brew/representation/generic/source_file.py b/brain_brew/representation/generic/source_file.py deleted file mode 100644 index 96231eb..0000000 --- a/brain_brew/representation/generic/source_file.py +++ /dev/null @@ -1,41 +0,0 @@ -import copy -from pathlib import Path - - -class SourceFile(object): - @classmethod - def from_file_loc(cls, file_loc) -> 'SourceFile': - pass - - @classmethod - def is_file(cls, filename: str): - return Path(filename).is_file() - - @classmethod - def is_dir(cls, folder_name: str): - return Path(folder_name).is_dir() - - @classmethod - def get_deep_copy(cls, data): - return copy.deepcopy(data) - - @classmethod - def create_or_get(cls, location): - from brain_brew.configuration.file_manager import FileManager - _file_manager = FileManager.get_instance() - formatted_location = cls.formatted_file_location(location) - file = _file_manager.file_if_exists(formatted_location) - - if file is not None: - return file - - # if not cls.is_file(location) and not cls.is_dir(location): - # raise FileNotFoundError(f"No file or folder '{location}' exists") - - file = cls.from_file_loc(location) - _file_manager.register_file(formatted_location, file) - return file - - @classmethod - def formatted_file_location(cls, location): - return location diff --git a/brain_brew/representation/json/__init__.py b/brain_brew/representation/json/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/json/crowd_anki_export.py b/brain_brew/representation/json/crowd_anki_export.py deleted file mode 100644 index 5652049..0000000 --- a/brain_brew/representation/json/crowd_anki_export.py +++ /dev/null @@ -1,63 +0,0 @@ -import glob -import logging -from typing import List - -from brain_brew.representation.generic.source_file import SourceFile -from brain_brew.representation.json.json_file import JsonFile -from brain_brew.representation.json.wrappers_for_crowd_anki import CrowdAnkiJsonWrapper -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.utils import create_path_if_not_exists - - -class CrowdAnkiExport(SourceFile): - folder_location: str - json_file_location: str - # import_config: CrowdAnkiImportConfig # TODO: Make this - json_data: CrowdAnkiJsonWrapper - note_models: List[NoteModel] - - media_loc: str - - def __init__(self, folder_location): - self.folder_location = folder_location - if self.folder_location[-1] != "/": - self.folder_location = self.folder_location + "/" - - create_path_if_not_exists(self.folder_location) - - self.json_file_location = self.find_json_file_in_folder() - self._read_json_file() - - self.media_loc = self.folder_location + "media/" - - if not self.is_dir(self.media_loc): - create_path_if_not_exists(self.media_loc) - return - - @classmethod - def from_file_loc(cls, file_loc) -> 'CrowdAnkiExport': - return cls(file_loc) - - def find_json_file_in_folder(self): - files = glob.glob(f"{glob.escape(self.folder_location)}*.json") - - if len(files) == 1: - return files[0] - elif not files: - file_loc = self.folder_location + "deck.json" - logging.warning(f"Creating missing json file '{file_loc}'") - return file_loc - else: - logging.error(f"Multiple json files found in '{self.folder_location}': {files}") - raise FileExistsError() - - def write_to_files(self, json_data): # import_config_data - JsonFile.write_file(self.json_file_location, json_data) - - def _read_json_file(self): - if SourceFile.is_file(self.json_file_location): - self.json_data = CrowdAnkiJsonWrapper(JsonFile.read_file(self.json_file_location)) - self.note_models = list(map(NoteModel.from_crowdanki, self.json_data.note_models)) - else: - self.write_to_files({}) - self.json_data = CrowdAnkiJsonWrapper({}) diff --git a/brain_brew/representation/json/json_file.py b/brain_brew/representation/json/json_file.py deleted file mode 100644 index c1ccfba..0000000 --- a/brain_brew/representation/json/json_file.py +++ /dev/null @@ -1,25 +0,0 @@ -import json - -_encoding = "utf-8" - - -class JsonFile: - @staticmethod - def pretty_print(data): - return json.dumps(data, indent=4) - - @staticmethod - def to_filename_json(filename: str): - if filename[-5:] != ".json": - return filename + ".json" - return filename - - @staticmethod - def read_file(file_location): - with open(JsonFile.to_filename_json(file_location), "r", encoding=_encoding) as read_file: - return json.load(read_file) - - @staticmethod - def write_file(file_location, data): - with open(JsonFile.to_filename_json(file_location), "w+", encoding=_encoding) as write_file: - json.dump(data, write_file, indent=4, sort_keys=False, ensure_ascii=False) diff --git a/brain_brew/representation/json/wrappers_for_crowd_anki.py b/brain_brew/representation/json/wrappers_for_crowd_anki.py deleted file mode 100644 index 26c6e5b..0000000 --- a/brain_brew/representation/json/wrappers_for_crowd_anki.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import List - - -CA_NOTE_MODELS = "note_models" -CA_NOTES = "notes" -CA_MEDIA_FILES = "media_files" -CA_CHILDREN = "children" -CA_TYPE = "__type__" -CA_NAME = "name" -CA_DESCRIPTION = "desc" -CA_UUID = "crowdanki_uuid" - -NOTE_MODEL = "note_model_uuid" -FLAGS = "flags" -GUID = "guid" -TAGS = "tags" -FIELDS = "fields" - - -class CrowdAnkiJsonWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def children(self) -> list: - return self.data.get(CA_CHILDREN, []) - - @property - def note_models(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTE_MODELS) - - - @note_models.setter - def note_models(self, value: list): - self.data[CA_NOTE_MODELS] = value - - @property - def notes(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_NOTES) - - @notes.setter - def notes(self, value: list): - self.data[CA_NOTES] = value - - @property - def media_files(self) -> list: - return CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(self.data, [], CA_MEDIA_FILES) - - @media_files.setter - def media_files(self, value: list): - self.data[CA_MEDIA_FILES] = value - - @property - def name(self) -> list: - return self.data.get(CA_NAME, []) - - @name.setter - def name(self, value: list): - self.data[CA_NAME] = value - - @staticmethod - def get_from_self_and_children_recursively(data: dict, running_data: list, key_name: str): - running_data += data.get(key_name, []) - children = data.get(CA_CHILDREN, []) - if isinstance(children, list): - for child in children: - running_data = CrowdAnkiJsonWrapper.get_from_self_and_children_recursively(child, running_data, key_name) - return running_data - - -class CrowdAnkiNoteWrapper: - data: dict - - def __init__(self, data: dict = None): - self.data = data - - @property - def note_model(self) -> str: - return self.data.get(NOTE_MODEL) - - @note_model.setter - def note_model(self, value: str): - self.data[NOTE_MODEL] = value - - @property - def flags(self) -> int: - return self.data.get(FLAGS) - - @flags.setter - def flags(self, value: int): - self.data[FLAGS] = value - - @property - def guid(self) -> str: - return self.data.get(GUID) - - @guid.setter - def guid(self, value: str): - self.data[GUID] = value - - @property - def tags(self) -> list: - return self.data.get(TAGS, []) - - @tags.setter - def tags(self, value: list): - self.data[TAGS] = value - - @property - def fields(self) -> List[str]: - return self.data.get(FIELDS, []) - - @fields.setter - def fields(self, value: List[str]): - self.data[FIELDS] = value diff --git a/brain_brew/representation/yaml/__init__.py b/brain_brew/representation/yaml/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/representation/yaml/headers.py b/brain_brew/representation/yaml/headers.py deleted file mode 100644 index dd27e7b..0000000 --- a/brain_brew/representation/yaml/headers.py +++ /dev/null @@ -1,44 +0,0 @@ -from dataclasses import dataclass - -from brain_brew.representation.json.wrappers_for_crowd_anki import CA_NAME, CA_DESCRIPTION, CA_UUID -from brain_brew.representation.yaml.yaml_object import YamlObject - - -@dataclass -class Headers(YamlObject): - data: dict - - @classmethod - def from_yaml_file(cls, filename: str): - return cls(data=cls.read_to_dict(filename)) - - def encode(self) -> dict: - return self.data - - @property - def name(self) -> str: - return self.data[CA_NAME] - - @name.setter - def name(self, desc: str): - self.data[CA_NAME] = desc - - @property - def description(self) -> str: - return self.data.get(CA_DESCRIPTION, "") - - @description.setter - def description(self, desc: str): - self.data[CA_DESCRIPTION] = desc - - @property - def crowdanki_uuid(self) -> str: - return self.data.get(CA_UUID, "") - - @crowdanki_uuid.setter - def crowdanki_uuid(self, desc: str): - self.data[CA_UUID] = desc - - @property - def data_without_name(self) -> dict: - return {k: v for k, v in sorted(self.data.items()) if k != CA_NAME} diff --git a/brain_brew/representation/yaml/media_group.py b/brain_brew/representation/yaml/media_group.py deleted file mode 100644 index eb26334..0000000 --- a/brain_brew/representation/yaml/media_group.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass -from typing import Set, Dict, List, Tuple - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_all_files_in_directory - - -@dataclass -class MediaGroup(YamlObject): - media_files: Dict[str, MediaFile] - - def encode(self) -> list: - return list(m.file_path for m in self.media_files.values()) # TODO: Use relative path for directory? - - @classmethod - def from_yaml_file(cls, filename: str) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(cls.read_to_dict(filename))) - - @classmethod - def from_directory(cls, directory: str, recursive: bool) -> 'MediaGroup': - return cls(media_files=cls.from_full_path_list(find_all_files_in_directory(directory, recursive=recursive))) - - @classmethod - def from_many(cls, groups: List['MediaGroup']) -> 'MediaGroup': - files = list(set(file.file_path for group in groups for file in group.media_files.values())) - return cls(media_files=cls.from_full_path_list(files)) - - @staticmethod - def from_full_path_list(known_files: list): - files: Dict[str, MediaFile] = dict() - - for full_path in known_files: - file = MediaFile.create_or_get(full_path) - if file.filename not in files.keys(): - files[file.filename] = file - else: - raise NameError(f"Duplicate files with same filename '{file.filename}' in group") - - return files - - def remove_by_filename(self, filename: str): - self.media_files.pop(filename, None) - - def filter_by_filenames(self, filenames: List[str], should_match: bool): - for media_filename in self.media_files.keys(): - is_match = media_filename in filenames - if is_match != should_match: - self.remove_by_filename(media_filename) - # TODO: Find all missing files - - def compare(self, other: 'MediaGroup') -> Tuple[Set[str], Set[str], Set[str]]: - - self_set = set(self.media_files) - other_set = set(other.media_files) - - return self_set.intersection(other_set), self_set - other_set, other_set - self_set diff --git a/brain_brew/representation/yaml/note_model.py b/brain_brew/representation/yaml/note_model.py deleted file mode 100644 index 74c5ee9..0000000 --- a/brain_brew/representation/yaml/note_model.py +++ /dev/null @@ -1,270 +0,0 @@ -from collections import OrderedDict -from dataclasses import dataclass, field -from typing import List, Union, Dict, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model_field import Field -from brain_brew.representation.yaml.note_model_template import Template -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import list_of_str_to_lowercase - -# CrowdAnki -CROWDANKI_ID = AnkiField("crowdanki_uuid", "id") -CROWDANKI_TYPE = AnkiField("__type__", default_value="NoteModel") - -# Shared -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") - -# Note Model -CSS = AnkiField("css") -LATEX_PRE = AnkiField("latexPre", "latex_pre", - default_value="\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{" - "amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{" - "document}\n") -LATEX_POST = AnkiField("latexPost", "latex_post", default_value="\\end{document}") -LATEX_SVG = AnkiField("latexsvg", "latex_svg", default_value=False) -REQUIRED_FIELDS_PER_TEMPLATE = AnkiField("req", "required_fields_per_template", default_value=[]) -FIELDS = AnkiField("flds", "fields") -TEMPLATES = AnkiField("tmpls", "templates") -TAGS = AnkiField("tags", default_value=[]) -SORT_FIELD_NUM = AnkiField("sortf", "sort_field_num", default_value=0) -IS_CLOZE = AnkiField("type", "is_cloze", default_value=False) -VERSION = AnkiField("vers", "version", default_value=[]) - -# Field -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - -# Template -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) - - -CSS_FILE = AnkiField("css_file") - - -@dataclass -class NoteModel(YamlObject, YamlRepr, MediaContainer): - @classmethod - def task_name(cls) -> str: - return r"note_model_from_yaml_repr_inner" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - {NAME.name}: str() - {CROWDANKI_ID.name}: str() - {CSS_FILE.name}: str() - {FIELDS.name}: include({Field.task_name()}, required=False) - {TEMPLATES.name}: include({Template.task_name()}, required=False) - {REQUIRED_FIELDS_PER_TEMPLATE.name}: list(required=False) - {LATEX_POST.name}: str(required=False) - {LATEX_PRE.name}: str(required=False) - {SORT_FIELD_NUM.name}: int(required=False) - {IS_CLOZE.name}: bool(required=False) - {CROWDANKI_TYPE.name}: str(required=False) - {TAGS.name}: str(required=False) - {VERSION.name}: list(required=False) - """ - - @classmethod - def yamale_dependencies(cls) -> set: - return {Field, Template} - - @dataclass - class Representation(RepresentationBase): - name: str - id: str - css_file: str - fields: List[dict] - templates: List[dict] - - required_fields_per_template: List[list] = field(default_factory=lambda: []) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - return cls( - rep=rep, - fields=[Field.from_repr(f) for f in rep.fields], - templates=[Template.from_html_files(t) for t in rep.templates], - css=HTMLFile.create_or_get(rep.css_file).get_data(deep_copy=False), - - name=rep.name, is_cloze=bool(rep.is_cloze), - latex_pre=rep.latex_pre, latex_post=rep.latex_post, latex_svg=rep.latex_svg, - required_fields_per_template=rep.required_fields_per_template, - tags=rep.tags, sort_field_num=rep.sort_field_num, version=rep.version, - id=rep.id, crowdanki_type=rep.crowdanki_type - ) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - crowdanki_uuid: str - css: str - flds: List[dict] - tmpls: List[dict] - req: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latexPre: str = field(default=LATEX_PRE.default_value) - latexPost: str = field(default=LATEX_POST.default_value) - latexsvg: bool = field(default=LATEX_SVG.default_value) # TODO: Fix lowercase here in CrowdAnki - __type__: str = field(default=CROWDANKI_TYPE.default_value) - tags: List[str] = field(default_factory=lambda: TAGS.default_value) - sortf: int = field(default=SORT_FIELD_NUM.default_value) - type: int = field(default=0) # Is_Cloze Manually set to 0 - vers: list = field(default_factory=lambda: VERSION.default_value) - - rep: Union[Representation, CrowdAnki] - - name: str - id: str - css: str - fields: List[Field] - templates: List[Template] - - required_fields_per_template: List[list] = field(default_factory=lambda: REQUIRED_FIELDS_PER_TEMPLATE.default_value) - latex_post: str = field(default=LATEX_POST.default_value) - latex_pre: str = field(default=LATEX_PRE.default_value) - latex_svg: bool = field(default=LATEX_SVG.default_value) - sort_field_num: int = field(default=SORT_FIELD_NUM.default_value) - is_cloze: bool = field(default=IS_CLOZE.default_value) - crowdanki_type: str = field(default=CROWDANKI_TYPE.default_value) # Should always be "NoteModel" - tags: List[str] = field(default_factory=lambda: TAGS.default_value) # Tags of the last added note - version: list = field(default_factory=lambda: VERSION.default_value) # Legacy version number. Deprecated in Anki - - @classmethod - def from_yaml_file(cls, filename: str): - data = cls.read_to_dict(filename) - return cls.from_repr(data) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): # TODO: field_whitelist, note_model_whitelist - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - rep=ca, - fields=[Field.from_crowd_anki(f) for f in ca.flds], - templates=[Template.from_crowdanki(t) for t in ca.tmpls], - is_cloze=bool(ca.type), - name=ca.name, css=ca.css, latex_pre=ca.latexPre, latex_post=ca.latexPost, latex_svg=ca.latexsvg, - required_fields_per_template=ca.req, tags=ca.tags, sort_field_num=ca.sortf, version=ca.vers, - id=ca.crowdanki_uuid, crowdanki_type=ca.__type__ - ) - - def encode_as_crowdanki(self) -> dict: - data_dict = { - NAME.anki_name: self.name, - CROWDANKI_ID.anki_name: self.id, - CSS.anki_name: self.css, - REQUIRED_FIELDS_PER_TEMPLATE.anki_name: self.required_fields_per_template, - LATEX_PRE.anki_name: self.latex_pre, - LATEX_POST.anki_name: self.latex_post, - LATEX_SVG.anki_name: self.latex_svg, - SORT_FIELD_NUM.anki_name: self.sort_field_num, - CROWDANKI_TYPE.anki_name: self.crowdanki_type, - TAGS.anki_name: self.tags, - VERSION.anki_name: self.version, - IS_CLOZE.anki_name: 1 if self.is_cloze else 0 - } - - data_dict.setdefault(FIELDS.anki_name, [f.encode_as_crowdanki(num) for num, f in enumerate(self.fields)]) - data_dict.setdefault(TEMPLATES.anki_name, [t.encode_as_crowdanki(num) for num, t in enumerate(self.templates)]) - - return OrderedDict(sorted(data_dict.items())) - - def encode_as_part_with_empty_file_references(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS_FILE.name: "" - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode_as_part() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - REQUIRED_FIELDS_PER_TEMPLATE.append_name_if_differs(data_dict, self.required_fields_per_template) - - return data_dict - - def encode(self) -> dict: - data_dict: Dict[str, Union[str, list]] = { - NAME.name: self.name, - CROWDANKI_ID.name: self.id, - CSS.name: self.css - } - - SORT_FIELD_NUM.append_name_if_differs(data_dict, self.sort_field_num) - IS_CLOZE.append_name_if_differs(data_dict, self.is_cloze) - LATEX_PRE.append_name_if_differs(data_dict, self.latex_pre) - LATEX_POST.append_name_if_differs(data_dict, self.latex_post) - LATEX_SVG.append_name_if_differs(data_dict, self.latex_svg) - - data_dict.setdefault(FIELDS.name, [f.encode_as_part() for f in self.fields]) - data_dict.setdefault(TEMPLATES.name, [t.encode() for t in self.templates]) - - # Useless - TAGS.append_name_if_differs(data_dict, self.tags) - VERSION.append_name_if_differs(data_dict, self.version) - CROWDANKI_TYPE.append_name_if_differs(data_dict, self.crowdanki_type) - data_dict.setdefault(REQUIRED_FIELDS_PER_TEMPLATE.name, self.required_fields_per_template) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for template in self.templates: - all_media = all_media.union(template.get_all_media_references()) - - return all_media - - @property - def field_names_lowercase(self): - return list_of_str_to_lowercase([f.name for f in self.fields]) - - def check_field_overlap(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - missing = [f for f in self.field_names_lowercase if f not in fields_to_check] - - return missing - - def check_field_extra(self, fields_to_check: List[str]): - fields_to_check = list_of_str_to_lowercase(fields_to_check) - - return [f for f in fields_to_check if f not in self.field_names_lowercase] - - def zip_field_to_data(self, data: List[str]) -> dict: - if len(self.fields) != len(data): - raise Exception( - f"Data of length {len(data)} cannot map to fields of length {len(self.field_names_lowercase)}", data, self.field_names_lowercase) - return dict(zip(self.field_names_lowercase, data)) - - diff --git a/brain_brew/representation/yaml/note_model_field.py b/brain_brew/representation/yaml/note_model_field.py deleted file mode 100644 index eebf277..0000000 --- a/brain_brew/representation/yaml/note_model_field.py +++ /dev/null @@ -1,86 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Union - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -FONT = AnkiField("font", default_value="Liberation Sans") -MEDIA = AnkiField("media", default_value=[]) -IS_RIGHT_TO_LEFT = AnkiField("rtl", "is_right_to_left", default_value=False) -FONT_SIZE = AnkiField("size", "font_size", default_value=20) -IS_STICKY = AnkiField("sticky", "is_sticky", default_value=False) - - -@dataclass -class Field(RepresentationBase, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r"note_model_field" - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - """ - - @classmethod - def from_repr(cls, data: dict): - return cls.from_dict(data) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - ord: int = field(default=None) - font: str = field(default=FONT.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) - rtl: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - size: int = field(default=FONT_SIZE.default_value) - sticky: bool = field(default=IS_STICKY.default_value) - - name: str - font: str = field(default=FONT.default_value) - is_right_to_left: bool = field(default=IS_RIGHT_TO_LEFT.default_value) - font_size: int = field(default=FONT_SIZE.default_value) - is_sticky: bool = field(default=IS_STICKY.default_value) - media: List[str] = field(default_factory=lambda: MEDIA.default_value) # Unused in Anki - - @classmethod - def from_crowd_anki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, font=ca.font, media=ca.media, - is_right_to_left=ca.rtl, font_size=ca.size, is_sticky=ca.sticky - ) - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - FONT.anki_name: self.font, - MEDIA.anki_name: self.media, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - IS_RIGHT_TO_LEFT.anki_name: self.is_right_to_left, - FONT_SIZE.anki_name: self.font_size, - IS_STICKY.anki_name: self.is_sticky - } - - return data_dict - - def encode_as_part(self) -> dict: - data_dict = { - NAME.name: self.name - } - - FONT.append_name_if_differs(data_dict, self.font) - MEDIA.append_name_if_differs(data_dict, self.media) - IS_RIGHT_TO_LEFT.append_name_if_differs(data_dict, self.is_right_to_left) - FONT_SIZE.append_name_if_differs(data_dict, self.font_size) - IS_STICKY.append_name_if_differs(data_dict, self.is_sticky) - - return data_dict diff --git a/brain_brew/representation/yaml/note_model_template.py b/brain_brew/representation/yaml/note_model_template.py deleted file mode 100644 index 2fa1fb8..0000000 --- a/brain_brew/representation/yaml/note_model_template.py +++ /dev/null @@ -1,196 +0,0 @@ -import os -from dataclasses import dataclass, field -from typing import Optional, Union, Set - -from brain_brew.configuration.anki_field import AnkiField -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field, split_by_regex - -NAME = AnkiField("name") -ORDINAL = AnkiField("ord", "ordinal") -QUESTION_FORMAT = AnkiField("qfmt", "question_format") -ANSWER_FORMAT = AnkiField("afmt", "answer_format") -BROWSER_ANSWER_FORMAT = AnkiField("bafmt", "browser_answer_format", default_value="") -BROWSER_QUESTION_FORMAT = AnkiField("bqfmt", "browser_question_format", default_value="") -DECK_OVERRIDE_ID = AnkiField("did", "deck_override_id", default_value=None) -BROWSER_FONT = AnkiField("bfont", "browser_font", default_value="") -BROWSER_FONT_SIZE = AnkiField("bsize", "browser_font_size", default_value=0) -SCRATCH_PAD = AnkiField("scratchPad", "scratch_pad", default_value=0) - -HTML_FILE = AnkiField("html_file") -BROWSER_HTML_FILE = AnkiField("browser_html_file", default_value=None) - -html_separator_regex = r'(?:\r\n|\r|\n){1,}[-]{1,}(?:\r\n|\r|\n){1,}' - - -@dataclass -class Template(RepresentationBase, YamlObject, YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_template_from_html' - - @classmethod - def yamale_schema(cls) -> str: - return f"""\ - name: str() - html_file: str() - browser_html_file: str(required=False) - deck_override_id: int(required=False) - """ - - @dataclass - class HTML(RepresentationBase): - name: str - html_file: str - browser_html_file: Optional[str] = field(default=None) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - @classmethod - def from_repr(cls, data: Union[HTML, dict]): - rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - return cls.from_html_files(rep) - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Template': - return cls.from_dict(cls.read_to_dict(filename)) - - @dataclass - class CrowdAnki(RepresentationBase): - name: str - qfmt: str - afmt: str - bqfmt: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - bafmt: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - bfont: str = field(default=BROWSER_FONT.default_value) - bsize: int = field(default=BROWSER_FONT_SIZE.default_value) - ord: int = field(default=None) - did: Optional[int] = field(default=None) - scratchPad: int = field(default=SCRATCH_PAD.default_value) - - name: str - question_format: str - answer_format: str - question_format_in_browser: str = field(default=BROWSER_QUESTION_FORMAT.default_value) - answer_format_in_browser: str = field(default=BROWSER_ANSWER_FORMAT.default_value) - browser_font: str = field(default=BROWSER_FONT.default_value) - browser_font_size: int = field(default=BROWSER_FONT_SIZE.default_value) - deck_override_id: Optional[int] = field(default=DECK_OVERRIDE_ID.default_value) - scratch_pad: int = field(default=SCRATCH_PAD.default_value) - - html_file: Optional[str] = field(default="") - browser_html_file: Optional[str] = field(default="") - - @classmethod - def from_html_files(cls, data: Union[HTML, dict]): - html_rep: cls.HTML = data if isinstance(data, cls.HTML) else cls.HTML.from_dict(data) - - html_file = HTMLFile.create_or_get(html_rep.html_file) - browser_html_file = HTMLFile.create_or_get(html_rep.browser_html_file) if html_rep.browser_html_file else None - - main_data = html_file.get_data(deep_copy=True) - browser_data = browser_html_file.get_data(deep_copy=True) if browser_html_file else None - - def split_template(the_data, file): - split = split_by_regex(the_data, html_separator_regex) - if len(split) != 2: - raise ValueError(f"Cannot find" if len(split) < 2 else "More than one" - f" separator '---' in html file '{file.file_location}'") - return split[0], split[1] - - front, back = split_template(main_data, html_file) - browser_front, browser_back = split_template(browser_data, browser_html_file) if browser_data else ("", "") - - return cls( - name=html_rep.name, - question_format=front, - answer_format=back, - question_format_in_browser=browser_front, - answer_format_in_browser=browser_back, - deck_override_id=html_rep.deck_override_id, - html_file=html_rep.html_file, - browser_html_file=html_rep.browser_html_file, - browser_font=html_rep.browser_font, - browser_font_size=html_rep.browser_font_size, - scratch_pad=html_rep.scratch_pad, - ) - - @classmethod - def from_crowdanki(cls, data: Union[CrowdAnki, dict]): - ca: cls.CrowdAnki = data if isinstance(data, cls.CrowdAnki) else cls.CrowdAnki.from_dict(data) - return cls( - name=ca.name, question_format=ca.qfmt, answer_format=ca.afmt, - question_format_in_browser=ca.bqfmt, answer_format_in_browser=ca.bafmt, - deck_override_id=ca.did, browser_font=ca.bfont, browser_font_size=ca.bsize, scratch_pad=ca.scratchPad, - ) - - def encode_as_part(self): - data_dict = { - NAME.name: self.name, - HTML_FILE.name: "" - } - - if self.has_browser_template(): - data_dict.setdefault(BROWSER_HTML_FILE.name, "") - - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def encode_as_crowdanki(self, ordinal: int) -> dict: - data_dict = { - ANSWER_FORMAT.anki_name: self.answer_format, - BROWSER_ANSWER_FORMAT.anki_name: self.answer_format_in_browser, - BROWSER_FONT.anki_name: self.browser_font, - BROWSER_QUESTION_FORMAT.anki_name: self.question_format_in_browser, - BROWSER_FONT_SIZE.anki_name: self.browser_font_size, - DECK_OVERRIDE_ID.anki_name: self.deck_override_id, - NAME.anki_name: self.name, - ORDINAL.anki_name: ordinal, - QUESTION_FORMAT.anki_name: self.question_format, - SCRATCH_PAD.anki_name: self.scratch_pad, - } - - return data_dict - - def encode(self) -> dict: - data_dict = { - NAME.name: self.name, - QUESTION_FORMAT.name: self.question_format, - ANSWER_FORMAT.name: self.answer_format - } - - BROWSER_QUESTION_FORMAT.append_name_if_differs(data_dict, self.question_format_in_browser) - BROWSER_ANSWER_FORMAT.append_name_if_differs(data_dict, self.answer_format_in_browser) - DECK_OVERRIDE_ID.append_name_if_differs(data_dict, self.deck_override_id) - BROWSER_FONT.append_name_if_differs(data_dict, self.browser_font) - BROWSER_FONT_SIZE.append_name_if_differs(data_dict, self.browser_font_size) - SCRATCH_PAD.append_name_if_differs(data_dict, self.scratch_pad) - - return data_dict - - def get_all_media_references(self) -> Set[str]: - all_media = set() \ - .union(find_media_in_field(self.question_format)) \ - .union(find_media_in_field(self.answer_format)) \ - .union(find_media_in_field(self.question_format_in_browser)) \ - .union(find_media_in_field(self.answer_format_in_browser)) - return all_media - - def has_browser_template(self): - return BROWSER_QUESTION_FORMAT.does_differ(self.question_format_in_browser) \ - or BROWSER_ANSWER_FORMAT.does_differ(self.answer_format_in_browser) - - def get_template_files_data(self): - template = f"{self.question_format}\n\n--\n\n{self.answer_format}" - browser_template = f"{self.question_format}\n\n--\n\n{self.answer_format}" if self.has_browser_template() else None - - return template, browser_template diff --git a/brain_brew/representation/yaml/notes.py b/brain_brew/representation/yaml/notes.py deleted file mode 100644 index ae90dcd..0000000 --- a/brain_brew/representation/yaml/notes.py +++ /dev/null @@ -1,184 +0,0 @@ -import logging -from abc import ABCMeta -from dataclasses import dataclass -from typing import List, Optional, Dict, Set - -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import find_media_in_field - -FIELDS = 'fields' -GUID = 'guid' -TAGS = 'tags' -NOTE_MODEL = 'note_model' -FLAGS = "flags" -NOTES = "notes" -NOTE_GROUPINGS = "note_groupings" -MEDIA_REFERENCES = "media_references" - - -@dataclass -class GroupableNoteData(YamlObject, MediaContainer, metaclass=ABCMeta): - note_model: Optional[str] - tags: Optional[List[str]] - - def encode_groupable(self, data_dict): - if self.note_model is not None: - data_dict.setdefault(NOTE_MODEL, self.note_model) - if self.tags is not None and self.tags != []: - data_dict.setdefault(TAGS, self.tags) - return data_dict - - -@dataclass -class Note(GroupableNoteData): - @classmethod - def from_yaml_file(cls, filename: str) -> 'Note': - return cls.from_dict(cls.read_to_dict(filename)) - - fields: List[str] - guid: str - flags: int - # media_references: Optional[Set[str]] - - @classmethod - def from_dict(cls, data: dict): - return cls( - fields=data.get(FIELDS), - guid=data.get(GUID), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None), - flags=data.get(FLAGS, 0) - ) - - def encode(self) -> dict: - data_dict: Dict[str, any] = {FIELDS: self.fields, GUID: self.guid} - if self.flags is not None and self.flags != 0: - data_dict.setdefault(FLAGS, self.flags) - super().encode_groupable(data_dict) - return data_dict - - def get_all_media_references(self) -> Set[str]: - return {entry for field in self.fields for entry in find_media_in_field(field)} - - -@dataclass -class NoteGrouping(GroupableNoteData): - notes: List[Note] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'NoteGrouping': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls( - notes=list(map(Note.from_dict, data.get(NOTES))), - note_model=data.get(NOTE_MODEL, None), - tags=data.get(TAGS, None) - ) - - def encode(self) -> dict: - data_dict = {} - super().encode_groupable(data_dict) - data_dict.setdefault(NOTES, [note.encode() for note in self.notes]) - return data_dict - - # TODO: Extract Shared Tags and Note Models - - def verify_groupings(self): - errors = [] - if self.note_model is not None: - if any([note.note_model for note in self.notes]): - errors.append(ValueError(f"NoteGrouping for 'note_model' {self.note_model} has notes with 'note_model'." - f" Please remove one of these.")) - return errors - - def get_all_known_note_model_names(self) -> set: - return {self.note_model} if self.note_model else {note.note_model for note in self.notes} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note in self.notes: - media = note.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes(self, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - def sort_method(i: Note): - def get_sort_tuple(attr_or_field): - if attr_or_field in [GUID, FLAGS, NOTE_MODEL, TAGS]: - value = getattr(i, attr_or_field) - elif isinstance(attr_or_field, int) and attr_or_field < len(i.fields): - value = i.fields[attr_or_field] - else: - value = "" - logging.warning(f"No known sort value for {attr_or_field}") - - if not isinstance(value, str): - return True, False - return (value == "", value.lower()) if case_insensitive_sort else (value == "", value) - - return tuple(get_sort_tuple(column) for column in sort_by_keys) - - return sorted(self.notes, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(self.notes)) - - return self.notes - - def get_all_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort) -> List[Note]: - def join_tags(n_tags): - if self.tags is None and n_tags is None: - return [] - elif self.tags is None: - return n_tags - elif n_tags is None: - return self.tags - else: - return [*n_tags, *self.tags] - - return [Note( - note_model=self.note_model or n.note_model, - tags=join_tags(n.tags), - fields=n.fields, - guid=n.guid, - flags=n.flags - # media_references=n.media_references or n.get_media_references() - ) for n in self.get_sorted_notes(sort_by_keys, reverse_sort, case_insensitive_sort)] - - -@dataclass -class Notes(YamlObject, MediaContainer): - note_groupings: List[NoteGrouping] - - @classmethod - def from_yaml_file(cls, filename: str) -> 'Notes': - return cls.from_dict(cls.read_to_dict(filename)) - - @classmethod - def from_dict(cls, data: dict): - return cls(note_groupings=list(map(NoteGrouping.from_dict, data.get(NOTE_GROUPINGS)))) - - @classmethod - def from_list_of_notes(cls, notes: List[Note]): - return cls(note_groupings=[NoteGrouping(note_model=None, tags=None, notes=notes)]) # TODO: Check grouping here - - def encode(self) -> dict: - data_dict = {NOTE_GROUPINGS: [note_grouping.encode() for note_grouping in self.note_groupings]} - return data_dict - - def get_all_known_note_model_names(self): - return {nms for group in self.note_groupings for nms in group.get_all_known_note_model_names()} - - def get_all_media_references(self) -> Set[str]: - all_media = set() - for note_group in self.note_groupings: - media = note_group.get_all_media_references() - all_media = all_media.union(media) - return all_media - - def get_sorted_notes_copy(self, sort_by_keys, reverse_sort, case_insensitive_sort): - return [note for group in self.note_groupings - for note in group.get_all_notes_copy(sort_by_keys, reverse_sort, case_insensitive_sort)] diff --git a/brain_brew/representation/yaml/yaml_object.py b/brain_brew/representation/yaml/yaml_object.py deleted file mode 100644 index 3a05198..0000000 --- a/brain_brew/representation/yaml/yaml_object.py +++ /dev/null @@ -1,55 +0,0 @@ -from abc import ABC, abstractmethod -from pathlib import Path - -from ruamel.yaml import YAML - -from brain_brew.utils import create_path_if_not_exists - -yaml_load = YAML(typ='safe') - - -yaml_dump = YAML() -yaml_dump.preserve_quotes = False -yaml_dump.indent(mapping=2, sequence=2, offset=0) -yaml_dump.representer.ignore_aliases = lambda *data: True -# yaml.sort_base_mapping_type_on_output = False - - -class YamlObject(ABC): - @staticmethod - def read_to_dict(filename: str): - filename = YamlObject.to_filename_yaml(filename) - - if not Path(filename).is_file(): - raise FileNotFoundError(filename) - - with open(filename) as file: - return yaml_load.load(file) - - @staticmethod - def to_filename_yaml(filename: str): - if filename[-5:] != ".yaml" and filename[-4:] != ".yml": - return filename + ".yaml" - return filename - - @abstractmethod - def encode(self) -> dict: - pass - - @classmethod - @abstractmethod - def from_yaml_file(cls, filename: str) -> 'YamlObject': - pass - - def dump_to_yaml(self, filepath): - self.dump_to_yaml_file(filepath, self.encode()) - - @classmethod - def dump_to_yaml_file(cls, filepath, data): - filepath = YamlObject.to_filename_yaml(filepath) - - create_path_if_not_exists(filepath) - - with open(filepath, 'w') as fp: - yaml_dump.dump(data, fp) - diff --git a/brain_brew/schemas/__init__.py b/brain_brew/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/schemas/recipe.yaml b/brain_brew/schemas/recipe.yaml deleted file mode 100644 index 6cf5ca7..0000000 --- a/brain_brew/schemas/recipe.yaml +++ /dev/null @@ -1,173 +0,0 @@ -list( - map(include('build_parts'), key=regex('build_parts?', ignore_case=True)), - map(any(include('generate_crowd_anki'), list(include('generate_crowd_anki'))), key=regex('generate_crowd_anki', ignore_case=True)), - map(any(include('generate_csvs'), list(include('generate_csvs'))), key=regex('generate_csvs?', ignore_case=True)), - map(any(include('generate_guids_in_csvs'), list(include('generate_guids_in_csvs'))), key=regex('generate_guids_in_csvs?', ignore_case=True)), - map(any(include('save_media_groups_to_folder'), list(include('save_media_groups_to_folder'))), key=regex('save_media_groups?_to_folder', ignore_case=True)), - map(any(include('save_note_models_to_folder'), list(include('save_note_models_to_folder'))), key=regex('save_note_models?_to_folder', ignore_case=True)) -) - - ---- - -build_parts: - list( - map(any(include('headers_from_crowd_anki'), list(include('headers_from_crowd_anki'))), key=regex('headers?_from_crowd_anki', ignore_case=True)), - map(any(include('headers_from_yaml_part'), list(include('headers_from_yaml_part'))), key=regex('headers?_from_yaml_part', ignore_case=True)), - map(any(include('media_group_from_crowd_anki'), list(include('media_group_from_crowd_anki'))), key=regex('media_group_from_crowd_anki', ignore_case=True)), - map(any(include('media_group_from_folder'), list(include('media_group_from_folder'))), key=regex('media_group_from_folder', ignore_case=True)), - map(any(include('media_group_from_yaml_part'), list(include('media_group_from_yaml_part'))), key=regex('media_group_from_yaml_part', ignore_case=True)), - map(any(include('note_model_from_crowd_anki'), list(include('note_model_from_crowd_anki'))), key=regex('note_model_from_crowd_anki', ignore_case=True)), - map(any(include('note_model_from_html_parts'), list(include('note_model_from_html_parts'))), key=regex('note_model_from_html_parts', ignore_case=True)), - map(any(include('note_models_all_from_crowd_anki'), list(include('note_models_all_from_crowd_anki'))), key=regex('note_models_all_from_crowd_anki', ignore_case=True)), - map(any(include('note_models_from_yaml_part'), list(include('note_models_from_yaml_part'))), key=regex('note_models?_from_yaml_part', ignore_case=True)), - map(any(include('notes_from_crowd_anki'), list(include('notes_from_crowd_anki'))), key=regex('notes_from_crowd_anki', ignore_case=True)), - map(any(include('notes_from_csvs'), list(include('notes_from_csvs'))), key=regex('notes_from_csvs?', ignore_case=True)), - map(any(include('notes_from_yaml_part'), list(include('notes_from_yaml_part'))), key=regex('notes_from_yaml_part', ignore_case=True)) - ) - -generate_crowd_anki: - folder: str() - headers: str() - notes: include('notes_to_crowd_anki') - note_models: include('note_models_to_crowd_anki') - media: include('media_group_to_crowd_anki', required=False) - -generate_csvs: - notes: str() - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -generate_guids_in_csvs: - source: any(str(), list(str())) - columns: any(str(), list(str())) - delimiter: str(required=False) - -save_media_groups_to_folder: - parts: list(str()) - folder: str() - clear_folder: bool(required=False) - recursive: bool(required=False) - -save_note_models_to_folder: - parts: list(str()) - folder: str() - clear_existing: bool(required=False) - - ---- - -file_mapping: - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('file_mapping'), required=False) - delimiter: str(required=False) - -headers_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - -headers_from_yaml_part: - part_id: str() - file: str() - override: include('headers_override', required=False) - -headers_override: - crowdanki_uuid: str(required=False) - deck_description_html_file: str(required=False) - name: str(required=False) - -media_group_from_crowd_anki: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_folder: - part_id: str() - source: str() - save_to_file: str(required=False) - recursive: bool(required=False) - filter_whitelist_from_parts: list(str(), required=False) - filter_blacklist_from_parts: list(str(), required=False) - -media_group_from_yaml_part: - part_id: str() - file: str() - -media_group_to_crowd_anki: - parts: list(str()) - -note_model_field: - name: str() - font: str(required=False) - font_size: int(required=False) - is_sticky: bool(required=False) - is_right_to_left: bool(required=False) - -note_model_from_crowd_anki: - part_id: str() - source: str() - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_from_html_parts: - part_id: str() - model_id: str() - css_file: str() - fields: list(include('note_model_field')) - templates: list(str()) - model_name: str(required=False) - save_to_file: str(required=False) - -note_model_mapping: - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - -note_models_all_from_crowd_anki: - source: str() - -note_models_from_yaml_part: - part_id: str() - file: str() - -note_models_to_crowd_anki: - parts: list(include('note_models_to_crowd_anki_item')) - -note_models_to_crowd_anki_item: - part_id: str() - -notes_from_crowd_anki: - part_id: str() - source: str() - sort_order: list(str(), required=False) - save_to_file: str(required=False) - reverse_sort: str(required=False) - -notes_from_csvs: - part_id: str() - save_to_file: str(required=False) - note_model_mappings: list(include('note_model_mapping')) - file_mappings: list(include('file_mapping')) - -notes_from_yaml_part: - part_id: str() - file: str() - -notes_override: - note_model: str(required=False) - -notes_to_crowd_anki: - part_id: str() - sort_order: list(str(), required=False) - reverse_sort: bool(required=False) - additional_items_to_add: map(str(), key=str(), required=False) - override: include('notes_override', required=False) - case_insensitive_sort: bool(required=False) diff --git a/brain_brew/transformers/__init__.py b/brain_brew/transformers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/brain_brew/transformers/create_media_group_from_location.py b/brain_brew/transformers/create_media_group_from_location.py deleted file mode 100644 index ee09946..0000000 --- a/brain_brew/transformers/create_media_group_from_location.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import List - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.interfaces.media_container import MediaContainer -from brain_brew.representation.yaml.media_group import MediaGroup - - -def create_media_group_from_location( - part_id: str, - save_to_file: str, - media_group: MediaGroup, - groups_to_blacklist: List[MediaContainer], - groups_to_whitelist: List[MediaContainer] -) -> MediaGroup: - if groups_to_whitelist: - white = list(set.union(*[container.get_all_media_references() for container in groups_to_whitelist])) - media_group.filter_by_filenames(white, should_match=True) - - if groups_to_blacklist: - black = list(set.union(*[container.get_all_media_references() for container in groups_to_blacklist])) - media_group.filter_by_filenames(black, should_match=False) - - holder = PartHolder.override_or_create(part_id, save_to_file, media_group) - return holder.part diff --git a/brain_brew/transformers/file_mapping.py b/brain_brew/transformers/file_mapping.py deleted file mode 100644 index 644dd9d..0000000 --- a/brain_brew/transformers/file_mapping.py +++ /dev/null @@ -1,194 +0,0 @@ -import re - -import logging -from dataclasses import dataclass, field -from typing import Dict, List, Optional, Union - -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.generic.csv_file import CsvFile, CsvKeys -from brain_brew.utils import single_item_to_list - -FILE = "csv_file" -NOTE_MODEL = "note_model" -SORT_BY_COLUMNS = "sort_by_columns" -REVERSE_SORT = "reverse_sort" -DERIVATIVES = "derivatives" - - -@dataclass -class FileMappingDerivative(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'file_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - file: str() - note_model: str(required=False) - sort_by_columns: list(str(), required=False) - reverse_sort: bool(required=False) - case_insensitive_sort: bool(required=False) - derivatives: list(include('{cls.task_name()}'), required=False) - delimiter: str(required=False) - ''' - - @dataclass(init=False) - class Representation(RepresentationBase): - file: str - note_model: Optional[str] - sort_by_columns: Optional[Union[list, str]] - reverse_sort: Optional[bool] - derivatives: Optional[List['FileMappingDerivative.Representation']] - delimiter: Optional[str] - - def __init__(self, file, note_model=None, sort_by_columns=None, reverse_sort=None, case_insensitive_sort=None, derivatives=None, delimiter=None): - self.file = file - self.note_model = note_model - self.sort_by_columns = sort_by_columns - self.reverse_sort = reverse_sort - self.case_insensitive_sort = case_insensitive_sort - self.derivatives = list(map(FileMappingDerivative.Representation.from_dict, derivatives)) \ - if derivatives is not None else [] - self.delimiter = delimiter - - def encode_filter(self, key, value): - if not super().encode_filter(key, value): - return False - if key == 'delimiter' and CsvFile.delimiter_matches_file_type(value, self.file): - return False - return True - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - csv = CsvFile.create_or_get(rep.file) - csv.set_delimiter(rep.delimiter) - csv.read_file() - return cls( - rep=rep, - csv_file=csv, - note_model=rep.note_model.strip() if rep.note_model else None, - sort_by_columns=single_item_to_list(rep.sort_by_columns), - reverse_sort=rep.reverse_sort or False, - case_insensitive_sort=rep.case_insensitive_sort or True, - derivatives=list(map(cls.from_repr, rep.derivatives)) if rep.derivatives is not None else [], - ) - - rep: Representation - - compiled_data: Dict[str, dict] = field(init=False) - - csv_file: CsvFile - - note_model: Optional[str] - sort_by_columns: list - reverse_sort: bool - case_insensitive_sort: bool - derivatives: Optional[List['FileMappingDerivative']] - - def get_available_columns(self): - return self.csv_file.column_headers + [col for der in self.derivatives for col in der.get_available_columns()] - - def get_used_note_model_names(self) -> List[str]: - nm = [self.note_model] if self.note_model is not None else [] - return nm + [name for der in self.derivatives for name in der.get_used_note_model_names()] - - def _build_data_recursive(self) -> List[dict]: - data_in_progress = self.csv_file.get_data(deep_copy=True) - - new_columns_seen_so_far = self.csv_file.column_headers.copy() - for der in self.derivatives: - der_cols = der.get_available_columns() - overlapping_cols = [col for col in der_cols if col in self.csv_file.column_headers] - der_cols = [col for col in der_cols if col not in overlapping_cols] - - if not overlapping_cols: - raise KeyError("No column overlap for derivative") - - column_repeat_errors = [KeyError(f"Derivative column {c} in multiple derivative lines") - for c in der_cols if c in new_columns_seen_so_far] - if column_repeat_errors: - raise Exception(column_repeat_errors) - new_columns_seen_so_far += der_cols - - der_match_errors = [] - for der_row in der._build_data_recursive(): - # Find matching row to pair data with - found_match = False - for row in data_in_progress: - if all([der_row[c] == row[c] for c in overlapping_cols]): - for der_col in der_cols: - row[der_col] = der_row[der_col] - found_match = True - # Set Note Model to matching Derivative Note Model - if der.note_model is not None: - row.setdefault(NOTE_MODEL, der.note_model) - break - if not found_match: - der_match_errors.append(ValueError(f"Cannot match derivative row {der_row} to parent")) - - if der_match_errors: - raise Exception(der_match_errors) - - return data_in_progress - - def write_to_csv(self, data_to_set): - self.csv_file.set_data_from_superset(data_to_set) - self.csv_file.sort_data(self.sort_by_columns, self.reverse_sort, self.case_insensitive_sort) - self.csv_file.write_file() - - for der in self.derivatives: - der.write_to_csv(data_to_set) - - -@dataclass -class FileMapping(FileMappingDerivative): - note_model: str # Override Optional on Children - - data_set_has_changed: bool = field(init=False, default=False) - - def compile_data(self): - self.compiled_data = {} - self.data_set_has_changed = False - - data_in_progress = self._build_data_recursive() - - # Set Note Model if not already set - if self.note_model is not None: - for row in data_in_progress: - row.setdefault(NOTE_MODEL, self.note_model) - - for row in data_in_progress: - guid = row[CsvKeys.GUID.value] - if not guid: - raise KeyError("Some rows are missing guids") - self.compiled_data.setdefault(guid, {key.lower(): row[key] for key in row}) - - def set_relevant_data(self, data_set: Dict[str, dict]): - unchanged, changed, added = 0, 0, 0 - for guid in data_set: - if guid in self.compiled_data.keys(): - changed_row = False - for key in data_set[guid]: - if key in self.compiled_data[guid] and self.compiled_data[guid][key] != data_set[guid][key]: - self.compiled_data[guid][key] = data_set[guid][key] - changed_row = True - if changed_row: - changed += 1 - else: - unchanged += 1 - else: - added += 1 - self.compiled_data.setdefault(guid, data_set[guid]) - - if changed > 0 or added > 0: - self.data_set_has_changed = True - - logging.info(f"Set {self.csv_file.file_location} data; changed {changed}, " - f"added {added}, while {unchanged} were identical") - - def write_file_on_close(self): - if self.data_set_has_changed: - self.write_to_csv(list(self.compiled_data.values())) diff --git a/brain_brew/transformers/note_model_mapping.py b/brain_brew/transformers/note_model_mapping.py deleted file mode 100644 index f27dce8..0000000 --- a/brain_brew/transformers/note_model_mapping.py +++ /dev/null @@ -1,178 +0,0 @@ -from dataclasses import dataclass, field -from enum import Enum -from typing import List, Union, Dict, Optional - -from brain_brew.configuration.part_holder import PartHolder -from brain_brew.configuration.representation_base import RepresentationBase -from brain_brew.interfaces.yamale_verifyable import YamlRepr -from brain_brew.representation.yaml.note_model import NoteModel -from brain_brew.representation.yaml.notes import GUID, TAGS -from brain_brew.utils import single_item_to_list - - -class FieldMapping: - class FieldMappingType(Enum): - COLUMN = "column" - PERSONAL_FIELD = "personal_field" - DEFAULT = "default" - - @classmethod - def values(cls): - return set(it.value for it in cls) - - type: FieldMappingType - value: str - field_name: str - - def __init__(self, field_type: FieldMappingType, field_name: str, value: str): - self.type = field_type - self.field_name = field_name.lower() - - if self.type == FieldMapping.FieldMappingType.COLUMN: - self.value = value.lower() - else: - self.value = value - - -@dataclass -class NoteModelMapping(YamlRepr): - @classmethod - def task_name(cls) -> str: - return r'note_model_mapping' - - @classmethod - def yamale_schema(cls) -> str: - return f'''\ - note_models: any(list(str()), str()) - columns_to_fields: map(str(), key=str(), required=False) - personal_fields: list(str(), required=False) - ''' - - @dataclass - class Representation(RepresentationBase): - note_models: Union[str, list] - columns_to_fields: Optional[Dict[str, str]] = field(default=None) - personal_fields: List[str] = field(default_factory=lambda: []) - - note_models: Dict[str, PartHolder[NoteModel]] - columns_manually_mapped: List[FieldMapping] - personal_fields: List[FieldMapping] - - @classmethod - def from_repr(cls, data: Union[Representation, dict]): - rep: cls.Representation = data if isinstance(data, cls.Representation) else cls.Representation.from_dict(data) - note_models = [PartHolder.from_file_manager(model) for model in single_item_to_list(rep.note_models)] - - return cls( - columns_manually_mapped=[FieldMapping( - field_type=FieldMapping.FieldMappingType.COLUMN, - field_name=f, - value=key) for key, f in rep.columns_to_fields.items()] - if rep.columns_to_fields else [], - personal_fields=[FieldMapping( - field_type=FieldMapping.FieldMappingType.PERSONAL_FIELD, - field_name=f, - value="") for f in rep.personal_fields], - note_models=dict(map(lambda nm: (nm.part_id, nm), note_models)) - ) - - def get_note_model_mapping_dict(self): - return {model: self for model in self.note_models} - - def verify_contents(self): - if not self.columns_manually_mapped: # No check needed if no manual mapping is performed - return - - errors = [] - required_field_definitions = [GUID, TAGS] - - extra_fields = [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - - for holder in self.note_models.values(): - model: NoteModel = holder.part - - # Check for Required Fields - missing = [] - for req in required_field_definitions: - if req not in [field.field_name for field in self.columns_manually_mapped]: - missing.append(req) - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Check Fields Align with Note Type - missing = model.check_field_overlap( - [field.field_name for field in self.columns_manually_mapped - if field.field_name not in required_field_definitions] - ) - missing = [m for m in missing if m not in [field.field_name for field in self.personal_fields]] - - if missing: - errors.append(KeyError(f"""Error in note_model_mappings part with note model "{holder.part_id}". \ - When mapping columns_to_fields you must map all fields. \ - Mapping is missing for for fields: {missing}""")) - - # Find mappings which do not exist on any note models - if extra_fields: - extra_fields = model.check_field_extra(extra_fields) - - if extra_fields: - errors.append( - KeyError(f"""Error in note_model_mappings part. \ - Field(s) '{extra_fields}' are defined as mappings, but match no Note Model fields""")) - - if errors: - raise Exception(errors) - - def csv_row_map_to_note_fields(self, row: dict) -> dict: - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - for pf in self.personal_fields: # Add in Personal Fields - relevant_row_data.setdefault(pf.field_name, False) - for column in self.columns_manually_mapped: # Rename from Csv Column to Note Type Field - if column.value in relevant_row_data: - relevant_row_data[column.field_name] = relevant_row_data.pop(column.value) - - return relevant_row_data - - def csv_headers_map_to_note_fields(self, row: list) -> list: - return list(self.csv_row_map_to_note_fields({row_name: "" for row_name in row}).keys()) - - def note_fields_map_to_csv_row(self, row): - for column in self.columns_manually_mapped: # Rename from Note Type Field to Csv Column - if column.field_name in row: - row[column.value] = row.pop(column.field_name) - for pf in self.personal_fields: # Remove Personal Fields - if pf.field_name in row: - del row[pf.field_name] - - relevant_row_data = self.filter_data_row_by_relevant_columns(row) - - return relevant_row_data - - def filter_data_row_by_relevant_columns(self, row): - cols = list(row.keys()) - - relevant_columns = [f.value for f in self.columns_manually_mapped] - if not relevant_columns: - return row - - # errors = [KeyError(f"Missing column {rel_col}") for rel_col in relevant_columns if rel_col not in cols] - # if errors: - # raise Exception(errors) - - irrelevant_columns = [column for column in cols if column not in relevant_columns] - if not irrelevant_columns: - return row - - relevant_data = {key: row[key] for key in row if key not in irrelevant_columns} - - return relevant_data - - def field_values_in_note_model_order(self, note_model_name, fields_from_csv): - return [fields_from_csv[f] if f in fields_from_csv else "" - for f in self.note_models[note_model_name].part.field_names_lowercase - ] diff --git a/brain_brew/transformers/save_media_group_to_location.py b/brain_brew/transformers/save_media_group_to_location.py deleted file mode 100644 index fb5e8fb..0000000 --- a/brain_brew/transformers/save_media_group_to_location.py +++ /dev/null @@ -1,36 +0,0 @@ -import logging -from typing import List, Set - -from brain_brew.representation.generic.media_file import MediaFile -from brain_brew.representation.yaml.media_group import MediaGroup -from brain_brew.utils import create_path_if_not_exists - - -def save_media_groups_to_location( - parts: List[MediaGroup], - folder: str, - clear_folder: bool, - recursive: bool -) -> Set[MediaFile]: - - create_path_if_not_exists(folder, is_path_override=True) - - existing_media_group = MediaGroup.from_directory(folder, recursive) - all_media_group = MediaGroup.from_many(parts) - - in_both, to_move, to_delete = all_media_group.compare(existing_media_group) - - for filename, media_file in all_media_group.media_files.items(): - if filename in in_both: - media_file.copy_self_to_target(existing_media_group.media_files[filename].file_path) - # TODO: Check if copying is needed? - elif filename in to_move: - media_file.copy_self_to_target(folder) - - if clear_folder and to_delete: - deleted = '\n'.join(to_delete) - logging.warning(f"Deleting extra files in media folder '{folder}':\n{'-'*20}\n{deleted}\n{'-'*20}") - for delete_name in to_delete: - existing_media_group.media_files[delete_name].delete_self() - - return set(all_media_group.media_files.values()) diff --git a/brain_brew/transformers/save_note_model_to_location.py b/brain_brew/transformers/save_note_model_to_location.py deleted file mode 100644 index 9a7b367..0000000 --- a/brain_brew/transformers/save_note_model_to_location.py +++ /dev/null @@ -1,46 +0,0 @@ -import logging -import os -from typing import List - -from brain_brew.representation.generic.html_file import HTMLFile -from brain_brew.representation.yaml.note_model import NoteModel, CSS_FILE, TEMPLATES -from brain_brew.representation.yaml.note_model_template import HTML_FILE as TEMPLATE_HTML_FILE, NAME as TEMPLATE_NAME, BROWSER_HTML_FILE as TEMPLATE_BROWSER_HTML_FILE -from brain_brew.representation.yaml.yaml_object import YamlObject -from brain_brew.utils import create_path_if_not_exists, clear_contents_of_folder - - -def save_note_model_to_location( - model: NoteModel, - folder: str, - clear_folder: bool -) -> str: - - nm_folder = os.path.join(folder, model.name + '/') - create_path_if_not_exists(nm_folder) - - if clear_folder: - clear_contents_of_folder(nm_folder) - - model_encoded = model.encode_as_part_with_empty_file_references() - - model_encoded[CSS_FILE.name] = os.path.join(nm_folder, "style.css") - HTMLFile.write_file(model_encoded[CSS_FILE.name], model.css) - - templates_dict = {t.name: t for t in model.templates} - - for template_data in model_encoded[TEMPLATES.name]: - name = template_data[TEMPLATE_NAME.name] - template = templates_dict[name] - t_data, b_t_data = template.get_template_files_data() - - template_data[TEMPLATE_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name)) - HTMLFile.write_file(template_data[TEMPLATE_HTML_FILE.name], t_data) - - if TEMPLATE_BROWSER_HTML_FILE.name in template_data and b_t_data is not None: - template_data[TEMPLATE_BROWSER_HTML_FILE.name] = os.path.join(nm_folder, HTMLFile.to_filename_html(name + "_browser")) - HTMLFile.write_file(template_data[TEMPLATE_BROWSER_HTML_FILE.name], b_t_data) - - model_yaml_file_name = YamlObject.to_filename_yaml(os.path.join(nm_folder, model.name)) - YamlObject.dump_to_yaml_file(model_yaml_file_name, model_encoded) - - return model_yaml_file_name diff --git a/brain_brew/utils.py b/brain_brew/utils.py deleted file mode 100644 index 2b85f39..0000000 --- a/brain_brew/utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -import os -import random -import re -import shutil -import string -from pathlib import Path -from typing import List - - -def blank_str_if_none(s): - return '' if s is None else s - - -def list_of_str_to_lowercase(list_of_strings): - if not list_of_strings: - return [] - return [entry.lower() for entry in list_of_strings] - - -def single_item_to_list(item): - if isinstance(item, list): - return item - if item is None: - return [] - return [item] - - -def str_to_lowercase_no_separators(str_to_tidy: str): - return re.sub(r'[\s+_-]+', '', str_to_tidy.lower()) - - -def filename_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+$', full_path)[0] - - -def folder_name_from_full_path(full_path): - return re.findall(r'[^\\/:*?"<>|\r\n]+[/]?$', full_path)[0] - - -def split_by_regex(str_to_split: str, pattern: str) -> List[str]: - return re.split(pattern, str_to_split) - - -def find_media_in_field(field_value: str) -> List[str]: - if not field_value: - return [] - - images = re.findall(r'<\s*?img.*?src="(.*?)"[^>]*?>', field_value) - audio = re.findall(r'\[sound:(.*?)]', field_value) - - return images + audio - - -def find_all_files_in_directory(directory, recursive=False): - found_files = [] - for path, dirs, files in os.walk(directory): - for file in files: - found_files.append(os.path.join(path, file)) - if not recursive: - return found_files - return found_files - - -def create_path_if_not_exists(path, is_path_override=False): - dir_name = os.path.dirname(path) if not is_path_override else path - if not Path(dir_name).is_dir(): - logging.warning(f"Creating missing filepath '{dir_name}'") - os.makedirs(dir_name, exist_ok=True) - - -def clear_contents_of_folder(path): - for filename in os.listdir(path): - file_path = os.path.join(path, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - print('Failed to delete %s. Reason: %s' % (file_path, e)) - - -def split_tags(tags_value: str) -> list: - split = [entry.strip() for entry in re.split(r';\s*|,\s*|\s+', tags_value)] - while "" in split: - split.remove("") - return split - - -def join_tags(tags_list: list) -> str: - return ", ".join(tags_list) # TODO: Make configurable - - -def generate_anki_guid() -> str: - """Return a base91-encoded 64bit random number.""" - - def base62(num: int, extra: str = "") -> str: - s = string - table = s.ascii_letters + s.digits + extra - buf = "" - while num: - num, i = divmod(num, len(table)) - buf = table[i] + buf - return buf - - _base91_extra_chars = "!#$%&()*+,-./:;<=>?@[]^_`{|}~" - - def base91(num: int) -> str: - # all printable characters minus quotes, backslash and separators - return base62(num, _base91_extra_chars) - - return base91(random.randint(0, 2 ** 64 - 1)) - - -def sort_dict(data, sort_by_keys, reverse_sort, case_insensitive_sort): - if sort_by_keys: - if case_insensitive_sort: - def sort_method(i): - return tuple((i[column] == "", i[column].lower()) for column in sort_by_keys) - else: - def sort_method(i): - return tuple((i[column] == "", i[column]) for column in sort_by_keys) - - return sorted(data, key=sort_method, reverse=reverse_sort) - elif reverse_sort: - return list(reversed(data)) - - return data diff --git a/crates/brain-brew-cli/Cargo.toml b/crates/brain-brew-cli/Cargo.toml new file mode 100644 index 0000000..cd234bd --- /dev/null +++ b/crates/brain-brew-cli/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "brainbrew" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Rust-based Brain Brew CLI for local-first Anki-compatible deck federation" +homepage = "https://github.com/jeprecated/brain-brew" + +[package.metadata.dist] +dist = true +formula = "brainbrew" + +[[bin]] +name = "brainbrew" +path = "src/main.rs" + +[dependencies] +axum = "0.8" +base64 = "0.22" +crossterm = "0.28" +flate2 = "1" +include_dir = "0.7" +nix-nar = "0.4" +brain-brew-core.workspace = true +brain-brew-formats.workspace = true +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +sha2 = "0.10" +tar = "0.4" +tempfile = "3" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +tower-http = { version = "0.6", features = ["fs"] } +ureq = "2" + +[lints] +workspace = true diff --git a/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06.js b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06.js new file mode 100644 index 0000000..0009c7d --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06.js @@ -0,0 +1,1024 @@ +export class IntoUnderlyingByteSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingByteSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingbytesource_free(ptr, 0); + } + /** + * @returns {number} + */ + get autoAllocateChunkSize() { + const ret = wasm.intounderlyingbytesource_autoAllocateChunkSize(this.__wbg_ptr); + return ret >>> 0; + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingbytesource_cancel(ptr); + } + /** + * @param {ReadableByteStreamController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingbytesource_pull(this.__wbg_ptr, controller); + return ret; + } + /** + * @param {ReadableByteStreamController} controller + */ + start(controller) { + wasm.intounderlyingbytesource_start(this.__wbg_ptr, controller); + } + /** + * @returns {ReadableStreamType} + */ + get type() { + const ret = wasm.intounderlyingbytesource_type(this.__wbg_ptr); + return __wbindgen_enum_ReadableStreamType[ret]; + } +} +if (Symbol.dispose) IntoUnderlyingByteSource.prototype[Symbol.dispose] = IntoUnderlyingByteSource.prototype.free; + +export class IntoUnderlyingSink { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSinkFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsink_free(ptr, 0); + } + /** + * @param {any} reason + * @returns {Promise} + */ + abort(reason) { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_abort(ptr, reason); + return ret; + } + /** + * @returns {Promise} + */ + close() { + const ptr = this.__destroy_into_raw(); + const ret = wasm.intounderlyingsink_close(ptr); + return ret; + } + /** + * @param {any} chunk + * @returns {Promise} + */ + write(chunk) { + const ret = wasm.intounderlyingsink_write(this.__wbg_ptr, chunk); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSink.prototype[Symbol.dispose] = IntoUnderlyingSink.prototype.free; + +export class IntoUnderlyingSource { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IntoUnderlyingSourceFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_intounderlyingsource_free(ptr, 0); + } + cancel() { + const ptr = this.__destroy_into_raw(); + wasm.intounderlyingsource_cancel(ptr); + } + /** + * @param {ReadableStreamDefaultController} controller + * @returns {Promise} + */ + pull(controller) { + const ret = wasm.intounderlyingsource_pull(this.__wbg_ptr, controller); + return ret; + } +} +if (Symbol.dispose) IntoUnderlyingSource.prototype[Symbol.dispose] = IntoUnderlyingSource.prototype.free; +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_debug_string_8a447059637473e2: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_falsy_863e44ed68df4b26: function(arg0) { + const ret = !arg0; + return ret; + }, + __wbg___wbindgen_is_function_acc5528be2b923f2: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_null_6d937fbfb6478470: function(arg0) { + const ret = arg0 === null; + return ret; + }, + __wbg___wbindgen_is_undefined_721f8decd50c87a3: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_string_get_71bb4348194e31f0: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_ea4887a5f8f9a9db: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_33c39e13d73b25f6: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_activeElement_39e967783d67360b: function(arg0) { + const ret = arg0.activeElement; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_addEventListener_ea90bc131475777e: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_appendChild_acb7691406591783: function() { return handleError(function (arg0, arg1) { + const ret = arg0.appendChild(arg1); + return ret; + }, arguments); }, + __wbg_body_c94f9310613c153b: function(arg0) { + const ret = arg0.body; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_buffer_9e4d98d0766fb908: function(arg0) { + const ret = arg0.buffer; + return ret; + }, + __wbg_byobRequest_73c9bc59c55e3ee6: function(arg0) { + const ret = arg0.byobRequest; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_byteLength_564c2b9b251b8b6c: function(arg0) { + const ret = arg0.byteLength; + return ret; + }, + __wbg_byteOffset_10f01f7956bb8fe3: function(arg0) { + const ret = arg0.byteOffset; + return ret; + }, + __wbg_call_5575218572ead796: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_cancelBubble_5b69b645651771ec: function(arg0) { + const ret = arg0.cancelBubble; + return ret; + }, + __wbg_checked_55b8214328709363: function(arg0) { + const ret = arg0.checked; + return ret; + }, + __wbg_cloneNode_631e6c7312338944: function() { return handleError(function (arg0) { + const ret = arg0.cloneNode(); + return ret; + }, arguments); }, + __wbg_cloneNode_9272c316db5e3f99: function() { return handleError(function (arg0, arg1) { + const ret = arg0.cloneNode(arg1 !== 0); + return ret; + }, arguments); }, + __wbg_close_5733609ce25f1cc5: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_close_7d786ceed6cb3df5: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_closest_36aba08ae599365a: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.closest(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_composedPath_def7f121fe83b309: function(arg0) { + const ret = arg0.composedPath(); + return ret; + }, + __wbg_content_f9ccb50e1342a471: function(arg0) { + const ret = arg0.content; + return ret; + }, + __wbg_createComment_7e51040356705a12: function(arg0, arg1, arg2) { + const ret = arg0.createComment(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_createElementNS_9b91843f9092dfaf: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + const ret = arg0.createElementNS(arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + return ret; + }, arguments); }, + __wbg_createElement_9e23ac95e40e302c: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_createTextNode_f1aff30c8f1e7fff: function(arg0, arg1, arg2) { + const ret = arg0.createTextNode(getStringFromWasm0(arg1, arg2)); + return ret; + }, + __wbg_deleteProperty_46503cf4081bd53c: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.deleteProperty(arg0, arg1); + return ret; + }, arguments); }, + __wbg_document_2634180a4c694068: function(arg0) { + const ret = arg0.document; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_enqueue_0b3f8e4acf60affc: function() { return handleError(function (arg0, arg1) { + arg0.enqueue(arg1); + }, arguments); }, + __wbg_error_a6fa202b58aa1cd3: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_fetch_8d9b732df7467c44: function(arg0) { + const ret = fetch(arg0); + return ret; + }, + __wbg_firstChild_464bcbc28a75f770: function(arg0) { + const ret = arg0.firstChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_firstElementChild_76be8507bd3e838f: function(arg0) { + const ret = arg0.firstElementChild; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_focus_c80921c19b28520b: function() { return handleError(function (arg0) { + arg0.focus(); + }, arguments); }, + __wbg_getAttribute_9f23675ef6df0d6b: function(arg0, arg1, arg2, arg3) { + const ret = arg1.getAttribute(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_getElementById_c7aba6b93b34bf01: function(arg0, arg1, arg2) { + const ret = arg0.getElementById(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_getItem_f2b45bf1b0166c48: function() { return handleError(function (arg0, arg1, arg2, arg3) { + const ret = arg1.getItem(getStringFromWasm0(arg2, arg3)); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_get_197a3fe98f169e38: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; + }, + __wbg_get_37b48b8fa52d1f2c: function(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_get_dddb90ff5d27a080: function() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; + }, arguments); }, + __wbg_host_3a9c4a713cc5f742: function(arg0) { + const ret = arg0.host; + return ret; + }, + __wbg_insertBefore_d1c9b9c881ae2c96: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.insertBefore(arg1, arg2); + return ret; + }, arguments); }, + __wbg_instanceof_Element_7cd17aeb1babd05e: function(arg0) { + let result; + try { + result = arg0 instanceof Element; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Error_77d0cf0b4f31a32f: function(arg0) { + let result; + try { + result = arg0 instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlInputElement_5a4ec7d390cf0cfc: function(arg0) { + let result; + try { + result = arg0 instanceof HTMLInputElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_HtmlSelectElement_dcf326cd531e9e5d: function(arg0) { + let result; + try { + result = arg0 instanceof HTMLSelectElement; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Response_79948c98d1d2ba75: function(arg0) { + let result; + try { + result = arg0 instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_ShadowRoot_6a0f2632ea4e6534: function(arg0) { + let result; + try { + result = arg0 instanceof ShadowRoot; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Window_0d356b88a2f77c42: function(arg0) { + let result; + try { + result = arg0 instanceof Window; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_is_de5b366c746e004c: function(arg0, arg1) { + const ret = Object.is(arg0, arg1); + return ret; + }, + __wbg_item_668a48adee49f9c6: function(arg0, arg1) { + const ret = arg0.item(arg1 >>> 0); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_key_69986dd1df591552: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg1.key(arg2 >>> 0); + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, arguments); }, + __wbg_length_589238bdcf171f0e: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_length_93d87b2ef70e71b2: function() { return handleError(function (arg0) { + const ret = arg0.length; + return ret; + }, arguments); }, + __wbg_length_f7d417ff7066e5e2: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_localStorage_8daa25c913870d2f: function() { return handleError(function (arg0) { + const ret = arg0.localStorage; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_message_6fc0a1f59fcc247b: function(arg0) { + const ret = arg0.message; + return ret; + }, + __wbg_name_3e4efa9f5ec3f127: function(arg0) { + const ret = arg0.name; + return ret; + }, + __wbg_new_10e2f2ad134f940f: function() { return handleError(function () { + const ret = new Headers(); + return ret; + }, arguments); }, + __wbg_new_227d7c05414eb861: function() { + const ret = new Error(); + return ret; + }, + __wbg_new_26880d0ec07a54ec: function() { return handleError(function (arg0, arg1) { + const ret = new URL(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_2e117a478906f062: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_df50d6b5ba0c6ef5: function() { return handleError(function () { + const ret = new URLSearchParams(); + return ret; + }, arguments); }, + __wbg_new_e66a4b7758dd2e5c: function(arg0, arg1) { + const ret = new Error(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_typed_00a409eb4ec4f2d9: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = 0; + } + }, + __wbg_new_with_byte_offset_and_length_f2b65504a914f37a: function(arg0, arg1, arg2) { + const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0); + return ret; + }, + __wbg_new_with_str_42b7fb3e03fb91e5: function() { return handleError(function (arg0, arg1) { + const ret = new Request(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_with_str_and_init_5b299538bdeeec64: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); + return ret; + }, arguments); }, + __wbg_nextSibling_d25dacde974cb36f: function(arg0) { + const ret = arg0.nextSibling; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_parentNode_31eaeb2d9a747844: function(arg0) { + const ret = arg0.parentNode; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_querySelectorAll_ffda3c891a9eb29a: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelectorAll(getStringFromWasm0(arg1, arg2)); + return ret; + }, arguments); }, + __wbg_querySelector_1f3658f4b48e268b: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_querySelector_45b96634e7f85460: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.querySelector(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_queueMicrotask_1c9b3800e321a967: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_queueMicrotask_311744e534a929a3: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_removeAttribute_f13ae85cfd9fbd58: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeAttribute(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_removeEventListener_c6782a9c30557d31: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_removeItem_dbf2a2abce661547: function() { return handleError(function (arg0, arg1, arg2) { + arg0.removeItem(getStringFromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_remove_4e0c46e5ae03c0b0: function(arg0) { + arg0.remove(); + }, + __wbg_remove_627151898f1aafc8: function(arg0) { + arg0.remove(); + }, + __wbg_resolve_d82363d90af6928a: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_respond_ab492bf06dffbd1d: function() { return handleError(function (arg0, arg1) { + arg0.respond(arg1 >>> 0); + }, arguments); }, + __wbg_search_15207b35b594af69: function(arg0, arg1) { + const ret = arg1.search; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_setAttribute_8bccfbabf2a83682: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setAttribute(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_setItem_ab73a1e4497df37e: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.setItem(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_0bf1fca872bc6d18: function(arg0, arg1, arg2) { + arg0.set(getArrayU8FromWasm0(arg1, arg2)); + }, + __wbg_set_1c87dcfd4a93c514: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.set(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_4564f7dc44fcb0c9: function() { return handleError(function (arg0, arg1, arg2) { + const ret = Reflect.set(arg0, arg1, arg2); + return ret; + }, arguments); }, + __wbg_set_body_97c25d1c0051cb04: function(arg0, arg1) { + arg0.body = arg1; + }, + __wbg_set_headers_6751c09a8e579ff7: function(arg0, arg1) { + arg0.headers = arg1; + }, + __wbg_set_innerHTML_30fcedd016ac76ab: function(arg0, arg1, arg2) { + arg0.innerHTML = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_method_1120482abe0934aa: function(arg0, arg1, arg2) { + arg0.method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_nodeValue_a5d55d296937f033: function(arg0, arg1, arg2) { + arg0.nodeValue = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_readOnly_5f5983a529df333e: function(arg0, arg1) { + arg0.readOnly = arg1 !== 0; + }, + __wbg_set_search_048cc0b716fa132a: function(arg0, arg1, arg2) { + arg0.search = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_textContent_5c5fef072bd24f7a: function(arg0, arg1, arg2) { + arg0.textContent = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); + }, + __wbg_set_value_3cd64c88136b460c: function(arg0, arg1, arg2) { + arg0.value = getStringFromWasm0(arg1, arg2); + }, + __wbg_stack_3b0d974bbf31e44f: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_static_accessor_GLOBAL_THIS_2fee5048bcca5938: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_ce44e66a4935da8c: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_44f6e0cb5e67cdad: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_168f178805d978fe: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_target_4387d5c508f1ecbd: function(arg0) { + const ret = arg0.target; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_textContent_a5497fd4d40a66b5: function(arg0, arg1) { + const ret = arg1.textContent; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_text_68ea00f7126f2706: function() { return handleError(function (arg0) { + const ret = arg0.text(); + return ret; + }, arguments); }, + __wbg_then_05edfc8a4fea5106: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, + __wbg_then_591b6b3a75ee817a: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbg_toString_73456969ad00f2fa: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_toString_b9fe6ec9b4809cee: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_url_c6a2bdc371c879a8: function(arg0, arg1) { + const ret = arg1.url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_1686b2f0ffb64a18: function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_value_18f5592a194a3ef2: function(arg0, arg1) { + const ret = arg1.value; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_view_b959961c99705680: function(arg0) { + const ret = arg0.view; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1014, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1076, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1014, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./brain_brew_workbench_ui_bg.js": import0, + }; +} + +function wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h3a340571e7e5a945_2(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__hb447bbf9b5cc1aa8(arg0, arg1, arg2); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + +function wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__hafc92e49482912e3(arg0, arg1, arg2, arg3); +} + + +const __wbindgen_enum_ReadableStreamType = ["bytes"]; +const IntoUnderlyingByteSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingbytesource_free(ptr, 1)); +const IntoUnderlyingSinkFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsink_free(ptr, 1)); +const IntoUnderlyingSourceFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_intounderlyingsource_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, f) { + const state = { a: arg0, b: arg1, cnt: 1 }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + wasm.__wbindgen_destroy_closure(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('brain_brew_workbench_ui_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06_bg.wasm b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06_bg.wasm new file mode 100644 index 0000000..e6cd2bd Binary files /dev/null and b/crates/brain-brew-cli/assets/workbench/brain_brew_workbench_ui-c7db93c79fa9aa06_bg.wasm differ diff --git a/crates/brain-brew-cli/assets/workbench/index.html b/crates/brain-brew-cli/assets/workbench/index.html new file mode 100644 index 0000000..e65fe89 --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/index.html @@ -0,0 +1,27 @@ + + + + + + Brain Brew Deck Workbench + + + + +
+ Brain Brew Deck Workbench loading workspace metadata +
+ + + + diff --git a/crates/brain-brew-cli/assets/workbench/workbench-66e33cced77ddc3f.css b/crates/brain-brew-cli/assets/workbench/workbench-66e33cced77ddc3f.css new file mode 100644 index 0000000..9e3a2a9 --- /dev/null +++ b/crates/brain-brew-cli/assets/workbench/workbench-66e33cced77ddc3f.css @@ -0,0 +1,566 @@ +:root { + color-scheme: dark; + --bg: #050811; + --bg-glow: #10264a; + --surface: #0f172a; + --surface-muted: #111827; + --surface-raised: #172033; + --surface-strong: #1f2a44; + --text: #e5edf8; + --text-strong: #f8fafc; + --muted: #97a6ba; + --muted-strong: #bac6d6; + --border: #263348; + --border-strong: #3b4a63; + --accent: #60a5fa; + --accent-strong: #3b82f6; + --accent-contrast: #031025; + --warning: #fbbf24; + --warning-bg: #30240d; + --warning-border: #8a6818; + --success: #86efac; + --success-bg: #0f2a1a; + --success-border: #1f7a3b; + --danger: #f87171; + --danger-bg: #331417; + --danger-border: #7f1d1d; + --shadow: 0 18px 50px rgb(0 0 0 / 0.35); +} + +* { box-sizing: border-box; } + +html, +body { + margin: 0; + width: 100%; + min-height: 100%; + background: radial-gradient(circle at 20% -10%, var(--bg-glow) 0, transparent 34rem), var(--bg); + color: var(--text); + font: 0.92rem/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body > *:not(#brainbrew-workbench-e2e):not(#workbench-dom-panel):not(script) { + display: none !important; +} + +[hidden] { display: none !important; } + +button, +input, +select { + font: inherit; +} + +button, +select, +input { + border: 1px solid var(--border-strong); + border-radius: 0.5rem; + background: var(--surface-muted); + color: var(--text); +} + +button { + cursor: pointer; + font-weight: 800; + padding: 0.55rem 0.8rem; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + background: var(--surface-raised); + color: var(--text-strong); +} + +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select { + padding: 0.45rem 0.6rem; +} + +input:focus, +select:focus, +button:focus-visible, +[contenteditable="true"]:focus-visible, +summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +label { + color: var(--muted-strong); + font-size: 0.86rem; + font-weight: 800; +} + +code, +pre { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +#brainbrew-workbench-e2e { + position: fixed; + top: 0.75rem; + right: 0.75rem; + z-index: 5; + max-width: min(48rem, calc(100vw - 1.5rem)); + border: 1px solid var(--border-strong); + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 88%, transparent); + box-shadow: var(--shadow); + color: var(--muted-strong); + font-size: 0.82rem; + padding: 0.35rem 0.8rem; +} + +#workbench-dom-panel { + position: static; + z-index: 2; + min-height: calc(100vh - 2rem); + overflow: visible; + width: min(1560px, calc(100% - 2rem)); + margin: 1rem auto; + border: 1px solid var(--border); + border-radius: 0.9rem; + background: color-mix(in srgb, var(--surface) 94%, black); + box-shadow: var(--shadow); + color: var(--text); + padding: 1rem; +} + +.workbench-panel__header { + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(2, minmax(12rem, 18rem)); + gap: 0.8rem; + align-items: end; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.workbench-panel__header h2 { + margin: 0; + color: var(--text-strong); + font-size: clamp(1.5rem, 3vw, 2.3rem); + letter-spacing: -0.03em; +} + +#translation-progress, +#optional-progress, +.navigation-page-summary, +.note-navigation-more, +.inline-edit-hint { + color: var(--muted); +} + +.overlay-badges, +.pivot-filters, +.card-pivot-filters, +.source-string-filters, +.source-string-global-edit { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.75rem 0; +} + +.overlay-badge, +.pivot-filters button, +.card-pivot-filters button { + display: inline-flex; + align-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + color: var(--muted-strong); + padding: 0.25rem 0.65rem; +} + +.overlay-badge.active, +.pivot-filters button.active, +.card-pivot-filters button.active { + border-color: var(--accent-strong); + background: var(--surface-raised); + color: var(--text-strong); +} + +.pane-layout-panel, +.new-language-panel, +.apply-box, +.note-navigation, +.note-card, +.card-detail, +.source-string-detail, +.lazy-pivot-panel { + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--surface-muted); + padding: 1rem; +} + +.pane-layout-panel, +.new-language-panel { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; + margin: 0.8rem 0; +} + +.pane-layout-panel h3, +.new-language-panel h3 { + width: 100%; + margin: 0; + color: var(--text-strong); +} + +.workbench-view-switch { + position: sticky; + top: 0; + z-index: 4; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 1rem -0.25rem; + padding: 0.35rem; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: blur(10px); +} + +.workbench-view-button { + background: transparent; + border-color: transparent; + color: var(--muted-strong); +} + +.workbench-view-button.active, +.workbench-view-button[aria-current="page"] { + border-color: var(--border-strong); + background: var(--surface-raised); + color: var(--text-strong); +} + +.workbench-view-stack { + display: grid; + gap: 1rem; +} + +.workbench-view.active { + display: block; +} + +#view-panel-notes.active { + display: grid; + grid-template-columns: minmax(18rem, 24rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.note-navigation { + position: sticky; + top: 4.5rem; + max-height: calc(100vh - 6rem); + overflow: auto; +} + +.note-navigation h3, +.note-card h3, +.card-detail h4, +.lazy-pivot-panel h3 { + margin: 0 0 0.75rem; + color: var(--text-strong); +} + +.note-navigation-list, +.card-list, +.source-string-list { + display: grid; + gap: 0.5rem; + list-style: none; + margin: 0; + padding: 0; +} + +.note-navigation-row, +.card-row, +.source-string-row { + width: 100%; + border: 1px solid var(--border); + border-radius: 0.6rem; + background: var(--surface); + color: var(--text); + cursor: pointer; + padding: 0.7rem; + text-align: left; +} + +.note-navigation-row strong, +.card-row strong, +.source-string-row strong { + display: block; + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-navigation-row.active, +.card-row.active, +.source-string-row.active { + border-color: var(--accent-strong); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + align-items: start; +} + +.preview-grid > section { + min-width: 0; +} + +.anki-card-preview { + border: 1px solid var(--border); + border-radius: 0.75rem; + margin: 0.7rem 0; + overflow: hidden; + background: #030712; +} + +.card-template-header { + display: flex; + gap: 0.6rem; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + background: var(--surface-raised); + padding: 0.45rem 0.65rem; +} + +.card-template-label { + color: var(--text-strong); + font-weight: 900; +} + +.card-template-header code { + max-width: 50%; + overflow: hidden; + border-radius: 999px; + background: var(--surface-strong); + color: var(--muted-strong); + font-size: 0.72rem; + padding: 0.15rem 0.45rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-side { + display: grid; + gap: 0.45rem; + padding: 0.75rem; +} + +.card-side + .card-side { + border-top: 1px solid var(--border); +} + +.card-side > strong { + color: var(--muted-strong); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.inline-target-field, +.card-inline-target-field { + border-radius: 0.35rem; + box-shadow: inset 0 -0.16rem 0 color-mix(in srgb, var(--accent) 45%, transparent); + cursor: text; + padding: 0.05rem 0.12rem; +} + +.inline-target-field:hover, +.card-inline-target-field:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); +} + +.inline-target-field[data-staged="true"], +.card-inline-target-field[data-staged="true"] { + background: var(--success-bg); + box-shadow: 0 0 0 2px var(--success-border); + color: var(--text-strong); +} + +.inline-target-field[contenteditable="false"], +.card-inline-target-field[contenteditable="false"] { + cursor: not-allowed; + opacity: 0.7; +} + +.field-editor-details { + margin-top: 1rem; + border: 1px solid var(--border); + border-radius: 0.65rem; + background: var(--surface); +} + +.field-editor-details summary { + cursor: pointer; + color: var(--text-strong); + font-weight: 900; + padding: 0.75rem; +} + +.field-editor, +.card-field-editor, +.source-string-occurrences { + width: 100%; + border-collapse: collapse; +} + +.field-editor th, +.field-editor td, +.card-field-editor th, +.card-field-editor td, +.source-string-occurrences th, +.source-string-occurrences td { + border-top: 1px solid var(--border); + padding: 0.5rem; + text-align: left; + vertical-align: top; +} + +.field-editor th, +.card-field-editor th, +.source-string-occurrences th { + color: var(--muted-strong); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.field-editor input, +.card-field-editor input, +.source-string-occurrences input, +.source-string-global-edit input { + width: 100%; + min-width: 12rem; +} + +.card-grid, +.source-string-grid { + display: grid; + grid-template-columns: minmax(16rem, 24rem) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.card-list, +.source-string-list { + max-height: 42rem; + overflow: auto; +} + +.card-detail-panel, +.source-string-detail { + min-width: 0; +} + +.lazy-pivot-panel { + border-style: dashed; + background: color-mix(in srgb, var(--surface-muted) 88%, var(--accent-contrast)); +} + +.lazy-pivot-panel[data-lazy-state="loading"] { + opacity: 0.82; +} + +.lazy-pivot-panel[data-lazy-state="loaded"] { + border-style: solid; + background: transparent; + padding: 0; +} + +.lazy-pivot-load-button, +#apply-preview-button, +#apply-confirm-button, +#load-secondary-pane, +#new-language-preview-button, +#new-language-confirm-button, +#source-string-direct-stage, +#source-string-no-change { + border-color: var(--accent-strong); + background: var(--accent-strong); + color: var(--text-strong); +} + +.lazy-pivot-load-button { + display: block; + width: min(100%, 28rem); +} + +.apply-box { + margin-top: 1rem; +} + +#apply-preview-output { + margin-top: 0.75rem; + max-height: 18rem; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid var(--border); + border-radius: 0.6rem; + background: #030712; + color: var(--text-strong); + padding: 0.8rem; +} + +.workbench-error { + border: 1px solid var(--danger-border); + border-radius: 0.6rem; + background: var(--danger-bg); + color: #fecaca; + padding: 0.75rem; +} + +.workbench-loading { + color: var(--muted-strong); +} + +img { + max-width: 100%; + height: auto; +} + +@media (max-width: 980px) { + .workbench-panel__header, + #view-panel-notes.active, + .preview-grid, + .card-grid, + .source-string-grid { + grid-template-columns: 1fr; + } + + .note-navigation { + position: static; + max-height: none; + } + + #brainbrew-workbench-e2e { + position: static; + margin: 0.75rem auto 0; + width: min(100% - 1rem, 48rem); + } +} diff --git a/crates/brain-brew-cli/src/args.rs b/crates/brain-brew-cli/src/args.rs new file mode 100644 index 0000000..466928a --- /dev/null +++ b/crates/brain-brew-cli/src/args.rs @@ -0,0 +1,363 @@ +use std::path::PathBuf; + +use brain_brew_core::{OverlayKind, StableId}; +use brain_brew_formats::manifest::TranslationCoveragePolicy; + +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) translation_coverage: Option, + pub(crate) skip_content_validation: bool, +} + +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 translation_coverage = None; + let mut skip_content_validation = false; + 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; + } + "--translation-coverage" => { + let Some(policy) = args.get(index + 1) else { + return Err("--translation-coverage requires lenient or strict".to_owned()); + }; + translation_coverage = Some(parse_translation_coverage_policy(policy)?); + index += 2; + } + "--skip-content-validation" => { + skip_content_validation = true; + index += 1; + } + 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, + translation_coverage, + skip_content_validation, + }) +} + +fn parse_translation_coverage_policy(value: &str) -> Result { + match value { + "lenient" => Ok(TranslationCoveragePolicy::Lenient), + "strict" => Ok(TranslationCoveragePolicy::Strict), + other => Err(format!( + "invalid translation coverage policy {other:?}; expected lenient or strict" + )), + } +} + +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 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..a838b88 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/compose.rs @@ -0,0 +1,88 @@ +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::commands::verify; +use crate::help; +use crate::io::{plan_manifest_target_with_packages, read_deck_and_overlays}; +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 plan = plan_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + verify::emit_stale_translation_warnings(&plan)?; + let deck = plan.compose()?; + 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]", + )); + } + if args[0].starts_with('-') { + return Err(format!("unexpected argument {:?}", args[0])); + } + let deck_path = Path::new(&args[0]); + let (overlay_paths, out_path) = parse_overlay_and_optional_out(&args[1..])?; + let (base, overlays) = read_deck_and_overlays(deck_path, &overlay_paths)?; + verify::emit_stale_translation_warnings_for_overlays("ad-hoc", &base, &overlays)?; + let deck = base + .compose( + &overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + .map_err(|error| error.to_string())?; + 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..5ce5cd0 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/diff.rs @@ -0,0 +1,36 @@ +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 let Some(arg) = paths.iter().find(|arg| arg.starts_with('-')) { + return Err(format!("unexpected argument {arg:?}")); + } + 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..50e052f --- /dev/null +++ b/crates/brain-brew-cli/src/commands/explain.rs @@ -0,0 +1,119 @@ +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::{self, 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::>(); + output::print_json_error_value(json!({ + "message": "target composition failed", + "errors": errors, + "package": plan.package.as_ref().map(package_json), + "target": plan.target, + "base": plan.base_label, + "overlay_stack": overlay_stack, + })); + Err(output::JSON_ERROR_ALREADY_PRINTED.to_owned()) + } 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..141d939 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/export.rs @@ -0,0 +1,121 @@ +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::commands::verify; +use crate::help; +use crate::io::{ + configured_crowdanki_out, manifest_root, plan_manifest_target_with_packages, + read_deck_and_overlays, 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 plan = plan_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + verify::emit_stale_translation_warnings(&plan)?; + let deck = plan.compose()?; + 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 (base, overlays) = read_deck_and_overlays(deck_path, &export_args.overlay_paths)?; + verify::emit_stale_translation_warnings_for_overlays("ad-hoc", &base, &overlays)?; + let deck = base + .compose( + &overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(), + ) + .map_err(|error| error.to_string())?; + 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..e3613b8 --- /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_at; +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_at(path, &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..0ef3d9a --- /dev/null +++ b/crates/brain-brew-cli/src/commands/lock.rs @@ -0,0 +1,1107 @@ +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 fetch_requested = args.source.to_fetch_source()?; + let fetched = fetch_source(&fetch_requested, None)?; + let requested = args.source.to_requested_source(&args.lock_path)?; + 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_for_verify(&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_fetch_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.to_requested_source(Path::new(".")), + } + } + + fn to_requested_source(&self, lock_path: &Path) -> Result { + match self { + Self::Path(path) => { + let path = path_for_lock(path, 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 { + fetch_locked_source_with_mode(lock_path, package_id, source, FetchLockedMode::UseCache) +} + +fn fetch_locked_source_for_verify( + lock_path: &Path, + package_id: &str, + source: &LockedSource, +) -> Result { + fetch_locked_source_with_mode( + lock_path, + package_id, + source, + FetchLockedMode::VerifyLivePath, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FetchLockedMode { + UseCache, + VerifyLivePath, +} + +fn fetch_locked_source_with_mode( + lock_path: &Path, + package_id: &str, + source: &LockedSource, + mode: FetchLockedMode, +) -> Result { + let expected_hash = source.nar_hash.as_deref(); + if !(mode == FetchLockedMode::VerifyLivePath && source.source_type == "path") + && 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 path_for_lock(path: &Path, lock_path: &Path) -> Result { + let canonical_path = canonicalize_for_lock(path)?; + let lock_parent = lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_lock_parent = canonicalize_for_lock(lock_parent)?; + Ok(relative_path_between(&canonical_lock_parent, &canonical_path).unwrap_or(canonical_path)) +} + +fn canonicalize_for_lock(path: &Path) -> Result { + path.canonicalize() + .map_err(|error| format!("{}: {error}", path.display())) +} + +fn relative_path_between(base: &Path, target: &Path) -> Option { + let base_components = base.components().collect::>(); + let target_components = target.components().collect::>(); + let common_len = base_components + .iter() + .zip(&target_components) + .take_while(|(base, target)| base == target) + .count(); + if common_len == 0 { + return None; + } + + let mut relative = PathBuf::new(); + for component in &base_components[common_len..] { + match component { + std::path::Component::Normal(_) => relative.push(".."), + _ => return None, + } + } + for component in &target_components[common_len..] { + relative.push(component.as_os_str()); + } + if relative.as_os_str().is_empty() { + relative.push("."); + } + Some(relative) +} + +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") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_repo_parses_only_plain_github_repo_urls() { + let repo = GithubRepo::parse("https://github.com/anki-geo/ultimate-geography.git") + .expect("GitHub HTTPS repo URL parses"); + assert_eq!(repo.owner, "anki-geo"); + assert_eq!(repo.name, "ultimate-geography"); + + let http_repo = GithubRepo::parse("http://github.com/anki-geo/ultimate-geography") + .expect("GitHub HTTP repo URL parses"); + assert_eq!(http_repo.owner, "anki-geo"); + assert_eq!(http_repo.name, "ultimate-geography"); + + assert!( + GithubRepo::parse("https://github.com/anki-geo/ultimate-geography/tree/main").is_none() + ); + assert!(GithubRepo::parse("https://example.com/anki-geo/ultimate-geography.git").is_none()); + } + + #[test] + fn github_repo_builds_codeload_and_percent_encoded_api_urls() { + let repo = GithubRepo { + owner: "anki geo".to_owned(), + name: "ultimate/geography".to_owned(), + }; + + assert_eq!( + repo.codeload_tarball_url("abc123"), + "https://codeload.github.com/anki geo/ultimate/geography/tar.gz/abc123" + ); + assert_eq!( + repo.commit_api_url("feature/deck lock"), + "https://api.github.com/repos/anki geo/ultimate/geography/commits/feature%2Fdeck%20lock" + ); + } + + #[test] + fn non_github_git_url_reports_native_locking_error() { + let source = RequestedSource { + source_type: "git".to_owned(), + url: Some("https://example.com/anki-geo/ultimate-geography.git".to_owned()), + path: None, + reference: None, + rev: Some("abc123".to_owned()), + }; + + let error = fetch_git_source(&source, Some("sha256-example")) + .expect_err("non-GitHub URL is rejected before fetch"); + assert!(error.contains("native git locking currently supports GitHub HTTPS URLs")); + } + + #[test] + fn should_skip_source_entry_filters_vcs_build_and_nix_result_paths() { + for name in [ + ".git", + ".jj", + ".hg", + ".svn", + "target", + "result", + "result-doc", + ] { + assert!(should_skip_source_entry(name), "{name} should be skipped"); + } + for name in ["deck.yaml", "results", "target-notes", ".github"] { + assert!(!should_skip_source_entry(name), "{name} should be included"); + } + } + + #[test] + fn relative_path_between_sibling_checkout_paths_uses_dot_dot() { + let base = Path::new("/workspace/consumer"); + let target = Path::new("/workspace/package"); + + assert_eq!( + relative_path_between(base, target).unwrap(), + PathBuf::from("../package") + ); + } + + #[test] + fn verify_locked_manifest_metadata_reports_version_mismatch() { + let dir = TempDir::new().expect("temp dir"); + let manifest_path = dir.path().join("brainbrew.yaml"); + fs::write( + &manifest_path, + r#"package: + id: anki-geo.ultimate-geography + version: 0.2.0 +base: deck.yaml +overlays: {} +targets: {} +"#, + ) + .expect("write manifest"); + let package = LockedPackage { + manifest: "brainbrew.yaml".to_owned(), + package: LockedPackageMetadata { + version: "0.1.0".to_owned(), + }, + original: None, + locked: LockedSource { + source_type: "path".to_owned(), + url: None, + path: Some(".".to_owned()), + reference: None, + rev: None, + nar_hash: None, + }, + }; + + let error = verify_locked_manifest_metadata( + "anki-geo.ultimate-geography", + &package, + &manifest_path, + ) + .expect_err("version mismatch is rejected"); + assert!(error.contains("version mismatch")); + } + + #[test] + fn read_lock_reports_missing_and_corrupt_lock_files() { + let dir = TempDir::new().expect("temp dir"); + let missing = dir.path().join("missing.lock"); + let missing_error = read_lock(&missing).expect_err("missing lock file is rejected"); + assert!(missing_error.contains("missing.lock")); + + let corrupt = dir.path().join("brainbrew.lock"); + fs::write(&corrupt, "version: [\n").expect("write corrupt lock"); + let corrupt_error = read_lock(&corrupt).expect_err("corrupt lock file is rejected"); + assert!(corrupt_error.contains("failed to parse lock YAML")); + } +} diff --git a/crates/brain-brew-cli/src/commands/media.rs b/crates/brain-brew-cli/src/commands/media.rs new file mode 100644 index 0000000..b0548cc --- /dev/null +++ b/crates/brain-brew-cli/src/commands/media.rs @@ -0,0 +1,628 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use brain_brew_formats::{media, media_map}; +use serde_yaml::{Mapping, Value}; + +use crate::help; +use crate::io::{ + format_source_at, manifest_root, plan_manifest_target_with_packages, read_manifest, + resolve_include_target_for_context, root_relative_path, source_context_for_path, + top_level_media_include_path, +}; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if matches!(args, [flag] if flag == "--help" || flag == "-h") + || matches!(args, [_, flag] if flag == "--help" || flag == "-h") + { + print!("{}", help::command("media").expect("media help exists")); + return Ok(()); + } + + match args.first().map(String::as_str) { + Some("hash") => run_hash(&args[1..]), + Some("images-to-refs") => run_images_to_refs(&args[1..]), + _ => Err(help::usage_error( + "media", + "usage: brainbrew media hash|images-to-refs --manifest brainbrew.yaml (--all-targets | --target )", + )), + } +} + +fn run_hash(args: &[String]) -> Result<(), String> { + let hash_args = parse_media_hash_args(args)?; + let root = manifest_root(&hash_args.manifest_path); + let media_root = root_relative_path(&root, &hash_args.media_root); + let (target_names, source_files) = collect_manifest_source_files( + &hash_args.manifest_path, + hash_args.target.as_deref(), + hash_args.all_targets, + &hash_args.include_paths, + &hash_args.package_roots, + )?; + + let mut changed_entries = 0usize; + let mut changed_files = 0usize; + for path in source_files { + let input = + fs::read_to_string(&path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", path.display()))?; + let (updated_path, updates) = + update_media_hashes_for_source(&path, &mut value, &media_root)?; + if updates == 0 { + continue; + } + match updated_path { + MediaHashUpdateTarget::Source => { + let updated_yaml = serde_yaml::to_string(&value) + .map_err(|error| format!("{}: {error}", path.display()))?; + let formatted = format_source_at(&path, &updated_yaml) + .map_err(|error| format!("{}: {error}", path.display()))?; + fs::write(&path, formatted) + .map_err(|error| format!("{}: {error}", path.display()))?; + } + MediaHashUpdateTarget::IncludedMedia(media_path, formatted) => { + fs::write(&media_path, formatted) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + } + } + changed_entries += updates; + changed_files += 1; + } + + output::print_success( + "updated media hashes", + &[ + ("manifest", hash_args.manifest_path.display().to_string()), + ("media root", media_root.display().to_string()), + ("targets", target_names.len().to_string()), + ("files changed", changed_files.to_string()), + ("entries changed", changed_entries.to_string()), + ], + ); + Ok(()) +} + +fn run_images_to_refs(args: &[String]) -> Result<(), String> { + let migration_args = parse_media_migration_args(args)?; + let (target_names, source_files) = collect_manifest_source_files( + &migration_args.manifest_path, + migration_args.target.as_deref(), + migration_args.all_targets, + &migration_args.include_paths, + &migration_args.package_roots, + )?; + let media_path_lookup = media_path_lookup_from_sources(&source_files)?; + + let mut report = ImageMigrationReport::default(); + for path in source_files { + let input = + fs::read_to_string(&path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", path.display()))?; + let before = report.converted; + migrate_image_fields_in_source(&mut value, &media_path_lookup, &mut report); + if report.converted == before { + continue; + } + let updated_yaml = serde_yaml::to_string(&value) + .map_err(|error| format!("{}: {error}", path.display()))?; + let formatted = format_source_at(&path, &updated_yaml) + .map_err(|error| format!("{}: {error}", path.display()))?; + fs::write(&path, formatted).map_err(|error| format!("{}: {error}", path.display()))?; + report.files_changed += 1; + } + + output::print_success( + "converted strict image fields to !image references", + &[ + ( + "manifest", + migration_args.manifest_path.display().to_string(), + ), + ("targets", target_names.len().to_string()), + ("files changed", report.files_changed.to_string()), + ("converted fields", report.converted.to_string()), + ( + "skipped non-strict image fields", + report.skipped_non_strict.to_string(), + ), + ( + "skipped no media match", + report.skipped_no_match.to_string(), + ), + ( + "skipped ambiguous media path", + report.skipped_ambiguous_path.to_string(), + ), + ], + ); + Ok(()) +} + +struct MediaHashArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + media_root: PathBuf, + include_paths: Vec, + package_roots: Vec, +} + +struct MediaMigrationArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + include_paths: Vec, + package_roots: Vec, +} + +fn parse_media_hash_args(args: &[String]) -> Result { + let mut parsed = parse_media_manifest_args(args, "media hash")?; + let Some(media_root) = parsed.media_root.take() else { + return Err("media hash requires --media-root".to_owned()); + }; + Ok(MediaHashArgs { + manifest_path: parsed.manifest_path, + target: parsed.target, + all_targets: parsed.all_targets, + media_root, + include_paths: parsed.include_paths, + package_roots: parsed.package_roots, + }) +} + +fn parse_media_migration_args(args: &[String]) -> Result { + let parsed = parse_media_manifest_args(args, "media images-to-refs")?; + if parsed.media_root.is_some() { + return Err("media images-to-refs does not use --media-root".to_owned()); + } + Ok(MediaMigrationArgs { + manifest_path: parsed.manifest_path, + target: parsed.target, + all_targets: parsed.all_targets, + include_paths: parsed.include_paths, + package_roots: parsed.package_roots, + }) +} + +struct ParsedMediaManifestArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + media_root: Option, + include_paths: Vec, + package_roots: Vec, +} + +fn parse_media_manifest_args( + args: &[String], + command_name: &str, +) -> 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 {command_name} argument {other:?}")), + } + } + if all_targets && target.is_some() { + return Err("choose --all-targets or --target, not both".to_owned()); + } + Ok(ParsedMediaManifestArgs { + manifest_path: manifest_path.unwrap_or_else(|| PathBuf::from("brainbrew.yaml")), + target, + all_targets, + media_root, + include_paths, + package_roots, + }) +} + +fn collect_manifest_source_files( + manifest_path: &Path, + target: Option<&str>, + all_targets: bool, + include_paths: &[PathBuf], + package_roots: &[PathBuf], +) -> Result<(Vec, BTreeSet), String> { + let manifest = read_manifest(manifest_path)?; + let root = manifest_root(manifest_path); + let target_names = if all_targets { + manifest.targets.keys().cloned().collect::>() + } else if let Some(target) = target { + vec![target.to_owned()] + } else { + return Err("media command requires --all-targets or --target ".to_owned()); + }; + + let mut source_files = BTreeSet::from([root.join(&manifest.base)]); + for target in &target_names { + let plan = plan_manifest_target_with_packages( + manifest_path, + target, + include_paths, + package_roots, + )?; + for (overlay, _) in plan.overlays { + source_files.insert(overlay.file); + } + } + + Ok((target_names, source_files)) +} + +enum MediaHashUpdateTarget { + Source, + IncludedMedia(PathBuf, String), +} + +fn update_media_hashes_for_source( + path: &Path, + value: &mut Value, + media_root: &Path, +) -> Result<(MediaHashUpdateTarget, usize), String> { + if let Some(include_path) = top_level_media_include_path(value) + .map_err(|error| format!("{}: {error}", path.display()))? + { + let context = source_context_for_path(path)?; + let media_path = resolve_include_target_for_context(&include_path, &context)?; + let input = fs::read_to_string(&media_path) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + media_map::from_str(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + let mut media_value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + let Some(media_entries) = media_value.as_mapping_mut() else { + return Err(format!( + "{}: media map root must be a mapping", + media_path.display() + )); + }; + let updates = update_media_hashes_in_mapping(media_entries, media_root)?; + if updates == 0 { + return Ok((MediaHashUpdateTarget::IncludedMedia(media_path, input), 0)); + } + let updated_yaml = serde_yaml::to_string(&media_value) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + let formatted = format_source_at(&media_path, &updated_yaml) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + return Ok(( + MediaHashUpdateTarget::IncludedMedia(media_path, formatted), + updates, + )); + } + + let updates = update_media_hashes_in_value(value, media_root)?; + Ok((MediaHashUpdateTarget::Source, updates)) +} + +fn update_media_hashes_in_value(value: &mut Value, media_root: &Path) -> Result { + let Some(media_entries) = value + .as_mapping_mut() + .and_then(|mapping| mapping.get_mut(Value::String("media".to_owned()))) + .and_then(Value::as_mapping_mut) + else { + return Ok(0); + }; + update_media_hashes_in_mapping(media_entries, media_root) +} + +fn update_media_hashes_in_mapping( + media_entries: &mut Mapping, + media_root: &Path, +) -> Result { + let mut updates = 0; + for (_id, entry) in media_entries { + let Some(entry) = entry.as_mapping_mut() else { + continue; + }; + let Some(path) = string_field(entry, "path") else { + continue; + }; + let relative_path = safe_media_relative_path(&path)?; + let full_path = media_root.join(relative_path); + let bytes = + fs::read(&full_path).map_err(|error| format!("{}: {error}", full_path.display()))?; + let actual = media::sha256_hex(&bytes); + let key = Value::String("sha256".to_owned()); + if entry.get(&key).and_then(Value::as_str) != Some(actual.as_str()) { + entry.insert(key, Value::String(actual)); + updates += 1; + } + } + Ok(updates) +} + +#[derive(Default)] +struct ImageMigrationReport { + converted: usize, + files_changed: usize, + skipped_non_strict: usize, + skipped_no_match: usize, + skipped_ambiguous_path: usize, +} + +fn media_path_lookup_from_sources( + source_files: &BTreeSet, +) -> Result>, String> { + let mut lookup = BTreeMap::new(); + for path in source_files { + let input = + fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", path.display()))?; + collect_media_paths_from_source(path, &value, &mut lookup)?; + } + Ok(lookup) +} + +fn collect_media_paths_from_source( + path: &Path, + value: &Value, + lookup: &mut BTreeMap>, +) -> Result<(), String> { + if let Some(include_path) = top_level_media_include_path(value) + .map_err(|error| format!("{}: {error}", path.display()))? + { + let context = source_context_for_path(path)?; + let media_path = resolve_include_target_for_context(&include_path, &context)?; + let input = fs::read_to_string(&media_path) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + media_map::from_str(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + let media_value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + let Some(media_entries) = media_value.as_mapping() else { + return Err(format!( + "{}: media map root must be a mapping", + media_path.display() + )); + }; + collect_media_paths_from_mapping(media_entries, lookup); + return Ok(()); + } + + collect_media_paths_from_value(value, lookup); + Ok(()) +} + +fn collect_media_paths_from_value(value: &Value, lookup: &mut BTreeMap>) { + let Some(media_entries) = value + .as_mapping() + .and_then(|mapping| mapping.get(Value::String("media".to_owned()))) + .and_then(Value::as_mapping) + else { + return; + }; + collect_media_paths_from_mapping(media_entries, lookup); +} + +fn collect_media_paths_from_mapping( + media_entries: &Mapping, + lookup: &mut BTreeMap>, +) { + for (id, entry) in media_entries { + let (Some(id), Some(entry)) = (id.as_str(), entry.as_mapping()) else { + continue; + }; + let Some(path) = string_field(entry, "path") else { + continue; + }; + lookup + .entry(path) + .and_modify(|existing| { + if existing.as_deref() != Some(id) { + *existing = None; + } + }) + .or_insert_with(|| Some(id.to_owned())); + } +} + +fn migrate_image_fields_in_source( + value: &mut Value, + media_path_lookup: &BTreeMap>, + report: &mut ImageMigrationReport, +) { + let Some(root) = value.as_mapping_mut() else { + return; + }; + + if let Some(notes) = mapping_value_mut(root, "notes").and_then(Value::as_mapping_mut) { + for (_note_id, note_value) in notes { + migrate_note_like_value(note_value, media_path_lookup, report); + } + } + + if let Some(field_additions) = + mapping_value_mut(root, "field_additions").and_then(Value::as_mapping_mut) + { + for (_note_type_id, additions_value) in field_additions { + let Some(additions) = additions_value.as_mapping_mut() else { + continue; + }; + let Some(values) = + mapping_value_mut(additions, "values").and_then(Value::as_mapping_mut) + else { + continue; + }; + for (_note_id, fields_value) in values { + migrate_field_mapping(fields_value, media_path_lookup, report); + } + } + } + + if let Some(field_fills) = + mapping_value_mut(root, "field_fills").and_then(Value::as_mapping_mut) + { + for (_note_id, fields_value) in field_fills { + migrate_field_mapping(fields_value, media_path_lookup, report); + } + } +} + +fn migrate_note_like_value( + note_value: &mut Value, + media_path_lookup: &BTreeMap>, + report: &mut ImageMigrationReport, +) { + let Some(note) = note_value.as_mapping_mut() else { + return; + }; + + if let Some(added_note) = mapping_value_mut(note, "note").and_then(Value::as_mapping_mut) + && let Some(fields) = mapping_value_mut(added_note, "fields") + { + migrate_field_mapping(fields, media_path_lookup, report); + } + + if let Some(fields) = mapping_value_mut(note, "fields") { + migrate_field_mapping(fields, media_path_lookup, report); + } +} + +fn migrate_field_mapping( + fields_value: &mut Value, + media_path_lookup: &BTreeMap>, + report: &mut ImageMigrationReport, +) { + let Some(fields) = fields_value.as_mapping_mut() else { + return; + }; + for (_field_id, field_value) in fields { + if let Some(field_change) = field_value.as_mapping_mut() { + if let Some(value) = mapping_value_mut(field_change, "value") { + migrate_one_field_value(value, media_path_lookup, report); + } + } else { + migrate_one_field_value(field_value, media_path_lookup, report); + } + } +} + +fn migrate_one_field_value( + value: &mut Value, + media_path_lookup: &BTreeMap>, + report: &mut ImageMigrationReport, +) { + let Some(text) = value.as_str() else { + return; + }; + let Some(paths) = media::strict_image_tag_paths(text) else { + if text.trim().contains(" media_ids.push(media_id.clone()), + Some(None) => ambiguous = true, + None => missing = true, + } + } + + if missing { + report.skipped_no_match += 1; + return; + } + if ambiguous { + report.skipped_ambiguous_path += 1; + return; + } + + *value = image_reference_value(&media_ids); + report.converted += 1; +} + +fn image_reference_value(media_ids: &[String]) -> Value { + match media_ids { + [media_id] => tagged_image_value(media_id), + _ => Value::Sequence( + media_ids + .iter() + .map(|media_id| tagged_image_value(media_id)) + .collect(), + ), + } +} + +fn tagged_image_value(media_id: &str) -> Value { + serde_yaml::from_str(&format!("!image {media_id}")) + .expect("stable media IDs are valid !image scalar values") +} + +fn mapping_value_mut<'a>(mapping: &'a mut Mapping, key: &str) -> Option<&'a mut Value> { + mapping.get_mut(Value::String(key.to_owned())) +} + +fn string_field(mapping: &Mapping, key: &str) -> Option { + mapping + .get(Value::String(key.to_owned())) + .and_then(Value::as_str) + .map(str::to_owned) +} + +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/commands/mod.rs b/crates/brain-brew-cli/src/commands/mod.rs new file mode 100644 index 0000000..75e8fcc --- /dev/null +++ b/crates/brain-brew-cli/src/commands/mod.rs @@ -0,0 +1,14 @@ +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 media; +pub(crate) mod targets; +pub(crate) mod translation_overlay; +pub(crate) mod translations; +pub(crate) mod validate; +pub(crate) mod verify; +pub(crate) mod workbench; 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/translation_overlay.rs b/crates/brain-brew-cli/src/commands/translation_overlay.rs new file mode 100644 index 0000000..76fbc70 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/translation_overlay.rs @@ -0,0 +1,66 @@ +use brain_brew_core::{CanonicalDeck, Overlay, TranslationCoverageCategory}; + +pub(crate) fn compose_lenient_translation_overlay( + current: &CanonicalDeck, + overlay: &Overlay, +) -> Result { + let mut sanitized = overlay.clone(); + if let Some(translations) = &mut sanitized.translations { + translations.require_complete = false; + if let Some(report) = current.translation_coverage(overlay) { + for entry in report.entries { + match entry.category { + TranslationCoverageCategory::StaleDirectKey => { + translations.direct.remove(&entry.source); + } + TranslationCoverageCategory::StaleContextualKey => { + if let Some(context) = &entry.context + && let Some(replacements) = translations.contextual.get_mut(context) + { + replacements.remove(&entry.source); + if replacements.is_empty() { + translations.contextual.remove(context); + } + } + } + TranslationCoverageCategory::StaleNoChangeKey => { + translations.no_change.remove(&entry.source); + } + TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::InvalidTargetAdaptation => { + let path = entry.context.as_deref().unwrap_or(&entry.path); + translations.target_adaptations.remove(path); + } + TranslationCoverageCategory::StaleVariableKey => { + if let Some(variable_key) = &entry.context + && let Some(replacements) = translations.variables.get_mut(variable_key) + { + replacements.remove(&entry.source); + if replacements.is_empty() { + translations.variables.remove(variable_key); + } + } + } + TranslationCoverageCategory::StaleAdapterIdKey => { + if let Some(adapter_key) = &entry.context + && let Some(replacements) = + translations.adapter_ids.get_mut(adapter_key) + { + replacements.remove(&entry.source); + if replacements.is_empty() { + translations.adapter_ids.remove(adapter_key); + } + } + } + _ => {} + } + } + } + } + current.compose(&[sanitized]).map_err(|error| { + format!( + "failed to compose translation overlay {}: {error}", + overlay.id + ) + }) +} diff --git a/crates/brain-brew-cli/src/commands/translations.rs b/crates/brain-brew-cli/src/commands/translations.rs new file mode 100644 index 0000000..0ac05ee --- /dev/null +++ b/crates/brain-brew-cli/src/commands/translations.rs @@ -0,0 +1,3346 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read, Write}; +use std::path::{Path, PathBuf}; + +use brain_brew_core::{ + Overlay, OverlayKind, TranslationContextUnit, TranslationContextView, + TranslationCoverageCategory, TranslationCoverageEntry, TranslationCoverageReport, + TranslationDictionary, TranslationMessageContext, +}; +use brain_brew_formats::manifest::FederatedDeckManifest; +use brain_brew_formats::yaml_scalar::scalar as yaml_scalar; +use brain_brew_formats::{canonical_yaml, source_includes}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use serde_json::json; + +use crate::commands::translation_overlay::compose_lenient_translation_overlay; +use crate::help; +use crate::io::{PlannedOverlay, plan_manifest_target_with_packages, read_manifest}; +use crate::output; + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!( + "{}", + help::command("translations").expect("translations help exists") + ); + return Ok(()); + } + + let mut args = parse_translation_args(args)?; + let interactive = should_use_interactive(&args); + if !args.manifest_path.exists() && !interactive { + return Err(missing_manifest_error(&args.manifest_path)); + } + + if interactive { + let raw_mode = io::stdin().is_terminal() && io::stdout().is_terminal(); + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + configure_interactively(&mut args, &mut reader, &mut writer, raw_mode)?; + } + + let reports = collect_translation_reports(&args)?; + + if args.resolve_action.is_some() { + let applied = resolve_stale_translations(&args, &reports)?; + if args.json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "resolved": applied })) + .expect("stale resolution JSON serializes") + ); + } else { + let total = applied.values().sum::(); + let noun = if total == 1 { + "translation" + } else { + "translations" + }; + let details = applied + .iter() + .map(|(file, count)| (file.as_str(), count.to_string())) + .collect::>(); + output::print_success(format!("resolved {total} stale {noun}"), &details); + } + return Ok(()); + } + + let mut edits_by_file = BTreeMap::::new(); + if args.apply { + if interactive { + let raw_mode = io::stdin().is_terminal() && io::stdout().is_terminal(); + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = stdin.lock(); + let mut writer = stdout.lock(); + edits_by_file = + prompt_selective_apply(&reports, &mut reader, &mut writer, raw_mode, args.full)?; + } else { + edits_by_file = direct_stub_edits_from_reports(&reports, args.full); + } + } + + let mut applied = BTreeMap::new(); + for (path, edits) in &edits_by_file { + let count = apply_overlay_edits(path, edits)?; + if count > 0 { + applied.insert(path.display().to_string(), count); + } + } + + if args.context { + if args.json_output { + print_json_contexts(&reports); + } else if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_contexts(&reports, args.full); + } + } else if args.summary { + if args.json_output { + print_json_summary(&reports); + } else if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_summary(&reports, args.full); + } + } else if args.json_output { + print_json_reports(&reports, &applied); + } else { + if reports.is_empty() { + print_no_translation_reports(&args)?; + } else { + print_human_reports(&reports, args.full); + } + if args.apply { + let total = applied.values().sum::(); + let details = applied + .iter() + .map(|(file, count)| (file.as_str(), count.to_string())) + .collect::>(); + output::print_success(format!("applied {total} translation edit(s)"), &details); + } + } + + Ok(()) +} + +#[derive(Clone)] +struct TranslationArgs { + manifest_path: PathBuf, + target: Option, + all_targets: bool, + include_paths: Vec, + package_roots: Vec, + language: Option, + overlay: Option, + note: Option, + field: Option, + path_prefixes: Vec, + apply: bool, + json_output: bool, + interactive: Option, + full: bool, + summary: bool, + context: bool, + source: Option, + duplicates: bool, + status: Option, + resolve_action: Option, + old_source: Option, + new_source: Option, + stale_context: Option, + replacement_translation: Option, +} + +fn parse_translation_args(args: &[String]) -> Result { + let mut parsed = TranslationArgs { + manifest_path: PathBuf::from("brainbrew.yaml"), + target: None, + all_targets: false, + include_paths: Vec::new(), + package_roots: Vec::new(), + language: None, + overlay: None, + note: None, + field: None, + path_prefixes: Vec::new(), + apply: false, + json_output: false, + interactive: None, + full: false, + summary: false, + context: false, + source: None, + duplicates: false, + status: None, + resolve_action: None, + old_source: None, + new_source: None, + stale_context: None, + replacement_translation: None, + }; + 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()); + }; + parsed.manifest_path = PathBuf::from(path); + index += 2; + } + "--target" => { + let Some(name) = args.get(index + 1) else { + return Err("--target requires a name".to_owned()); + }; + parsed.target = Some(name.clone()); + index += 2; + } + "--all-targets" => { + parsed.all_targets = true; + index += 1; + } + "--include" => { + let Some(path) = args.get(index + 1) else { + return Err("--include requires a path".to_owned()); + }; + parsed.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()); + }; + parsed.package_roots.push(PathBuf::from(path)); + index += 2; + } + "--language" => { + let Some(language) = args.get(index + 1) else { + return Err("--language requires a code".to_owned()); + }; + parsed.language = Some(language.clone()); + index += 2; + } + "--overlay" => { + let Some(overlay) = args.get(index + 1) else { + return Err("--overlay requires an id or file fragment".to_owned()); + }; + parsed.overlay = Some(overlay.clone()); + index += 2; + } + "--note" => { + let Some(note) = args.get(index + 1) else { + return Err("--note requires a note id".to_owned()); + }; + parsed.note = Some(note.clone()); + index += 2; + } + "--field" => { + let Some(field) = args.get(index + 1) else { + return Err("--field requires a field id".to_owned()); + }; + parsed.field = Some(field.clone()); + index += 2; + } + "--path-prefix" => { + let Some(prefix) = args.get(index + 1) else { + return Err("--path-prefix requires a deck path prefix".to_owned()); + }; + parsed.path_prefixes.push(prefix.clone()); + index += 2; + } + "--apply" => { + parsed.apply = true; + index += 1; + } + "--json" => { + parsed.json_output = true; + index += 1; + } + "--full" => { + parsed.full = true; + index += 1; + } + "--summary" => { + parsed.summary = true; + index += 1; + } + "--context" => { + parsed.context = true; + index += 1; + } + "--source" => { + let Some(source) = args.get(index + 1) else { + return Err("--source requires text to match".to_owned()); + }; + parsed.source = Some(source.clone()); + index += 2; + } + "--duplicates" => { + parsed.duplicates = true; + index += 1; + } + "--status" => { + let Some(status) = args.get(index + 1) else { + return Err("--status requires a status filter".to_owned()); + }; + validate_status_filter(status)?; + parsed.status = Some(status.clone()); + index += 2; + } + "--resolve" => { + let Some(action) = args.get(index + 1) else { + return Err("--resolve requires confirm or replace".to_owned()); + }; + parsed.resolve_action = Some(parse_stale_resolve_action(action)?); + index += 2; + } + "--old-source" => { + let Some(source) = args.get(index + 1) else { + return Err("--old-source requires source text".to_owned()); + }; + parsed.old_source = Some(source.clone()); + index += 2; + } + "--new-source" => { + let Some(source) = args.get(index + 1) else { + return Err("--new-source requires source text".to_owned()); + }; + parsed.new_source = Some(source.clone()); + index += 2; + } + "--stale-context" => { + let Some(context) = args.get(index + 1) else { + return Err("--stale-context requires a dictionary context path".to_owned()); + }; + parsed.stale_context = Some(context.clone()); + index += 2; + } + "--translation" => { + let Some(translation) = args.get(index + 1) else { + return Err("--translation requires translated text".to_owned()); + }; + parsed.replacement_translation = Some(translation.clone()); + index += 2; + } + "--interactive" => { + parsed.interactive = Some(true); + index += 1; + } + "--no-interactive" => { + parsed.interactive = Some(false); + index += 1; + } + other => return Err(format!("unexpected translations argument {other:?}")), + } + } + if parsed.all_targets && parsed.target.is_some() { + return Err("choose --all-targets or --target, not both".to_owned()); + } + if parsed.interactive == Some(true) && parsed.json_output { + return Err("choose --interactive or --json, not both".to_owned()); + } + if parsed.summary && parsed.apply { + return Err("choose --summary or --apply, not both".to_owned()); + } + if parsed.summary && parsed.context { + return Err("choose --summary or --context, not both".to_owned()); + } + if parsed.resolve_action.is_some() { + if parsed.apply { + return Err("choose --resolve or --apply, not both".to_owned()); + } + if parsed.summary || parsed.context { + return Err("choose --resolve or reporting views, not both".to_owned()); + } + if parsed.resolve_action == Some(StaleResolveAction::Replace) + && parsed.replacement_translation.is_none() + { + return Err("--resolve replace requires --translation".to_owned()); + } + if parsed.resolve_action == Some(StaleResolveAction::Confirm) + && parsed.replacement_translation.is_some() + { + return Err("--translation is only valid with --resolve replace".to_owned()); + } + } else if parsed.old_source.is_some() + || parsed.new_source.is_some() + || parsed.stale_context.is_some() + || parsed.replacement_translation.is_some() + { + return Err( + "--old-source, --new-source, --stale-context, and --translation require --resolve" + .to_owned(), + ); + } + Ok(parsed) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StaleResolveAction { + Confirm, + Replace, +} + +fn parse_stale_resolve_action(value: &str) -> Result { + match value { + "confirm" => Ok(StaleResolveAction::Confirm), + "replace" => Ok(StaleResolveAction::Replace), + other => Err(format!( + "invalid stale translation resolve action {other:?}; expected confirm or replace" + )), + } +} + +fn should_use_interactive(args: &TranslationArgs) -> bool { + match args.interactive { + Some(true) => !args.json_output, + Some(false) => false, + None => { + !args.json_output + && io::stdin().is_terminal() + && io::stdout().is_terminal() + && (!args.manifest_path.exists() || (args.target.is_none() && !args.all_targets)) + } + } +} + +fn configure_interactively( + args: &mut TranslationArgs, + reader: &mut R, + writer: &mut W, + raw_mode: bool, +) -> Result<(), String> { + let mut ui = TerminalUi::new(reader, writer, raw_mode)?; + ui.message("Brain Brew translation coverage")?; + + if !args.manifest_path.exists() { + let manifests = discover_nearby_manifests(); + if manifests.is_empty() { + return Err(missing_manifest_error(&args.manifest_path)); + } + let labels = manifests + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + let choice = ui.select_one("Manifest", &labels, 0)?; + args.manifest_path = manifests[choice].clone(); + } + + let manifest = read_manifest(&args.manifest_path)?; + if args.target.is_none() && !args.all_targets { + let mut target_values = manifest.targets.keys().cloned().collect::>(); + target_values.sort(); + let mut labels = target_values.clone(); + labels.push("all targets".to_owned()); + let choice = ui.select_one("Target", &labels, 0)?; + if choice == target_values.len() { + args.all_targets = true; + } else { + args.target = Some(target_values[choice].clone()); + } + } + + if args.language.is_none() && args.all_targets { + let languages = inferred_languages(&manifest); + if !languages.is_empty() { + let mut labels = vec!["all languages".to_owned()]; + labels.extend(languages.iter().cloned()); + let choice = ui.select_one("Language filter", &labels, 0)?; + if choice > 0 { + args.language = Some(languages[choice - 1].clone()); + } + } + } + + if args.overlay.is_none() { + let overlays = translation_overlay_choices(args)?; + if overlays.len() > 1 { + let mut labels = vec!["all translation overlays".to_owned()]; + labels.extend(overlays.iter().map(|overlay| overlay.label.clone())); + let choice = ui.select_one("Translation overlay", &labels, 0)?; + if choice > 0 { + args.overlay = Some(overlays[choice - 1].id.clone()); + } + } + } + + if args.note.is_none() && args.field.is_none() && args.path_prefixes.is_empty() { + let reports = collect_translation_reports(args)?; + configure_scope_interactively(args, &reports, &mut ui)?; + } + + if !args.apply { + let labels = vec![ + "report only".to_owned(), + "apply selected source→source stubs".to_owned(), + ]; + let choice = ui.select_one("Mode", &labels, 0)?; + if choice == 1 { + args.apply = true; + } + } + + ui.finish_with_equivalent_command(args) +} + +fn configure_scope_interactively( + args: &mut TranslationArgs, + reports: &[ScopedTranslationReport], + ui: &mut TerminalUi<'_, R, W>, +) -> Result<(), String> { + let mut notes = BTreeSet::new(); + let mut fields = BTreeSet::new(); + let mut problem_paths = BTreeSet::new(); + for report in reports { + for entry in &report.report.entries { + if let Some(note) = note_id_from_path(&entry.path) { + notes.insert(note); + } + if let Some(field) = field_id_from_path(&entry.path) { + fields.insert(field); + } + if entry.category.is_problem() { + problem_paths.insert(entry.path.clone()); + } + } + } + + let labels = vec![ + "whole overlay".to_owned(), + "select note".to_owned(), + "select field".to_owned(), + "select path prefix".to_owned(), + "changed-path prefixes from diff".to_owned(), + ]; + let choice = ui.select_one("Scope", &labels, 0)?; + match choice { + 0 => {} + 1 => { + let note_labels = notes.into_iter().collect::>(); + if note_labels.is_empty() { + ui.message("No note paths were found; using whole overlay.")?; + } else { + let choice = ui.select_one("Note", ¬e_labels, 0)?; + args.note = Some(note_labels[choice].clone()); + } + } + 2 => { + let field_labels = fields.into_iter().collect::>(); + if field_labels.is_empty() { + ui.message("No field paths were found; using whole overlay.")?; + } else { + let choice = ui.select_one("Field", &field_labels, 0)?; + args.field = Some(field_labels[choice].clone()); + } + } + 3 => { + let mut body = Vec::new(); + if !problem_paths.is_empty() { + body.push("Known problem paths:".to_owned()); + for path in problem_paths.iter().take(12) { + body.push(format!(" - {path}")); + } + } + let prefix = ui.prompt_text("Path prefix", &body)?; + if !prefix.trim().is_empty() { + args.path_prefixes.push(prefix.trim().to_owned()); + } + } + 4 => { + let prefixes = ui.prompt_text( + "Changed deck path prefixes", + &["Paste changed deck path prefixes, comma-separated.".to_owned()], + )?; + args.path_prefixes.extend( + prefixes + .split(',') + .map(str::trim) + .filter(|prefix| !prefix.is_empty()) + .map(str::to_owned), + ); + } + _ => unreachable!(), + } + Ok(()) +} + +struct TerminalUi<'a, R: Read, W: Write> { + reader: &'a mut R, + writer: &'a mut W, + raw_mode: bool, + max_option_rows: Option, + terminal_cols: Option, +} + +impl<'a, R: Read, W: Write> TerminalUi<'a, R, W> { + fn new(reader: &'a mut R, writer: &'a mut W, raw_mode: bool) -> Result { + let (terminal_cols, max_option_rows) = if raw_mode { + enable_raw_mode().map_err(|error| error.to_string())?; + write!(writer, "\x1b[?25l").map_err(|error| error.to_string())?; + match crossterm::terminal::size() { + Ok((cols, rows)) => ( + Some(cols as usize), + Some(usize::from(rows.saturating_sub(6).max(3))), + ), + Err(_) => (None, Some(12)), + } + } else { + (None, None) + }; + Ok(Self { + reader, + writer, + raw_mode, + max_option_rows, + terminal_cols, + }) + } + + fn message(&mut self, message: &str) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(message, "1;36"))?; + self.blank_line()?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn finish_with_equivalent_command(&mut self, args: &TranslationArgs) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line("Equivalent command:")?; + self.write_line(&format!(" {}", equivalent_command(args)))?; + self.blank_line()?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn select_one( + &mut self, + label: &str, + options: &[String], + default_index: usize, + ) -> Result { + if options.is_empty() { + return Err(format!("no {label} options are available")); + } + let mut selected = default_index.min(options.len() - 1); + loop { + self.render_select_one(label, options, selected)?; + match self.read_key()? { + TuiKey::Up => selected = selected.saturating_sub(1), + TuiKey::Down => selected = (selected + 1).min(options.len() - 1), + TuiKey::PageUp => { + selected = selected.saturating_sub(self.visible_option_rows(options.len())) + } + TuiKey::PageDown => { + selected = + (selected + self.visible_option_rows(options.len())).min(options.len() - 1) + } + TuiKey::Home => selected = 0, + TuiKey::End => selected = options.len() - 1, + TuiKey::Enter => return Ok(selected), + TuiKey::Char('k') => selected = selected.saturating_sub(1), + TuiKey::Char('j') => selected = (selected + 1).min(options.len() - 1), + TuiKey::Char('q') | TuiKey::Esc => { + return Err("interactive selection cancelled".to_owned()); + } + _ => {} + } + } + } + + fn select_many( + &mut self, + label: &str, + options: &[String], + selected_by_default: bool, + ) -> Result, String> { + if options.is_empty() { + return Ok(Vec::new()); + } + let mut cursor = 0; + let mut selected = vec![selected_by_default; options.len()]; + loop { + self.render_select_many(label, options, &selected, cursor)?; + match self.read_key()? { + TuiKey::Up => cursor = cursor.saturating_sub(1), + TuiKey::Down => cursor = (cursor + 1).min(options.len() - 1), + TuiKey::PageUp => { + cursor = cursor.saturating_sub(self.visible_option_rows(options.len())) + } + TuiKey::PageDown => { + cursor = + (cursor + self.visible_option_rows(options.len())).min(options.len() - 1) + } + TuiKey::Home => cursor = 0, + TuiKey::End => cursor = options.len() - 1, + TuiKey::Char('k') => cursor = cursor.saturating_sub(1), + TuiKey::Char('j') => cursor = (cursor + 1).min(options.len() - 1), + TuiKey::Space => selected[cursor] = !selected[cursor], + TuiKey::Char('a') => { + let all_selected = selected.iter().all(|value| *value); + selected.fill(!all_selected); + } + TuiKey::Enter => { + return Ok(selected + .iter() + .enumerate() + .filter_map(|(index, selected)| selected.then_some(index)) + .collect()); + } + TuiKey::Char('q') | TuiKey::Esc => { + return Err("interactive selection cancelled".to_owned()); + } + _ => {} + } + } + } + + fn prompt_text(&mut self, label: &str, body: &[String]) -> Result { + let mut input = String::new(); + loop { + self.render_text_prompt(label, body, &input)?; + match self.read_key()? { + TuiKey::Enter => return Ok(input), + TuiKey::Backspace => { + input.pop(); + } + TuiKey::Char(ch) => input.push(ch), + TuiKey::Esc => return Err("interactive text entry cancelled".to_owned()), + _ => {} + } + } + } + + fn render_select_one( + &mut self, + label: &str, + options: &[String], + selected: usize, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + let (start, end, scrolled) = self.option_window(options.len(), selected); + if scrolled { + self.write_line(&format!( + "Showing {}–{} of {}", + start + 1, + end, + options.len() + ))?; + self.blank_line()?; + } + for (relative_index, option) in options[start..end].iter().enumerate() { + let index = start + relative_index; + let marker = if index == selected { "›" } else { " " }; + let option = self.truncate_option(option, 4); + let option = if index == selected { + color_stdout(&option, "1;32") + } else { + option + }; + self.write_line(&format!(" {marker} {option}"))?; + } + self.blank_line()?; + self.write_line("↑/↓ move • PgUp/PgDn jump • Enter select • q cancel")?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn render_select_many( + &mut self, + label: &str, + options: &[String], + selected: &[bool], + cursor: usize, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + let (start, end, scrolled) = self.option_window(options.len(), cursor); + if scrolled { + self.write_line(&format!( + "Showing {}–{} of {}", + start + 1, + end, + options.len() + ))?; + self.blank_line()?; + } + for (relative_index, option) in options[start..end].iter().enumerate() { + let index = start + relative_index; + let cursor_marker = if index == cursor { "›" } else { " " }; + let selected_marker = if selected[index] { "[x]" } else { "[ ]" }; + let option = self.truncate_option(option, 8); + let line = format!("{selected_marker} {option}"); + let line = if index == cursor { + color_stdout(&line, "1;32") + } else { + line + }; + self.write_line(&format!(" {cursor_marker} {line}"))?; + } + self.blank_line()?; + self.write_line( + "↑/↓ move • PgUp/PgDn jump • Space toggle • a toggle all • Enter confirm • q cancel", + )?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn render_text_prompt( + &mut self, + label: &str, + body: &[String], + input: &str, + ) -> Result<(), String> { + self.clear_if_raw()?; + self.write_line(&color_stdout(label, "1;36"))?; + self.blank_line()?; + for line in body { + self.write_line(line)?; + } + if !body.is_empty() { + self.blank_line()?; + } + self.write_line(&format!("> {input}"))?; + self.blank_line()?; + self.write_line("Type text • Enter confirm • Esc cancel")?; + self.writer.flush().map_err(|error| error.to_string()) + } + + fn option_window(&self, option_count: usize, cursor: usize) -> (usize, usize, bool) { + if option_count == 0 { + return (0, 0, false); + } + let visible = self + .visible_option_rows(option_count) + .min(option_count) + .max(1); + if visible >= option_count { + return (0, option_count, false); + } + let half = visible / 2; + let mut start = cursor.saturating_sub(half); + if start + visible > option_count { + start = option_count - visible; + } + (start, start + visible, true) + } + + fn visible_option_rows(&self, option_count: usize) -> usize { + self.max_option_rows + .unwrap_or(option_count) + .min(option_count) + .max(1) + } + + fn truncate_option(&self, option: &str, prefix_width: usize) -> String { + let Some(cols) = self.terminal_cols else { + return option.to_owned(); + }; + let width = cols.saturating_sub(prefix_width).max(1); + truncate_chars(option, width) + } + + fn clear_if_raw(&mut self) -> Result<(), String> { + if self.raw_mode { + self.clear()?; + } + Ok(()) + } + + fn clear(&mut self) -> Result<(), String> { + write!(self.writer, "\x1b[2J\x1b[H").map_err(|error| error.to_string()) + } + + fn write_line(&mut self, text: &str) -> Result<(), String> { + write!(self.writer, "{}{}", text, terminal_line_end(self.raw_mode)) + .map_err(|error| error.to_string()) + } + + fn blank_line(&mut self) -> Result<(), String> { + write!(self.writer, "{}", terminal_line_end(self.raw_mode)) + .map_err(|error| error.to_string()) + } + + fn read_key(&mut self) -> Result { + if self.raw_mode { + return read_terminal_key(); + } + read_scripted_key(self.reader) + } +} + +fn terminal_line_end(raw_mode: bool) -> &'static str { + if raw_mode { "\r\n" } else { "\n" } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_owned(); + } + if max_chars <= 1 { + return "…".to_owned(); + } + value.chars().take(max_chars - 1).chain(['…']).collect() +} + +impl Drop for TerminalUi<'_, R, W> { + fn drop(&mut self) { + if self.raw_mode { + let _ = write!(self.writer, "\x1b[?25h"); + let _ = self.writer.flush(); + let _ = disable_raw_mode(); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TuiKey { + Up, + Down, + PageUp, + PageDown, + Home, + End, + Enter, + Space, + Backspace, + Esc, + Char(char), + Other, +} + +fn read_terminal_key() -> Result { + loop { + let event = crossterm::event::read().map_err(|error| error.to_string())?; + let crossterm::event::Event::Key(key) = event else { + continue; + }; + use crossterm::event::KeyCode; + return Ok(match key.code { + KeyCode::Up => TuiKey::Up, + KeyCode::Down => TuiKey::Down, + KeyCode::PageUp => TuiKey::PageUp, + KeyCode::PageDown => TuiKey::PageDown, + KeyCode::Home => TuiKey::Home, + KeyCode::End => TuiKey::End, + KeyCode::Enter => TuiKey::Enter, + KeyCode::Char(' ') => TuiKey::Space, + KeyCode::Char(ch) => TuiKey::Char(ch), + KeyCode::Backspace => TuiKey::Backspace, + KeyCode::Esc => TuiKey::Esc, + _ => TuiKey::Other, + }); + } +} + +fn read_scripted_key(reader: &mut R) -> Result { + let mut byte = [0_u8; 1]; + let count = reader.read(&mut byte).map_err(|error| error.to_string())?; + if count == 0 { + return Err("interactive input ended before a selection was made".to_owned()); + } + match byte[0] { + b'\n' | b'\r' => Ok(TuiKey::Enter), + b' ' => Ok(TuiKey::Space), + 8 | 127 => Ok(TuiKey::Backspace), + 27 => read_escape_sequence(reader), + byte if byte.is_ascii() && !byte.is_ascii_control() => Ok(TuiKey::Char(byte as char)), + _ => Ok(TuiKey::Other), + } +} + +fn read_escape_sequence(reader: &mut R) -> Result { + let mut rest = [0_u8; 2]; + let count = reader.read(&mut rest).map_err(|error| error.to_string())?; + if count < 2 || rest[0] != b'[' { + return Ok(TuiKey::Esc); + } + Ok(match rest[1] { + b'A' => TuiKey::Up, + b'B' => TuiKey::Down, + _ => TuiKey::Other, + }) +} + +#[derive(Clone)] +struct ScopedTranslationReport { + target: String, + overlay_id: String, + overlay_file: String, + overlay_path: PathBuf, + report: TranslationCoverageReport, + context: TranslationContextView, +} + +fn collect_translation_reports( + args: &TranslationArgs, +) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + + let mut reports = Vec::new(); + for target in &target_names { + let plan = plan_manifest_target_with_packages( + &args.manifest_path, + target, + &args.include_paths, + &args.package_roots, + )?; + let mut current = plan.base.clone(); + for (planned, overlay) in &plan.overlays { + if overlay.translations.is_some() { + if overlay_matches_scope(target, planned, overlay, args) + && let Some(report) = current.translation_coverage(overlay) + { + let full_context = current.translation_context(&report); + let mut entries = report + .entries + .iter() + .filter(|entry| entry_matches_scope(entry, args)) + .cloned() + .collect::>(); + if args.duplicates { + retain_duplicate_source_entries(&mut entries); + } + let scoped_report = TranslationCoverageReport { + overlay_id: report.overlay_id, + entries, + }; + let context = filter_context_to_report(full_context, &scoped_report); + reports.push(ScopedTranslationReport { + target: target.clone(), + overlay_id: planned.id.clone(), + overlay_file: planned.display_file.clone(), + overlay_path: planned.file.clone(), + report: scoped_report, + context, + }); + } + current = compose_lenient_translation_overlay(¤t, overlay)?; + } else { + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|error| { + format!( + "failed to compose overlay {} for target {target}: {error}", + planned.id + ) + })?; + } + } + } + Ok(reports) +} + +fn selected_target_names(manifest: &FederatedDeckManifest, args: &TranslationArgs) -> Vec { + if args.all_targets || args.target.is_none() { + manifest.targets.keys().cloned().collect::>() + } else { + vec![args.target.clone().expect("target exists")] + } +} + +#[derive(Clone, Debug, Default)] +struct OverlayEdits { + direct: BTreeSet, + contextual: BTreeMap>, + no_change: BTreeSet, + ignore_paths: BTreeSet, +} + +impl OverlayEdits { + fn is_empty(&self) -> bool { + self.direct.is_empty() + && self.contextual.is_empty() + && self.no_change.is_empty() + && self.ignore_paths.is_empty() + } + + fn count(&self) -> usize { + self.direct.len() + + self.contextual.values().map(BTreeSet::len).sum::() + + self.no_change.len() + + self.ignore_paths.len() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct StaleResolution { + old_source: String, + new_source: String, + context: Option, + replacement: Option, +} + +fn resolve_stale_translations( + args: &TranslationArgs, + reports: &[ScopedTranslationReport], +) -> Result, String> { + let action = args + .resolve_action + .expect("resolve action checked before resolving stale translations"); + let mut resolutions_by_file = BTreeMap::>::new(); + let mut stale_record_matched_filter = false; + let mut non_stale_source_match = None::; + + for report in reports { + let shadowing_entries = report + .report + .entries + .iter() + .filter(|entry| entry_can_shadow_stale_translation(entry)) + .collect::>(); + for entry in &report.report.entries { + if !entry_matches_stale_resolution_filter(entry, args) { + if entry.category != TranslationCoverageCategory::StaleTranslation + && args + .new_source + .as_ref() + .is_some_and(|source| source == &entry.source) + && non_stale_source_match.is_none() + { + non_stale_source_match = Some(entry.source.clone()); + } + continue; + } + + if entry.category != TranslationCoverageCategory::StaleTranslation { + non_stale_source_match.get_or_insert_with(|| entry.source.clone()); + continue; + } + + stale_record_matched_filter = true; + let shadowed = shadowing_entries + .iter() + .any(|candidate| entry_shadows_stale_record(candidate, entry)); + if entry.path.starts_with("translations.stale_translations.") && !shadowed { + continue; + } + + let old_source = entry.old_source.clone().ok_or_else(|| { + format!( + "stale translation at {} is missing old_source; refusing to resolve", + entry.path + ) + })?; + resolutions_by_file + .entry(report.overlay_path.clone()) + .or_default() + .insert(StaleResolution { + old_source, + new_source: entry.source.clone(), + context: entry.context.clone(), + replacement: args.replacement_translation.clone(), + }); + } + } + + if resolutions_by_file.is_empty() { + if stale_record_matched_filter { + return Err(stale_resolution_mismatched_current_source_error(args)); + } + if let Some(source) = non_stale_source_match { + return Err(format!( + "source {source:?} is not stale; refusing to resolve" + )); + } + return Err(stale_resolution_no_match_error(args)); + } + + let mut applied = BTreeMap::new(); + for (path, resolutions) in resolutions_by_file { + let count = apply_stale_resolutions_to_overlay(&path, action, &resolutions)?; + if count > 0 { + applied.insert(path.display().to_string(), count); + } + } + Ok(applied) +} + +fn entry_can_shadow_stale_translation(entry: &TranslationCoverageEntry) -> bool { + matches!( + entry.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + ) +} + +fn entry_shadows_stale_record( + candidate: &TranslationCoverageEntry, + stale: &TranslationCoverageEntry, +) -> bool { + candidate.source == stale.source + && match candidate.category { + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::NoChange => true, + TranslationCoverageCategory::ContextualTranslation => stale + .context + .as_deref() + .is_none_or(|context| context_matches_path(context, &candidate.path)), + _ => false, + } +} + +fn context_matches_path(context_path: &str, path: &str) -> bool { + path == context_path + || path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn entry_matches_stale_resolution_filter( + entry: &TranslationCoverageEntry, + args: &TranslationArgs, +) -> bool { + if let Some(old_source) = &args.old_source + && entry.old_source.as_deref() != Some(old_source.as_str()) + { + return false; + } + if let Some(new_source) = &args.new_source + && entry.source != *new_source + { + return false; + } + if let Some(context) = &args.stale_context + && entry.context.as_deref() != Some(context.as_str()) + { + return false; + } + true +} + +fn stale_resolution_mismatched_current_source_error(args: &TranslationArgs) -> String { + let mut parts = Vec::new(); + if let Some(old_source) = &args.old_source { + parts.push(format!("old_source={old_source:?}")); + } + if let Some(new_source) = &args.new_source { + parts.push(format!("new_source={new_source:?}")); + } + if let Some(context) = &args.stale_context { + parts.push(format!("context={context:?}")); + } + if parts.is_empty() { + "matched stale translation record does not match the current base text; refusing to resolve" + .to_owned() + } else { + format!( + "stale translation {} does not match the current base text; refusing to resolve", + parts.join(" ") + ) + } +} + +fn stale_resolution_no_match_error(args: &TranslationArgs) -> String { + if let Some(new_source) = &args.new_source { + format!("no stale translation matched new_source={new_source:?}") + } else { + "no stale translations matched the selected target and scope".to_owned() + } +} + +fn apply_stale_resolutions_to_overlay( + path: &Path, + action: StaleResolveAction, + resolutions: &BTreeSet, +) -> Result { + if resolutions.is_empty() { + return Ok(0); + } + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let output = source_includes::format_preserving_file_includes(&input, |source| { + let mut overlay = + canonical_yaml::overlay_from_str(source).map_err(|error| error.to_string())?; + let translations = overlay + .translations + .as_mut() + .ok_or_else(|| format!("{} has no translations dictionary", path.display()))?; + for resolution in resolutions { + apply_stale_resolution(translations, resolution, action, path)?; + } + Ok::<_, String>(canonical_yaml::overlay_to_string(&overlay)) + })?; + if output != input { + fs::write(path, output).map_err(|error| format!("{}: {error}", path.display()))?; + } + Ok(resolutions.len()) +} + +fn apply_stale_resolution( + translations: &mut TranslationDictionary, + resolution: &StaleResolution, + action: StaleResolveAction, + path: &Path, +) -> Result<(), String> { + let position = translations + .stale_translations + .iter() + .position(|record| { + record.old_source == resolution.old_source + && record.new_source == resolution.new_source + && record.context == resolution.context + }) + .ok_or_else(|| { + format!( + "stale translation old_source={:?} new_source={:?} context={:?} was not found in {}", + resolution.old_source, + resolution.new_source, + resolution.context, + path.display() + ) + })?; + let shadowed = stale_record_shadowed_by_existing_entry(translations, position); + let record = translations.stale_translations.remove(position); + if shadowed { + return Ok(()); + } + + let replacement = if action == StaleResolveAction::Replace { + resolution + .replacement + .as_ref() + .expect("replace action requires replacement translation") + .clone() + } else { + record.target + }; + if let Some(context) = record.context { + translations + .contextual + .entry(context) + .or_default() + .insert(record.new_source, replacement); + } else { + translations.direct.insert(record.new_source, replacement); + } + Ok(()) +} + +fn stale_record_shadowed_by_existing_entry( + translations: &TranslationDictionary, + stale_index: usize, +) -> bool { + let record = &translations.stale_translations[stale_index]; + translations.direct.contains_key(&record.new_source) + || translations.no_change.contains(&record.new_source) + || translations + .contextual + .iter() + .any(|(context, replacements)| { + replacements.contains_key(&record.new_source) + && record.context.as_deref().is_none_or(|stale_context| { + context_matches_path(context, stale_context) + || context_matches_path(stale_context, context) + }) + }) +} + +fn direct_stub_edits_from_reports( + reports: &[ScopedTranslationReport], + full: bool, +) -> BTreeMap { + let mut edits = BTreeMap::::new(); + for report in reports { + let stubs = report + .report + .entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && (full || default_report_includes_entry(entry)) + }) + .map(|entry| entry.source.clone()) + .collect::>(); + if !stubs.is_empty() { + edits + .entry(report.overlay_path.clone()) + .or_default() + .direct + .extend(stubs); + } + } + edits +} + +fn prompt_selective_apply( + reports: &[ScopedTranslationReport], + reader: &mut R, + writer: &mut W, + raw_mode: bool, + full: bool, +) -> Result, String> { + let mut ui = TerminalUi::new(reader, writer, raw_mode)?; + let rows = reports + .iter() + .enumerate() + .flat_map(|(report_index, report)| { + report + .report + .entries + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.category.is_problem() + && (entry.category != TranslationCoverageCategory::UntranslatedFallback + || full + || default_report_includes_entry(entry)) + }) + .map(move |(entry_index, _)| (report_index, entry_index)) + }) + .collect::>(); + + if rows.is_empty() { + ui.message("No missing, stale, or invalid translation rows to apply.")?; + return Ok(BTreeMap::new()); + } + + let row_labels = rows + .iter() + .map(|(report_index, entry_index)| { + let report = &reports[*report_index]; + let entry = &report.report.entries[*entry_index]; + format!( + "{} {} {} source={} — {}", + report.target, + report.overlay_id, + entry.category.as_str(), + yaml_scalar(&entry.source), + entry.path + ) + }) + .collect::>(); + let selected = ui.select_many("Rows to edit", &row_labels, true)?; + + let selected_missing = selected + .iter() + .copied() + .filter(|row_index| { + let (_, entry_index) = rows[*row_index]; + reports[rows[*row_index].0].report.entries[entry_index].category + == TranslationCoverageCategory::UntranslatedFallback + }) + .collect::>(); + + let bulk_action = if selected_missing.is_empty() { + MissingApplyAction::Skip + } else { + let labels = vec![ + format!( + "mark no-change for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add direct source→source translation stubs for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add contextual source→source translation stubs for all {} selected missing translations", + selected_missing.len() + ), + format!( + "add ignore_paths entries for all {} selected missing translations", + selected_missing.len() + ), + "decide per row".to_owned(), + "skip selected missing translations".to_owned(), + ]; + match ui.select_one("Action for selected missing translations", &labels, 0)? { + 0 => MissingApplyAction::NoChange, + 1 => MissingApplyAction::Direct, + 2 => MissingApplyAction::Contextual, + 3 => MissingApplyAction::IgnorePath, + 4 => MissingApplyAction::DecidePerRow, + 5 => MissingApplyAction::Skip, + _ => unreachable!(), + } + }; + + let mut edits = BTreeMap::::new(); + for row_index in selected { + let (report_index, entry_index) = rows[row_index]; + let report = &reports[report_index]; + let entry = &report.report.entries[entry_index]; + match entry.category { + TranslationCoverageCategory::UntranslatedFallback => { + let action = match bulk_action { + MissingApplyAction::DecidePerRow => { + prompt_missing_apply_action(&mut ui, entry)? + } + other => other, + }; + apply_missing_translation_action(&mut edits, &report.overlay_path, entry, action); + } + _ => { + ui.message(&format!( + "{} at {} is stale/invalid; no safe automatic rewrite is applied, skipping. Use `brainbrew translations --resolve confirm` when the existing translation is still correct, or `brainbrew translations --resolve replace --translation ` to retire the stale record with a new translation.", + entry.category.as_str(), + entry.path + ))?; + } + } + } + + edits.retain(|_, edits| !edits.is_empty()); + if edits.is_empty() { + return Ok(edits); + } + + let confirm_labels = vec!["apply selected changes".to_owned(), "cancel".to_owned()]; + if ui.select_one("Apply selected changes?", &confirm_labels, 0)? == 0 { + Ok(edits) + } else { + Ok(BTreeMap::new()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MissingApplyAction { + NoChange, + Direct, + Contextual, + IgnorePath, + DecidePerRow, + Skip, +} + +fn prompt_missing_apply_action( + ui: &mut TerminalUi<'_, R, W>, + entry: &TranslationCoverageEntry, +) -> Result { + let context = contextual_context_for_entry(entry); + let action_labels = vec![ + "mark no-change".to_owned(), + "add direct source→source translation stub".to_owned(), + format!("add contextual source→source translation stub at {context}"), + "add ignore path for this deck path".to_owned(), + "skip".to_owned(), + ]; + match ui.select_one( + &format!( + "{} at {} source={}", + entry.category.as_str(), + entry.path, + yaml_scalar(&entry.source) + ), + &action_labels, + 0, + )? { + 0 => Ok(MissingApplyAction::NoChange), + 1 => Ok(MissingApplyAction::Direct), + 2 => Ok(MissingApplyAction::Contextual), + 3 => Ok(MissingApplyAction::IgnorePath), + 4 => Ok(MissingApplyAction::Skip), + _ => unreachable!(), + } +} + +fn apply_missing_translation_action( + edits: &mut BTreeMap, + overlay_path: &Path, + entry: &TranslationCoverageEntry, + action: MissingApplyAction, +) { + let file_edits = edits.entry(overlay_path.to_path_buf()).or_default(); + match action { + MissingApplyAction::NoChange => { + file_edits.no_change.insert(entry.source.clone()); + } + MissingApplyAction::Direct => { + file_edits.direct.insert(entry.source.clone()); + } + MissingApplyAction::Contextual => { + file_edits + .contextual + .entry(contextual_context_for_entry(entry)) + .or_default() + .insert(entry.source.clone()); + } + MissingApplyAction::IgnorePath => { + file_edits.ignore_paths.insert(entry.path.clone()); + } + MissingApplyAction::DecidePerRow | MissingApplyAction::Skip => {} + } +} + +fn contextual_context_for_entry(entry: &TranslationCoverageEntry) -> String { + context_parent_candidate(&entry.path).unwrap_or_else(|| entry.path.clone()) +} + +fn overlay_matches_scope( + target: &str, + planned: &PlannedOverlay, + overlay: &Overlay, + args: &TranslationArgs, +) -> bool { + if let Some(overlay_filter) = &args.overlay { + let overlay_id = overlay.id.as_str(); + if !planned.id.contains(overlay_filter) + && !planned.display_file.contains(overlay_filter) + && !overlay_id.contains(overlay_filter) + { + return false; + } + } + if let Some(language) = &args.language + && !language_matches(language, target, planned, overlay) + { + return false; + } + true +} + +fn language_matches( + language: &str, + target: &str, + planned: &PlannedOverlay, + overlay: &Overlay, +) -> bool { + let language = language.trim(); + if language.is_empty() { + return true; + } + let target_has_language = target == language + || target.starts_with(&format!("{language}-")) + || target.ends_with(&format!("-{language}")); + let overlay_id = overlay.id.as_str(); + let file_stem = Path::new(&planned.display_file) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + target_has_language + || planned.id.contains(&format!(".{language}")) + || planned.id.contains(&format!("-{language}")) + || overlay_id.contains(&format!(".{language}")) + || overlay_id.contains(&format!("-{language}")) + || file_stem == language + || planned + .display_file + .split(['/', '\\']) + .any(|part| part == language || part == format!("{language}.yaml")) +} + +fn filter_context_to_report( + mut context: TranslationContextView, + report: &TranslationCoverageReport, +) -> TranslationContextView { + context.units.retain(|unit| { + report.entries.iter().any(|entry| { + entry.path == unit.path + && entry.category == unit.category + && entry.source == unit.source + && entry.context.as_deref() == unit.context.as_deref() + }) + }); + context +} + +fn retain_duplicate_source_entries(entries: &mut Vec) { + let mut counts = BTreeMap::::new(); + for entry in entries.iter().filter(|entry| !entry.source.is_empty()) { + *counts.entry(entry.source.clone()).or_insert(0) += 1; + } + entries.retain(|entry| counts.get(&entry.source).copied().unwrap_or(0) > 1); +} + +fn entry_matches_scope(entry: &TranslationCoverageEntry, args: &TranslationArgs) -> bool { + if let Some(source) = &args.source + && !entry.source.contains(source) + { + return false; + } + if let Some(status) = &args.status + && !status_matches_filter(entry.category, status) + { + return false; + } + if let Some(note) = &args.note { + let full = if note.starts_with("note.") { + note.clone() + } else { + format!("note.{note}") + }; + if !entry.path.starts_with(&format!("notes.{note}.")) + && !entry.path.starts_with(&format!("notes.{full}.")) + && !entry.path.contains(&format!(".notes.{note}.")) + && !entry.path.contains(&format!(".notes.{full}.")) + { + return false; + } + } + if let Some(field) = &args.field { + let full = if field.starts_with("field.") { + field.clone() + } else { + format!("field.{field}") + }; + if !entry.path.contains(&format!(".fields.{field}")) + && !entry.path.contains(&format!(".fields.{full}")) + { + return false; + } + } + if !args.path_prefixes.is_empty() + && !args + .path_prefixes + .iter() + .any(|prefix| entry.path == *prefix || entry.path.starts_with(&format!("{prefix}."))) + { + return false; + } + true +} + +fn print_no_translation_reports(args: &TranslationArgs) -> Result<(), String> { + let non_dictionary = non_dictionary_translation_overlays(args)?; + if non_dictionary.is_empty() { + println!( + "{}", + color_stdout( + "No translation coverage entries matched the selected target and scope.", + "33" + ) + ); + println!( + "Try a broader scope, or run `brainbrew translations --manifest {} --all-targets`.", + args.manifest_path.display() + ); + return Ok(()); + } + + println!( + "{}", + color_stdout( + "No translation dictionary coverage reports matched the selected target and scope.", + "33" + ) + ); + println!(); + println!( + "The selected target includes translation overlays that do not use a `translations:` dictionary yet:" + ); + for overlay in non_dictionary { + println!(" - {} ({})", overlay.id, overlay.label); + } + println!(); + println!( + "`brainbrew translations` reports source-keyed translation dictionaries such as `translations.direct`, `translations.contextual`, plus path-scoped `target_adaptations`." + ); + println!( + "Patch-style translation overlays still compose, but they do not expose coverage data for this workflow yet." + ); + Ok(()) +} + +fn non_dictionary_translation_overlays( + args: &TranslationArgs, +) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + let mut choices = BTreeSet::::new(); + for target in target_names { + let plan = plan_manifest_target_with_packages( + &args.manifest_path, + &target, + &args.include_paths, + &args.package_roots, + )?; + for (planned, overlay) in &plan.overlays { + if overlay.kind == OverlayKind::Translation + && overlay.translations.is_none() + && overlay_matches_scope(&target, planned, overlay, args) + { + choices.insert(OverlayChoice { + id: planned.id.clone(), + label: planned.display_file.clone(), + }); + } + } + } + Ok(choices.into_iter().collect()) +} + +fn print_human_reports(reports: &[ScopedTranslationReport], full: bool) { + for report in reports { + let display_entries = display_entries(&report.report.entries, full); + let display_counts = category_counts_refs(&display_entries); + let all_counts = category_counts(&report.report.entries); + let hidden_untranslated = hidden_untranslated_fallback_count(&report.report.entries, full); + println!( + "{}", + color_stdout( + &format!( + "Translation coverage for target {} overlay {} ({})", + report.target, report.overlay_id, report.overlay_file + ), + "1;36" + ) + ); + println!( + " {}: {}", + color_stdout("direct translations", "32"), + all_counts.get("direct_translation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("contextual translations", "36"), + all_counts + .get("contextual_translation") + .copied() + .unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("target adaptations", "35"), + all_counts.get("target_adaptation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("intentionally unchanged text", "34"), + all_counts.get("no_change").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("missing text translations", "31"), + display_counts + .get("untranslated_fallback") + .copied() + .unwrap_or(0) + ); + if hidden_untranslated > 0 { + println!( + " {}: {}", + color_stdout("hidden structural/media/tag fallbacks", "2"), + hidden_untranslated + ); + } + println!( + " {}: {}", + color_stdout("ignored entries", "2"), + all_counts.get("ignored_source").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("stale translations", "33"), + all_counts.get("stale_translation").copied().unwrap_or(0) + ); + println!( + " {}: {}", + color_stdout("stale/invalid keys", "33"), + report + .report + .entries + .iter() + .filter(|entry| is_stale_or_invalid(entry.category)) + .count() + ); + if hidden_untranslated > 0 && !full { + println!(" hint: use --full to include structural/media/tag fallbacks"); + } + + let problem_entries = display_entries + .iter() + .filter(|entry| entry.category.is_problem()) + .copied() + .collect::>(); + let shown_problem_limit = 40; + for entry in problem_entries.iter().take(shown_problem_limit) { + let old_source = entry + .old_source + .as_deref() + .map(|old_source| format!(" old_source={}", yaml_scalar(old_source))) + .unwrap_or_default(); + println!( + " - {} {}{} source={} translated={}", + color_category(entry.category, display_category_name(entry.category)), + entry.path, + old_source, + yaml_scalar(&entry.source), + entry + .translated + .as_deref() + .map(yaml_scalar) + .unwrap_or_else(|| "''".to_owned()) + ); + } + if problem_entries.len() > shown_problem_limit { + println!( + " … {} more problem entries not shown; narrow the scope or use --json", + problem_entries.len() - shown_problem_limit + ); + } + } +} + +fn display_entries( + entries: &[TranslationCoverageEntry], + full: bool, +) -> Vec<&TranslationCoverageEntry> { + entries + .iter() + .filter(|entry| full || default_report_includes_entry(entry)) + .collect() +} + +fn hidden_untranslated_fallback_count(entries: &[TranslationCoverageEntry], full: bool) -> usize { + if full { + return 0; + } + entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && !default_report_includes_entry(entry) + }) + .count() +} + +fn default_report_includes_entry(entry: &TranslationCoverageEntry) -> bool { + if entry.category != TranslationCoverageCategory::UntranslatedFallback { + return true; + } + default_report_includes_untranslated_fallback(entry) +} + +fn default_report_includes_untranslated_fallback(entry: &TranslationCoverageEntry) -> bool { + let path = entry.path.as_str(); + path.starts_with("notes.") + && path.contains(".fields.") + && !is_media_or_structural_field_path(path) + && !looks_like_media_markup(&entry.source) +} + +fn is_media_or_structural_field_path(path: &str) -> bool { + path.ends_with(".fields.field.flag") || path.ends_with(".fields.field.map") +} + +fn looks_like_media_markup(source: &str) -> bool { + let trimmed = source.trim_start(); + trimmed.starts_with(" &'static str { + match category { + TranslationCoverageCategory::UntranslatedFallback => "missing_translation", + other => other.as_str(), + } +} + +fn is_stale_or_invalid(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::InvalidTargetAdaptation + ) +} + +fn validate_status_filter(status: &str) -> Result<(), String> { + if status_filter_is_known(status) { + Ok(()) + } else { + Err(format!( + "invalid translation status {status:?}; expected missing, stale, translated, changed, direct, contextual, no-change, adaptation, variable, adapter-id, ignored, or a coverage category name" + )) + } +} + +fn status_filter_is_known(status: &str) -> bool { + let normalized = normalized_status_filter(status); + matches!( + normalized.as_str(), + "missing" + | "untranslated" + | "stale" + | "invalid" + | "translated" + | "covered" + | "changed" + | "direct" + | "contextual" + | "no_change" + | "adaptation" + | "variable" + | "adapter_id" + | "ignored" + | "direct_translation" + | "contextual_translation" + | "target_adaptation" + | "variable_translation" + | "adapter_id_translation" + | "ignored_source" + | "untranslated_fallback" + | "stale_direct_key" + | "stale_contextual_key" + | "stale_no_change_key" + | "stale_target_adaptation" + | "stale_variable_key" + | "stale_adapter_id_key" + | "stale_translation" + | "invalid_target_adaptation" + ) +} + +fn status_matches_filter(category: TranslationCoverageCategory, status: &str) -> bool { + let normalized = normalized_status_filter(status); + match normalized.as_str() { + "missing" | "untranslated" => category == TranslationCoverageCategory::UntranslatedFallback, + "stale" | "invalid" => is_stale_or_invalid(category), + "translated" | "covered" => matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::TargetAdaptation + | TranslationCoverageCategory::VariableTranslation + | TranslationCoverageCategory::AdapterIdTranslation + ), + "changed" => matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::TargetAdaptation + | TranslationCoverageCategory::VariableTranslation + ), + "direct" => category == TranslationCoverageCategory::DirectTranslation, + "contextual" => category == TranslationCoverageCategory::ContextualTranslation, + "no_change" => category == TranslationCoverageCategory::NoChange, + "adaptation" => category == TranslationCoverageCategory::TargetAdaptation, + "variable" => category == TranslationCoverageCategory::VariableTranslation, + "adapter_id" => category == TranslationCoverageCategory::AdapterIdTranslation, + "ignored" => category == TranslationCoverageCategory::IgnoredSource, + other => category.as_str() == other, + } +} + +fn normalized_status_filter(status: &str) -> String { + status.trim().to_ascii_lowercase().replace(['-', ' '], "_") +} + +fn print_json_reports(reports: &[ScopedTranslationReport], applied: &BTreeMap) { + let reports_json = reports + .iter() + .map(|report| { + json!({ + "target": report.target, + "overlay": report.overlay_id, + "file": report.overlay_file, + "summary": category_counts(&report.report.entries), + "entries": report.report.entries.iter().map(entry_json).collect::>(), + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "reports": reports_json, + "applied": applied, + })) + .expect("translation report JSON serializes") + ); +} + +fn print_json_contexts(reports: &[ScopedTranslationReport]) { + let contexts_json = reports + .iter() + .map(|report| { + json!({ + "target": report.target, + "language": inferred_report_language(report), + "overlay": report.overlay_id, + "file": report.overlay_file, + "units": report.context.units.iter().map(context_unit_json).collect::>(), + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ "contexts": contexts_json })) + .expect("translation context JSON serializes") + ); +} + +fn context_unit_json(unit: &TranslationContextUnit) -> serde_json::Value { + json!({ + "status": unit.category.as_str(), + "path": unit.path, + "source": unit.source, + "old_source": unit.old_source, + "translated": unit.translated, + "context": unit.context, + "note_id": unit.note_id.as_ref().map(|id| id.as_str()), + "note_type_id": unit.note_type_id.as_ref().map(|id| id.as_str()), + "field_id": unit.field_id.as_ref().map(|id| id.as_str()), + "field_name": unit.field_name, + "source_occurrences": unit.source_occurrences, + "note_fields": unit.note_fields.iter().map(|field| json!({ + "field_id": field.field_id.as_str(), + "field_name": field.field_name, + "source": field.source, + "translated": field.translated, + "status": field.category.map(|category| category.as_str()), + })).collect::>(), + "message": unit.message.as_ref().map(|message| json!({ + "source": message.source, + "translated": message.translated, + "format": message.format.as_ref().map(|component| json!({ + "index": component.index, + "name": component.name, + "kind": component.kind.as_str(), + "path": component.path, + "source": component.source, + "translated": component.translated, + "reference": component.reference, + "status": component.category.map(|category| category.as_str()), + })), + "components": message.components.iter().map(|component| json!({ + "index": component.index, + "name": component.name, + "kind": component.kind.as_str(), + "path": component.path, + "source": component.source, + "translated": component.translated, + "reference": component.reference, + "status": component.category.map(|category| category.as_str()), + })).collect::>(), + })), + "card_templates": unit.card_templates.iter().map(|card| json!({ + "template_id": card.template_id.as_str(), + "template_name": card.template_name, + "sides": card.sides.iter().map(|side| side.as_str()).collect::>(), + "question_format": card.question_format, + "answer_format": card.answer_format, + })).collect::>(), + }) +} + +fn print_human_contexts(reports: &[ScopedTranslationReport], full: bool) { + for report in reports { + let language = inferred_report_language(report); + let units = report + .context + .units + .iter() + .filter(|unit| full || default_context_includes_unit(unit)) + .collect::>(); + println!( + "{}", + color_stdout( + &format!( + "Translation context for target {} language {} overlay {} ({})", + report.target, language, report.overlay_id, report.overlay_file + ), + "1;36" + ) + ); + if units.is_empty() { + println!(" no context units matched this scope"); + continue; + } + for unit in units { + print_context_unit(unit, &language); + } + } +} + +fn default_context_includes_unit(unit: &TranslationContextUnit) -> bool { + is_stale_or_invalid(unit.category) + || (unit.note_id.is_some() + && unit.field_id.is_some() + && !is_media_or_structural_field_path(&unit.path) + && !looks_like_media_markup(&unit.source)) +} + +fn print_context_unit(unit: &TranslationContextUnit, language: &str) { + println!( + " - {} {}", + color_category(unit.category, display_category_name(unit.category)), + unit.path + ); + if unit.source_occurrences > 1 { + println!( + " duplicate source group: {} occurrence(s) of {}", + unit.source_occurrences, + yaml_scalar(&unit.source) + ); + } + if let Some(note_id) = &unit.note_id { + println!(" note: {note_id}"); + } + if let Some(note_type_id) = &unit.note_type_id { + println!(" note type: {note_type_id}"); + } + if let Some(field_id) = &unit.field_id { + let field_name = unit + .field_name + .as_ref() + .map(|name| format!(" ({name})")) + .unwrap_or_default(); + println!(" field: {field_id}{field_name}"); + } + if !unit.note_fields.is_empty() { + print_note_field_context(unit, language); + } + if let Some(message) = &unit.message { + print_message_context(message, language); + } + if let Some(context) = &unit.context { + println!(" dictionary context: {context}"); + } + if !unit.card_templates.is_empty() { + let cards = unit + .card_templates + .iter() + .map(|card| { + let sides = card + .sides + .iter() + .map(|side| side.as_str()) + .collect::>() + .join("+"); + format!("{} [{}]", card.template_id, sides) + }) + .collect::>() + .join(", "); + println!(" cards: {cards}"); + } + for line in side_by_side_lines( + "source/en", + &unit.source, + &format!("target/{language}"), + unit.translated.as_deref().unwrap_or(""), + ) { + println!(" {line}"); + } +} + +fn print_note_field_context(unit: &TranslationContextUnit, language: &str) { + println!(" note fields (source/en | target/{language}):"); + for field in unit.note_fields.iter().filter(|field| { + !field.source.is_empty() + && !looks_like_media_markup(&field.source) + && !is_media_or_structural_field_id(field.field_id.as_str()) + }) { + let marker = if unit.field_id.as_ref() == Some(&field.field_id) { + "*" + } else { + " " + }; + println!( + " {marker} {:<24} {} | {}", + format!("{} ({})", field.field_id, field.field_name), + compact_context_value(&field.source), + compact_context_value(&field.translated) + ); + } +} + +fn print_message_context(message: &TranslationMessageContext, language: &str) { + println!(" structured message (source/en | target/{language}):"); + println!( + " resolved {} | {}", + compact_context_value(&message.source), + compact_context_value(&message.translated) + ); + if let Some(format) = &message.format { + println!( + " {:<32} {} | {}", + "format", + compact_context_value(&format.source), + compact_context_value(&format.translated) + ); + } + for component in &message.components { + let label_prefix = component + .name + .as_deref() + .map_or_else(|| format!("[{}]", component.index), ToOwned::to_owned); + let label = match component.reference.as_deref() { + Some(reference) => { + format!("{} {} {}", label_prefix, component.kind.as_str(), reference) + } + None => format!("{} {}", label_prefix, component.kind.as_str()), + }; + println!( + " {:<32} {} | {}", + label, + compact_context_value(&component.source), + compact_context_value(&component.translated) + ); + } +} + +fn is_media_or_structural_field_id(field_id: &str) -> bool { + field_id == "field.flag" || field_id == "field.map" +} + +fn compact_context_value(value: &str) -> String { + let collapsed = value.split_whitespace().collect::>().join(" "); + const LIMIT: usize = 42; + if collapsed.chars().count() <= LIMIT { + collapsed + } else { + let mut truncated = collapsed + .chars() + .take(LIMIT.saturating_sub(1)) + .collect::(); + truncated.push('…'); + truncated + } +} + +fn side_by_side_lines( + left_header: &str, + left: &str, + right_header: &str, + right: &str, +) -> Vec { + const COLUMN_WIDTH: usize = 38; + let mut lines = vec![format!( + "{left_header: Vec { + let mut lines = Vec::new(); + for raw_line in text.lines().flat_map(|line| line.split("\n")) { + let mut current = String::new(); + for word in raw_line.split_whitespace() { + let separator = usize::from(!current.is_empty()); + if current.chars().count() + separator + word.chars().count() > width + && !current.is_empty() + { + lines.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push(' '); + } + current.push_str(word); + } + if !current.is_empty() { + lines.push(current); + } + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd)] +struct TranslationSummaryCounts { + direct_translation: usize, + contextual_translation: usize, + no_change: usize, + target_adaptation: usize, + variable_translation: usize, + adapter_id_translation: usize, + untranslated_fallback: usize, + missing_text_translation: usize, + hidden_untranslated_fallback: usize, + ignored_source: usize, + stale_invalid: usize, +} + +struct TranslationSummaryRow { + language: String, + targets: BTreeSet, + overlay_id: String, + overlay_file: String, + counts: TranslationSummaryCounts, +} + +fn print_json_summary(reports: &[ScopedTranslationReport]) { + let summaries = translation_summary_rows(reports) + .into_iter() + .map(|row| { + json!({ + "language": row.language, + "targets": row.targets.into_iter().collect::>(), + "overlay": row.overlay_id, + "file": row.overlay_file, + "direct_translation": row.counts.direct_translation, + "contextual_translation": row.counts.contextual_translation, + "no_change": row.counts.no_change, + "target_adaptation": row.counts.target_adaptation, + "variable_translation": row.counts.variable_translation, + "adapter_id_translation": row.counts.adapter_id_translation, + "untranslated_fallback": row.counts.untranslated_fallback, + "missing_text_translation": row.counts.missing_text_translation, + "hidden_untranslated_fallback": row.counts.hidden_untranslated_fallback, + "ignored_source": row.counts.ignored_source, + "stale_invalid": row.counts.stale_invalid, + }) + }) + .collect::>(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ "summaries": summaries })) + .expect("translation summary JSON serializes") + ); +} + +fn print_human_summary(reports: &[ScopedTranslationReport], full: bool) { + let rows = translation_summary_rows(reports); + println!("{}", color_stdout("Translation coverage summary", "1;36")); + if full { + print_full_human_summary_table(rows); + } else { + print_compact_human_summary_table(rows); + println!("Legend: ctxt=contextual, same=no-change, miss=actionable missing text."); + println!(" hidden/raw=untranslated fallbacks; --summary --full shows overlay/file."); + } +} + +fn print_compact_human_summary_table(rows: Vec) { + let mut table = vec![vec![ + "lang".to_owned(), + "tgt".to_owned(), + "direct".to_owned(), + "ctxt".to_owned(), + "same".to_owned(), + "adapt".to_owned(), + "vars".to_owned(), + "ids".to_owned(), + "miss".to_owned(), + "hidden".to_owned(), + "raw".to_owned(), + "ign".to_owned(), + "stale".to_owned(), + ]]; + for row in rows { + table.push(vec![ + row.language, + row.targets.len().to_string(), + row.counts.direct_translation.to_string(), + row.counts.contextual_translation.to_string(), + row.counts.no_change.to_string(), + row.counts.target_adaptation.to_string(), + row.counts.variable_translation.to_string(), + row.counts.adapter_id_translation.to_string(), + row.counts.missing_text_translation.to_string(), + row.counts.hidden_untranslated_fallback.to_string(), + row.counts.untranslated_fallback.to_string(), + row.counts.ignored_source.to_string(), + row.counts.stale_invalid.to_string(), + ]); + } + let right_aligned = [ + false, true, true, true, true, true, true, true, true, true, true, true, true, + ]; + for line in aligned_table_lines(&table, &right_aligned) { + println!("{line}"); + } +} + +fn print_full_human_summary_table(rows: Vec) { + let mut table = vec![vec![ + "language".to_owned(), + "targets".to_owned(), + "overlay".to_owned(), + "file".to_owned(), + "direct".to_owned(), + "contextual".to_owned(), + "no-change".to_owned(), + "adaptations".to_owned(), + "variables".to_owned(), + "adapter-ids".to_owned(), + "missing-text".to_owned(), + "hidden-fallbacks".to_owned(), + "raw-untranslated".to_owned(), + "ignored".to_owned(), + "stale/invalid".to_owned(), + ]]; + for row in rows { + table.push(vec![ + row.language, + row.targets.len().to_string(), + row.overlay_id, + row.overlay_file, + row.counts.direct_translation.to_string(), + row.counts.contextual_translation.to_string(), + row.counts.no_change.to_string(), + row.counts.target_adaptation.to_string(), + row.counts.variable_translation.to_string(), + row.counts.adapter_id_translation.to_string(), + row.counts.missing_text_translation.to_string(), + row.counts.hidden_untranslated_fallback.to_string(), + row.counts.untranslated_fallback.to_string(), + row.counts.ignored_source.to_string(), + row.counts.stale_invalid.to_string(), + ]); + } + let right_aligned = [ + false, true, false, false, true, true, true, true, true, true, true, true, true, true, true, + ]; + for line in aligned_table_lines(&table, &right_aligned) { + println!("{line}"); + } +} + +fn aligned_table_lines(rows: &[Vec], right_aligned: &[bool]) -> Vec { + let column_count = rows.iter().map(Vec::len).max().unwrap_or(0); + let mut widths = vec![0; column_count]; + for row in rows { + for (index, cell) in row.iter().enumerate() { + widths[index] = widths[index].max(cell.chars().count()); + } + } + rows.iter() + .map(|row| { + let mut line = String::new(); + for (index, width) in widths.iter().enumerate() { + if index > 0 { + line.push_str(" "); + } + let cell = row.get(index).map(String::as_str).unwrap_or_default(); + let padding = width.saturating_sub(cell.chars().count()); + if right_aligned.get(index).copied().unwrap_or(false) { + line.push_str(&" ".repeat(padding)); + line.push_str(cell); + } else { + line.push_str(cell); + line.push_str(&" ".repeat(padding)); + } + } + line.trim_end().to_owned() + }) + .collect() +} + +fn translation_summary_rows(reports: &[ScopedTranslationReport]) -> Vec { + let mut grouped = + BTreeMap::<(String, String, String, TranslationSummaryCounts), BTreeSet>::new(); + for report in reports { + let language = inferred_report_language(report); + let counts = translation_summary_counts(&report.report.entries); + grouped + .entry(( + language, + report.overlay_id.clone(), + report.overlay_file.clone(), + counts, + )) + .or_default() + .insert(report.target.clone()); + } + grouped + .into_iter() + .map( + |((language, overlay_id, overlay_file, counts), targets)| TranslationSummaryRow { + language, + targets, + overlay_id, + overlay_file, + counts, + }, + ) + .collect() +} + +fn translation_summary_counts(entries: &[TranslationCoverageEntry]) -> TranslationSummaryCounts { + let counts = category_counts(entries); + let untranslated_fallback = counts.get("untranslated_fallback").copied().unwrap_or(0); + let missing_text_translation = entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + && default_report_includes_untranslated_fallback(entry) + }) + .count(); + TranslationSummaryCounts { + direct_translation: counts.get("direct_translation").copied().unwrap_or(0), + contextual_translation: counts.get("contextual_translation").copied().unwrap_or(0), + no_change: counts.get("no_change").copied().unwrap_or(0), + target_adaptation: counts.get("target_adaptation").copied().unwrap_or(0), + variable_translation: counts.get("variable_translation").copied().unwrap_or(0), + adapter_id_translation: counts.get("adapter_id_translation").copied().unwrap_or(0), + untranslated_fallback, + missing_text_translation, + hidden_untranslated_fallback: untranslated_fallback + .saturating_sub(missing_text_translation), + ignored_source: counts.get("ignored_source").copied().unwrap_or(0), + stale_invalid: entries + .iter() + .filter(|entry| is_stale_or_invalid(entry.category)) + .count(), + } +} + +fn inferred_report_language(report: &ScopedTranslationReport) -> String { + let stem = Path::new(&report.overlay_file) + .file_stem() + .and_then(|stem| stem.to_str()); + if let Some(language) = language_candidate(stem) { + return language; + } + if let Some(language) = stem + .and_then(|stem| stem.strip_prefix("translation-")) + .and_then(|candidate| language_candidate(Some(candidate))) + { + return language; + } + if let Some((_, suffix)) = report.overlay_id.rsplit_once(".translation.") { + if let Some(language) = language_candidate(Some(suffix)) { + return language; + } + if let Some(language) = suffix + .rsplit_once('.') + .and_then(|(_, last_segment)| language_candidate(Some(last_segment))) + { + return language; + } + } + if let Some(language) = language_candidate(Some(trim_target_language_suffixes(&report.target))) + { + return language; + } + if let Some(language) = stem + .and_then(|stem| stem.rsplit_once(['-', '.'])) + .and_then(|(_, suffix)| language_candidate(Some(suffix))) + { + return language; + } + "unknown".to_owned() +} + +fn trim_target_language_suffixes(target: &str) -> &str { + let mut candidate = target; + loop { + let Some((prefix, suffix)) = candidate.rsplit_once('-') else { + return candidate; + }; + if matches!( + suffix, + "standard" | "extended" | "experimental" | "hardcore" | "release" + ) { + candidate = prefix; + } else { + return candidate; + } + } +} + +fn entry_json(entry: &TranslationCoverageEntry) -> serde_json::Value { + json!({ + "category": entry.category.as_str(), + "path": entry.path, + "source": entry.source, + "old_source": entry.old_source, + "translated": entry.translated, + "context": entry.context, + }) +} + +fn category_counts(entries: &[TranslationCoverageEntry]) -> BTreeMap<&'static str, usize> { + let mut counts = BTreeMap::new(); + for entry in entries { + *counts.entry(entry.category.as_str()).or_insert(0) += 1; + } + counts +} + +fn category_counts_refs(entries: &[&TranslationCoverageEntry]) -> BTreeMap<&'static str, usize> { + let mut counts = BTreeMap::new(); + for entry in entries { + *counts.entry(entry.category.as_str()).or_insert(0) += 1; + } + counts +} + +fn apply_overlay_edits(path: &Path, edits: &OverlayEdits) -> Result { + if edits.is_empty() { + return Ok(0); + } + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let mut output = input.clone(); + if !edits.ignore_paths.is_empty() { + output = insert_ignore_path_lines(&output, &edits.ignore_paths); + } + if !edits.direct.is_empty() { + output = insert_direct_stub_lines(&output, &edits.direct); + } + if !edits.contextual.is_empty() { + output = insert_contextual_stub_lines(&output, &edits.contextual); + } + if !edits.no_change.is_empty() { + output = insert_no_change_lines(&output, &edits.no_change); + } + if output != input { + fs::write(path, output).map_err(|error| format!("{}: {error}", path.display()))?; + } + Ok(edits.count()) +} + +fn insert_direct_stub_lines(input: &str, stubs: &BTreeSet) -> String { + let entries = stubs + .iter() + .map(|source| format!(" {}: {}\n", yaml_scalar(source), yaml_scalar(source))) + .collect::(); + insert_translation_section( + input, + "direct", + &entries, + &["ignore_paths", "require_complete"], + ) +} + +fn insert_contextual_stub_lines(input: &str, stubs: &BTreeMap>) -> String { + let mut entries = String::new(); + for (context, sources) in stubs { + entries.push_str(&format!(" {}:\n", yaml_scalar(context))); + for source in sources { + entries.push_str(&format!( + " {}: {}\n", + yaml_scalar(source), + yaml_scalar(source) + )); + } + } + insert_translation_section( + input, + "contextual", + &entries, + &["direct", "ignore_paths", "require_complete"], + ) +} + +fn insert_no_change_lines(input: &str, sources: &BTreeSet) -> String { + let entries = sources + .iter() + .map(|source| format!(" - {}\n", yaml_scalar(source))) + .collect::(); + insert_translation_section( + input, + "no_change", + &entries, + &["contextual", "direct", "ignore_paths", "require_complete"], + ) +} + +fn insert_ignore_path_lines(input: &str, paths: &BTreeSet) -> String { + let entries = paths + .iter() + .map(|path| format!(" - {}\n", yaml_scalar(path))) + .collect::(); + insert_translation_section(input, "ignore_paths", &entries, &["require_complete"]) +} + +fn insert_translation_section( + input: &str, + section_name: &str, + entries: &str, + preferred_after_sections: &[&str], +) -> String { + let spans = line_spans(input); + let Some(translations_index) = find_translations_line(input, &spans) else { + let insert_after = spans + .iter() + .find(|(start, end)| input[*start..*end].trim_start().starts_with("kind:")) + .map(|(_, end)| *end) + .or_else(|| spans.first().map(|(_, end)| *end)) + .unwrap_or(0); + return insert_at_byte( + input, + insert_after, + &format!("translations:\n {section_name}:\n{entries}"), + ); + }; + + if let Some(rewritten) = + rewrite_inline_empty_translations_line(input, &spans, translations_index) + { + return insert_translation_section( + &rewritten, + section_name, + entries, + preferred_after_sections, + ); + } + + if let Some(section_index) = + find_translation_section(input, &spans, translations_index, section_name) + { + let insert_at = translation_section_end(input, &spans, section_index); + return insert_at_byte(input, insert_at, entries); + } + + for anchor in preferred_after_sections { + if let Some(section_index) = + find_translation_section(input, &spans, translations_index, anchor) + { + let insert_at = translation_section_end(input, &spans, section_index); + return insert_at_byte(input, insert_at, &format!(" {section_name}:\n{entries}")); + } + } + + let insert_at = spans[translations_index].1; + insert_at_byte(input, insert_at, &format!(" {section_name}:\n{entries}")) +} + +fn find_translations_line(input: &str, spans: &[(usize, usize)]) -> Option { + spans.iter().position(|(start, end)| { + let line = &input[*start..*end]; + is_translations_header_line(line) + }) +} + +fn is_translations_header_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed == "translations:" + || trimmed + .strip_prefix("translations:") + .is_some_and(|rest| rest.trim_start().starts_with('#')) + || trimmed == "translations: {}" + || trimmed + .strip_prefix("translations: {}") + .is_some_and(|rest| rest.trim_start().starts_with('#')) +} + +fn rewrite_inline_empty_translations_line( + input: &str, + spans: &[(usize, usize)], + translations_index: usize, +) -> Option { + let (start, end) = spans[translations_index]; + let line = &input[start..end]; + let trimmed = line.trim(); + if !(trimmed == "translations: {}" + || trimmed + .strip_prefix("translations: {}") + .is_some_and(|rest| rest.trim_start().starts_with('#'))) + { + return None; + } + + let line_without_newline = line.trim_end_matches(['\r', '\n']); + let newline = &line[line_without_newline.len()..]; + let prefix_len = line_without_newline + .find("translations:") + .expect("translations header line contains key"); + let prefix = &line_without_newline[..prefix_len]; + let after_empty = line_without_newline[prefix_len + "translations: {}".len()..].trim_start(); + let comment = if after_empty.starts_with('#') { + after_empty + } else { + "" + }; + let replacement = if comment.is_empty() { + format!("{prefix}translations:{newline}") + } else { + format!("{prefix}translations: {comment}{newline}") + }; + + let mut output = String::with_capacity(input.len() + replacement.len()); + output.push_str(&input[..start]); + output.push_str(&replacement); + output.push_str(&input[end..]); + Some(output) +} + +fn find_translation_section( + input: &str, + spans: &[(usize, usize)], + translations_index: usize, + section_name: &str, +) -> Option { + let needle = format!(" {section_name}:"); + spans + .iter() + .enumerate() + .skip(translations_index + 1) + .take_while(|(_, (start, end))| { + let line = &input[*start..*end]; + line.trim().is_empty() || line.trim_start().starts_with('#') || line.starts_with(' ') + }) + .find(|(_, (start, end))| input[*start..*end].trim_end() == needle) + .map(|(index, _)| index) +} + +fn translation_section_end(input: &str, spans: &[(usize, usize)], section_index: usize) -> usize { + spans + .iter() + .enumerate() + .skip(section_index + 1) + .find(|(_, (start, end))| { + let line = &input[*start..*end]; + !line.trim().is_empty() + && !line.trim_start().starts_with('#') + && ((line.starts_with(" ") && !line.starts_with(" ")) || !line.starts_with(' ')) + }) + .map(|(_, (start, _))| *start) + .unwrap_or(input.len()) +} + +fn insert_at_byte(input: &str, index: usize, insertion: &str) -> String { + let mut output = String::with_capacity(input.len() + insertion.len() + 1); + output.push_str(&input[..index]); + if index > 0 && !input[..index].ends_with('\n') { + output.push('\n'); + } + output.push_str(insertion); + output.push_str(&input[index..]); + output +} + +fn line_spans(input: &str) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + let mut start = 0; + for (index, ch) in input.char_indices() { + if ch == '\n' { + spans.push((start, index + 1)); + start = index + 1; + } + } + if start < input.len() || input.is_empty() { + spans.push((start, input.len())); + } + spans +} + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] +struct OverlayChoice { + id: String, + label: String, +} + +fn translation_overlay_choices(args: &TranslationArgs) -> Result, String> { + let manifest = read_manifest(&args.manifest_path)?; + let target_names = selected_target_names(&manifest, args); + let mut choices = BTreeSet::::new(); + for target in target_names { + let plan = plan_manifest_target_with_packages( + &args.manifest_path, + &target, + &args.include_paths, + &args.package_roots, + )?; + for (planned, overlay) in &plan.overlays { + if overlay.kind == OverlayKind::Translation + && args + .language + .as_ref() + .is_none_or(|language| language_matches(language, &target, planned, overlay)) + { + let suffix = if overlay.translations.is_some() { + "" + } else { + " (no translations: dictionary)" + }; + choices.insert(OverlayChoice { + id: planned.id.clone(), + label: format!("{} {}{}", planned.id, planned.display_file, suffix), + }); + } + } + } + Ok(choices.into_iter().collect()) +} + +fn inferred_languages(manifest: &FederatedDeckManifest) -> Vec { + let mut languages = BTreeSet::new(); + for (id, overlay) in &manifest.overlays { + let is_translation_overlay = overlay.kind.as_deref() == Some("translation") + || id.contains("translation.") + || id.contains("translation-"); + if !is_translation_overlay { + continue; + } + maybe_insert_language(id.rsplit(['.', '-']).next(), &mut languages); + let stem = Path::new(&overlay.file) + .file_stem() + .and_then(|stem| stem.to_str()); + if let Some(stem) = stem { + if let Some((_, suffix)) = stem.rsplit_once(['-', '.']) { + maybe_insert_language(Some(suffix), &mut languages); + } else { + maybe_insert_language(Some(stem), &mut languages); + } + } + } + languages.into_iter().collect() +} + +fn maybe_insert_language(candidate: Option<&str>, languages: &mut BTreeSet) { + if let Some(language) = language_candidate(candidate) { + languages.insert(language); + } +} + +fn language_candidate(candidate: Option<&str>) -> Option { + let language = candidate?; + if (2..=8).contains(&language.len()) + && language + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch == '-') + && language != "translation" + { + Some(language.to_owned()) + } else { + None + } +} + +fn note_id_from_path(path: &str) -> Option { + let rest = path.strip_prefix("notes.")?; + let end = [".fields.", ".tags.", ".variables.", ".adapter_ids."] + .into_iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + Some(rest[..end].to_owned()) +} + +fn field_id_from_path(path: &str) -> Option { + let rest = path.split_once(".fields.")?.1; + let end = [".variables.", ".adapter_ids."] + .into_iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + Some(rest[..end].to_owned()) +} + +fn context_parent_candidate(path: &str) -> Option { + if let Some((note_context, _)) = path.split_once(".fields.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_context, _)) = path.split_once(".tags.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_context, _)) = path.split_once(".variables.") + && note_context.starts_with("notes.") + { + return Some(note_context.to_owned()); + } + if let Some((note_type_context, _)) = path.split_once(".fields.") + && note_type_context.starts_with("note_types.") + { + return Some(note_type_context.to_owned()); + } + if let Some((note_type_context, _)) = path.split_once(".card_templates.") + && note_type_context.starts_with("note_types.") + { + return Some(note_type_context.to_owned()); + } + None +} + +fn discover_nearby_manifests() -> Vec { + let mut results = BTreeSet::new(); + if let Ok(current) = env::current_dir() { + discover_manifests_under(¤t, ¤t, 0, &mut results); + for ancestor in current.ancestors().skip(1).take(4) { + let candidate = ancestor.join("brainbrew.yaml"); + if candidate.exists() { + results.insert(relative_to_current(&candidate)); + } + } + } + results.into_iter().take(20).collect() +} + +fn discover_manifests_under( + root: &Path, + dir: &Path, + depth: usize, + results: &mut BTreeSet, +) { + if depth > 4 { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == "target" + || name == ".git" + || name == ".jj" + || name == ".devenv" + || name == ".direnv" + || name == "node_modules" + { + continue; + } + if path.is_file() && name == "brainbrew.yaml" { + results.insert(path.strip_prefix(root).unwrap_or(&path).to_path_buf()); + } else if path.is_dir() { + discover_manifests_under(root, &path, depth + 1, results); + } + } +} + +fn relative_to_current(path: &Path) -> PathBuf { + env::current_dir() + .ok() + .and_then(|current| path.strip_prefix(current).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| path.to_path_buf()) +} + +fn missing_manifest_error(path: &Path) -> String { + let mut message = format!("No Brain Brew manifest found at {}.", path.display()); + if path == Path::new("brainbrew.yaml") { + message.push_str("\n\nRun from a deck workspace, or pass --manifest ."); + } + let manifests = discover_nearby_manifests(); + if !manifests.is_empty() { + message.push_str("\n\nFound possible manifests:"); + for manifest in &manifests { + message.push_str(&format!("\n - {}", manifest.display())); + } + if let Some(first) = manifests.first() { + message.push_str("\n\nTry:"); + message.push_str(&format!( + "\n brainbrew translations --manifest {} --target ", + shell_word(&first.display().to_string()) + )); + message.push_str("\n brainbrew translations --interactive"); + } + } + message +} + +fn equivalent_command(args: &TranslationArgs) -> String { + let mut parts = vec!["brainbrew".to_owned(), "translations".to_owned()]; + push_flag_value( + &mut parts, + "--manifest", + &args.manifest_path.display().to_string(), + ); + if args.all_targets { + parts.push("--all-targets".to_owned()); + } else if let Some(target) = &args.target { + push_flag_value(&mut parts, "--target", target); + } + if let Some(language) = &args.language { + push_flag_value(&mut parts, "--language", language); + } + if let Some(overlay) = &args.overlay { + push_flag_value(&mut parts, "--overlay", overlay); + } + if let Some(note) = &args.note { + push_flag_value(&mut parts, "--note", note); + } + if let Some(field) = &args.field { + push_flag_value(&mut parts, "--field", field); + } + for prefix in &args.path_prefixes { + push_flag_value(&mut parts, "--path-prefix", prefix); + } + if args.apply { + parts.push("--apply".to_owned()); + } + if args.full { + parts.push("--full".to_owned()); + } + parts.join(" ") +} + +fn push_flag_value(parts: &mut Vec, flag: &str, value: &str) { + parts.push(flag.to_owned()); + parts.push(shell_word(value)); +} + +fn shell_word(value: &str) -> String { + if !value.is_empty() + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '=')) + { + value.to_owned() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn color_category(category: TranslationCoverageCategory, text: &str) -> String { + match category { + TranslationCoverageCategory::DirectTranslation => color_stdout(text, "32"), + TranslationCoverageCategory::ContextualTranslation => color_stdout(text, "36"), + TranslationCoverageCategory::NoChange => color_stdout(text, "34"), + TranslationCoverageCategory::TargetAdaptation => color_stdout(text, "35"), + TranslationCoverageCategory::VariableTranslation + | TranslationCoverageCategory::AdapterIdTranslation + | TranslationCoverageCategory::IgnoredSource => color_stdout(text, "2"), + TranslationCoverageCategory::UntranslatedFallback => color_stdout(text, "31"), + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::InvalidTargetAdaptation => color_stdout(text, "33"), + } +} + +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_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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOSTILE_STRINGS: &[(&str, &str)] = &[ + ("empty", ""), + ("leading space", " leading"), + ("trailing space", "trailing "), + ("yaml yes", "yes"), + ("yaml no", "no"), + ("yaml null", "null"), + ("yaml tilde", "~"), + ("yaml float", "1.0"), + ("yaml hex", "0x1F"), + ("single quote", "L'Anse aux Meadows"), + ("double quote", "said \"hello\""), + ("colon space", "capital: Helsinki"), + ("trailing colon", "label:"), + ("hash", "text # not a comment"), + ("accented", "Québec"), + ("cjk", "日本語"), + ("hebrew rtl", "עברית"), + ("newline", "first line\nsecond line"), + ("lone newline", "\n"), + ]; + + #[test] + fn translation_yaml_stub_emission_round_trips_hostile_scalars() { + for (case, value) in HOSTILE_STRINGS { + let input = "id: overlay.translation.hostile\nkind: translation\ntranslations:\n"; + let output = insert_direct_stub_lines(input, &BTreeSet::from([(*value).to_owned()])); + let overlay = brain_brew_formats::canonical_yaml::overlay_from_str(&output) + .unwrap_or_else(|error| { + panic!("{case}: emitted overlay parses: {error}\n{output}") + }); + let translations = overlay + .translations + .unwrap_or_else(|| panic!("{case}: emitted overlay has translations")); + assert_eq!( + translations.direct.get(*value).map(String::as_str), + Some(*value), + "{case}: direct translation source and value round trip" + ); + } + } + + #[test] + fn raw_mode_uses_crlf_line_endings() { + assert_eq!(terminal_line_end(false), "\n"); + assert_eq!(terminal_line_end(true), "\r\n"); + } + + #[test] + fn raw_mode_rendering_returns_cursor_to_column_zero() { + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: None, + terminal_cols: None, + }; + ui.render_select_one("Title", &["one".to_owned(), "two".to_owned()], 0) + .unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Title\r\n\r\n")); + assert!(output.contains(" › one\r\n")); + assert!(output.contains(" two\r\n")); + } + + #[test] + fn long_raw_mode_lists_scroll_to_keep_selection_visible() { + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: Some(3), + terminal_cols: Some(40), + }; + let options = (1..=10) + .map(|index| format!("option-{index}")) + .collect::>(); + ui.render_select_one("Title", &options, 8).unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Showing 8–10 of 10\r\n")); + assert!(!output.contains("option-1\r\n")); + assert!(output.contains(" › option-9\r\n")); + } + + #[test] + fn long_options_are_truncated_to_prevent_wrapping() { + assert_eq!(truncate_chars("abcdef", 4), "abc…"); + let mut input = io::empty(); + let mut output = Vec::new(); + { + let mut ui = TerminalUi { + reader: &mut input, + writer: &mut output, + raw_mode: true, + max_option_rows: None, + terminal_cols: Some(14), + }; + ui.render_select_one("Title", &["very-long-option".to_owned()], 0) + .unwrap(); + } + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(" › very-long…\r\n")); + } +} 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..038d390 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/validate.rs @@ -0,0 +1,184 @@ +use std::path::Path; + +use brain_brew_core::{ComposeReport, ValidationReport}; +use serde_json::json; + +use crate::args::{parse_manifest_target_args, split_json_flag}; +use crate::help; +use crate::io::{ + plan_manifest_target_with_packages, read_and_compose_deck, + read_and_compose_manifest_target_with_packages, read_deck_and_overlays, +}; +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 = if json_output { + let plan = plan_manifest_target_with_packages( + &manifest_args.manifest_path, + &manifest_args.target, + &manifest_args.include_paths, + &manifest_args.package_roots, + )?; + let overlays = plan + .overlays + .iter() + .map(|(_, overlay)| overlay.clone()) + .collect::>(); + match plan.base.compose(&overlays) { + Ok(deck) => deck, + Err(report) => return report_json_compose_failure(report, "invalid deck"), + } + } else { + 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 = if json_output { + let (base, overlays) = read_deck_and_overlays(Path::new(&deck_path), &overlay_paths)?; + let overlays = overlays + .into_iter() + .map(|(_, overlay)| overlay) + .collect::>(); + match base.compose(&overlays) { + Ok(deck) => deck, + Err(report) => return report_json_compose_failure(report, "invalid deck"), + } + } else { + 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 { + report_json_validation_failure(report, "invalid deck") + } else { + eprintln!("{report}"); + Err("invalid deck".to_owned()) + } + } + } +} + +fn report_json_validation_failure(report: ValidationReport, message: &str) -> Result<(), String> { + let errors = report + .errors + .iter() + .map(|error| { + json!({ + "kind": format!("{:?}", error.kind), + "path": error.path, + "message": error.message, + }) + }) + .collect::>(); + report_json_errors(message, errors) +} + +fn report_json_compose_failure(report: ComposeReport, message: &str) -> Result<(), String> { + let errors = report + .errors + .iter() + .map(|error| { + json!({ + "kind": format!("{:?}", error.kind), + "path": error.path, + "message": error.message, + }) + }) + .collect::>(); + report_json_errors(message, errors) +} + +fn report_json_errors(message: &str, errors: Vec) -> Result<(), String> { + output::print_json_error_value(json!({ + "message": message, + "errors": errors, + })); + Err(output::JSON_ERROR_ALREADY_PRINTED.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..46ec597 --- /dev/null +++ b/crates/brain-brew-cli/src/commands/verify.rs @@ -0,0 +1,393 @@ +use std::fs; +use std::path::Path; + +use brain_brew_core::{CanonicalDeck, Overlay, TranslationCoverageCategory, validate_deck_content}; +use brain_brew_formats::{crowdanki, manifest, media_map}; + +use crate::args::parse_verify_args; +use crate::help; +use crate::io::{ + ManifestTargetPlan, SourceContext, include_roots_from_manifest, manifest_root, + plan_manifest_target_with_packages, read_manifest, resolve_include_target_for_context, + root_relative_path, top_level_media_include_path, verify_canonical_deck_format, + verify_manifest_format, verify_overlay_format, +}; +use crate::media_assets::{validate_media_assets, validate_media_references}; +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", + )); + }; + + let base_deck_path = root.join(&manifest.base); + verify_canonical_deck_format(&base_deck_path)?; + verify_included_media_map_format( + &base_deck_path, + &SourceContext { + root: root.clone(), + include_roots: include_roots_from_manifest(&root, &manifest), + }, + )?; + 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 policy = verify_args.translation_coverage.unwrap_or_else(|| { + manifest + .targets + .get(target) + .map(|target| target.translation_coverage) + .unwrap_or_default() + }); + verify_translation_coverage_policy(&plan, policy)?; + 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)?; + } else { + validate_media_references(&deck)?; + } + if !verify_args.skip_content_validation { + verify_deck_content(target, &deck)?; + } + 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_included_media_map_format( + deck_path: &Path, + context: &SourceContext, +) -> Result<(), String> { + let input = fs::read_to_string(deck_path) + .map_err(|error| format!("{}: {error}", deck_path.display()))?; + let value = serde_yaml::from_str::(&input) + .map_err(|error| format!("{}: {error}", deck_path.display()))?; + let Some(include_path) = top_level_media_include_path(&value) + .map_err(|error| format!("{}: {error}", deck_path.display()))? + else { + return Ok(()); + }; + let media_path = resolve_include_target_for_context(&include_path, context)?; + let input = fs::read_to_string(&media_path) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + media_map::from_str(&input).map_err(|error| format!("{}: {error}", media_path.display()))?; + let formatted = media_map::format_str(&input) + .map_err(|error| format!("{}: {error}", media_path.display()))?; + if formatted != input { + return Err(format!( + "{} is not in canonical format", + media_path.display() + )); + } + Ok(()) +} + +fn verify_deck_content(target: &str, deck: &CanonicalDeck) -> Result<(), String> { + let rendered = deck + .render_variables() + .map_err(|error| format!("content validation failed for target {target}: {error}"))?; + let report = validate_deck_content(&rendered); + if report.is_empty() { + Ok(()) + } else { + Err(format!( + "content validation failed for target {target}: {report}" + )) + } +} + +fn verify_translation_coverage_policy( + plan: &ManifestTargetPlan, + policy: manifest::TranslationCoveragePolicy, +) -> Result<(), String> { + let mut current = plan.base.clone(); + for (planned, overlay) in &plan.overlays { + if let Some(report) = current.translation_coverage(overlay) { + if policy == manifest::TranslationCoveragePolicy::Strict { + let missing = report + .entries + .iter() + .filter(|entry| { + entry.category == TranslationCoverageCategory::UntranslatedFallback + }) + .take(10) + .map(|entry| format!("{} source {:?}", entry.path, entry.source)) + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "translation coverage strict policy failed for target {} overlay {} ({}): {} untranslated fallback(s): {}", + plan.target, + planned.id, + planned.display_file, + report + .entries + .iter() + .filter(|entry| entry.category + == TranslationCoverageCategory::UntranslatedFallback) + .count(), + missing.join(", ") + )); + } + } + + if let Some(message) = stale_translation_warning_message( + &plan.target, + &planned.id, + &planned.display_file, + &report.entries, + ) { + if policy == manifest::TranslationCoveragePolicy::Strict { + return Err(format!("translation stale strict policy failed: {message}")); + } + eprintln!("warning: {message}"); + } + } + current = current.compose(std::slice::from_ref(overlay)).map_err(|error| { + format!( + "failed to compose overlay {} for target {} while checking translation coverage: {error}", + planned.id, plan.target + ) + })?; + } + Ok(()) +} + +pub(crate) fn emit_stale_translation_warnings(plan: &ManifestTargetPlan) -> Result<(), String> { + for message in stale_translation_warning_messages(plan)? { + eprintln!("warning: {message}"); + } + Ok(()) +} + +pub(crate) fn emit_stale_translation_warnings_for_overlays( + target: &str, + base: &CanonicalDeck, + overlays: &[(String, Overlay)], +) -> Result<(), String> { + for message in stale_translation_warning_messages_for_overlays(target, base, overlays)? { + eprintln!("warning: {message}"); + } + Ok(()) +} + +fn stale_translation_warning_messages(plan: &ManifestTargetPlan) -> Result, String> { + let mut messages = Vec::new(); + let mut current = plan.base.clone(); + for (planned, overlay) in &plan.overlays { + if let Some(report) = current.translation_coverage(overlay) + && let Some(message) = stale_translation_warning_message( + &plan.target, + &planned.id, + &planned.display_file, + &report.entries, + ) + { + messages.push(message); + } + current = current.compose(std::slice::from_ref(overlay)).map_err(|error| { + format!( + "failed to compose overlay {} for target {} while checking stale translations: {error}", + planned.id, plan.target + ) + })?; + } + Ok(messages) +} + +fn stale_translation_warning_messages_for_overlays( + target: &str, + base: &CanonicalDeck, + overlays: &[(String, Overlay)], +) -> Result, String> { + let mut messages = Vec::new(); + let mut current = base.clone(); + for (display_file, overlay) in overlays { + if let Some(report) = current.translation_coverage(overlay) + && let Some(message) = stale_translation_warning_message( + target, + overlay.id.as_str(), + display_file, + &report.entries, + ) + { + messages.push(message); + } + current = current + .compose(std::slice::from_ref(overlay)) + .map_err(|error| { + format!( + "failed to compose overlay {} while checking stale translations: {error}", + overlay.id + ) + })?; + } + Ok(messages) +} + +fn stale_translation_warning_message( + target: &str, + overlay_id: &str, + display_file: &str, + entries: &[brain_brew_core::TranslationCoverageEntry], +) -> Option { + let stale_translations = stale_translation_warning_details(entries); + if stale_translations.is_empty() { + return None; + } + Some(format!( + "stale translation warning for target {target} overlay {overlay_id} ({display_file}): {} stale translation(s): {}", + stale_translations.len(), + stale_translations + .iter() + .take(10) + .cloned() + .collect::>() + .join(", ") + )) +} + +fn stale_translation_warning_details( + entries: &[brain_brew_core::TranslationCoverageEntry], +) -> Vec { + entries + .iter() + .filter(|entry| entry.category == TranslationCoverageCategory::StaleTranslation) + .map(|entry| { + format!( + "{} old {:?} -> new {:?}", + entry.path, + entry.old_source.as_deref().unwrap_or(""), + entry.source + ) + }) + .collect() +} + +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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use brain_brew_core::TranslationCoverageEntry; + + #[test] + fn stale_translation_warning_message_matches_existing_text() { + let entries = vec![ + TranslationCoverageEntry { + category: TranslationCoverageCategory::StaleTranslation, + path: "notes.note.finland.fields.field.capital".to_owned(), + source: "Helsinki changed".to_owned(), + old_source: Some("Helsinki".to_owned()), + translated: Some("Helsinki".to_owned()), + context: None, + }, + TranslationCoverageEntry { + category: TranslationCoverageCategory::DirectTranslation, + path: "notes.note.sweden.fields.field.capital".to_owned(), + source: "Stockholm".to_owned(), + old_source: None, + translated: Some("Stockholm".to_owned()), + context: None, + }, + ]; + + assert_eq!( + stale_translation_warning_message( + "en-standard", + "overlay.translation.en", + "overlays/languages/en.yaml", + &entries, + ), + Some( + "stale translation warning for target en-standard overlay overlay.translation.en (overlays/languages/en.yaml): 1 stale translation(s): notes.note.finland.fields.field.capital old \"Helsinki\" -> new \"Helsinki changed\"" + .to_owned() + ) + ); + } +} diff --git a/crates/brain-brew-cli/src/commands/workbench.rs b/crates/brain-brew-cli/src/commands/workbench.rs new file mode 100644 index 0000000..345b3ba --- /dev/null +++ b/crates/brain-brew-cli/src/commands/workbench.rs @@ -0,0 +1,4985 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::SystemTime; + +use axum::body::Body; +use axum::extract::{Path as AxumPath, Query, State}; +use axum::http::{StatusCode, Uri, header}; +use axum::response::Response; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use brain_brew_core::{ + CanonicalDeck, CardTemplate, Note, NoteType, Overlay, OverlayKind, StableId, StaleTranslation, + TranslationCoverageCategory, TranslationCoverageEntry, TranslationDictionary, + validate_deck_content, +}; +use brain_brew_formats::canonical_yaml; +use brain_brew_formats::manifest::{ + self, BuildTarget, FederatedDeckManifest, LanguageManifestEntry, MetadataCategory, + OverlayManifestEntry, TargetExports, +}; +use include_dir::{Dir, include_dir}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; +use tokio::task; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::commands::translation_overlay::compose_lenient_translation_overlay; +use crate::help; +use crate::io::{manifest_root, plan_manifest_target_with_packages, read_manifest}; + +static EMBEDDED_WORKBENCH_ASSETS: Dir<'static> = + include_dir!("$CARGO_MANIFEST_DIR/assets/workbench"); + +pub(crate) fn run(args: &[String]) -> Result<(), String> { + if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + return Ok(()); + } + + let Some(subcommand) = args.first().map(String::as_str) else { + return Err(help::usage_error( + "workbench", + "usage: brainbrew workbench serve --manifest brainbrew.yaml", + )); + }; + if subcommand == "serve" + && args + .get(1) + .is_some_and(|arg| arg == "--help" || arg == "-h") + { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + return Ok(()); + } + match subcommand { + "serve" => serve_sync(parse_serve_args(&args[1..])?), + "--help" | "-h" => { + print!( + "{}", + help::command("workbench").expect("workbench help exists") + ); + Ok(()) + } + other => Err(format!("unexpected workbench subcommand {other:?}")), + } +} + +#[derive(Clone, Debug)] +struct ServeArgs { + manifest_path: PathBuf, + port: u16, + open_browser: bool, + dev_assets: Option, + media_root: Option, +} + +fn parse_serve_args(args: &[String]) -> Result { + let mut parsed = ServeArgs { + manifest_path: PathBuf::from("brainbrew.yaml"), + port: 0, + open_browser: true, + dev_assets: None, + media_root: None, + }; + 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()); + }; + parsed.manifest_path = PathBuf::from(path); + index += 2; + } + "--port" => { + let Some(port) = args.get(index + 1) else { + return Err("--port requires a port number".to_owned()); + }; + parsed.port = port + .parse::() + .map_err(|_| format!("invalid --port value {port:?}"))?; + index += 2; + } + "--no-open" => { + parsed.open_browser = false; + index += 1; + } + "--dev-assets" => { + let Some(path) = args.get(index + 1) else { + return Err("--dev-assets requires a directory".to_owned()); + }; + parsed.dev_assets = Some(PathBuf::from(path)); + index += 2; + } + "--media-root" => { + let Some(path) = args.get(index + 1) else { + return Err("--media-root requires a directory".to_owned()); + }; + parsed.media_root = Some(PathBuf::from(path)); + index += 2; + } + other => return Err(format!("unexpected workbench serve argument {other:?}")), + } + } + if let Some(path) = &parsed.dev_assets + && !path.is_dir() + { + return Err(format!( + "--dev-assets directory {} does not exist", + path.display() + )); + } + if let Some(path) = &parsed.media_root + && !path.is_dir() + { + return Err(format!( + "--media-root directory {} does not exist", + path.display() + )); + } + Ok(parsed) +} + +fn serve_sync(args: ServeArgs) -> Result<(), String> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|error| format!("failed to start Tokio runtime: {error}"))?; + runtime.block_on(serve(args)) +} + +async fn serve(args: ServeArgs) -> Result<(), String> { + let manifest = read_manifest(&args.manifest_path)?; + let metadata = Arc::new(WorkspaceMetadata::load( + &args.manifest_path, + manifest, + args.media_root.clone(), + )); + let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port)) + .await + .map_err(|error| format!("failed to bind workbench server: {error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("failed to inspect workbench server address: {error}"))?; + let url = format!("http://{address}"); + + println!("Workbench listening at {url}"); + io::stdout() + .flush() + .map_err(|error| format!("failed to flush workbench URL: {error}"))?; + + if args.open_browser { + open_browser(&url); + } + + axum::serve(listener, app(metadata, args.dev_assets)) + .await + .map_err(|error| format!("workbench server failed: {error}")) +} + +fn app(metadata: Arc, dev_assets: Option) -> Router { + let router = Router::new() + .route("/api/health", get(health)) + .route("/api/workspace", get(workspace)) + .route("/api/workbench/note-list", get(note_list)) + .route("/api/workbench/note-detail", get(note_detail)) + .route("/api/workbench/card-list", get(card_list)) + .route("/api/workbench/source-string-list", get(source_string_list)) + .route("/api/workbench/metadata-list", get(optional_metadata_list)) + .route( + "/api/workbench/optional-metadata-list", + get(optional_metadata_list), + ) + .route("/api/workbench/note-pivot", get(note_pivot)) + .route( + "/api/workbench/source-string-pivot", + get(source_string_pivot), + ) + .route("/api/workbench/card-pivot", get(card_pivot)) + .route("/api/workbench/comparison-pane", get(comparison_pane)) + .route("/api/workbench/metadata", get(optional_metadata)) + .route("/api/workbench/optional-metadata", get(optional_metadata)) + .route( + "/api/workbench/new-language-preview", + get(new_language_preview), + ) + .route("/api/workbench/new-language", post(create_new_language)) + .route("/api/workbench/apply-preview", post(apply_preview)) + .route("/api/workbench/apply", post(apply_edits)) + .route("/api/media/{*path}", get(media_asset)) + .route("/favicon.ico", get(favicon)) + .with_state(metadata); + + if let Some(assets) = dev_assets { + router.fallback_service( + ServeDir::new(&assets).not_found_service(ServeFile::new(assets.join("index.html"))), + ) + } else { + router.fallback(get(embedded_asset)) + } +} + +async fn health(State(metadata): State>) -> Json { + Json(json!({ + "status": "ok", + "manifest": metadata.manifest_path.display().to_string(), + })) +} + +async fn workspace( + State(metadata): State>, +) -> Result, (StatusCode, String)> { + task::spawn_blocking(move || metadata.workspace_json().map(Json)) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("workbench task failed: {error}"), + ) + })? + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error)) +} + +async fn note_list( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.note_list_json(&query).map(Json)).await +} + +async fn note_detail( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.note_detail_json(&query).map(Json)).await +} + +async fn card_list( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.card_list_json(&query).map(Json)).await +} + +async fn source_string_list( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.source_string_list_json(&query).map(Json)).await +} + +async fn optional_metadata_list( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.optional_metadata_list_json(&query).map(Json)).await +} + +async fn note_pivot( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.note_pivot_json(&query).map(Json)).await +} + +async fn source_string_pivot( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.source_string_pivot_json(&query).map(Json)).await +} + +async fn card_pivot( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.card_pivot_json(&query).map(Json)).await +} + +async fn comparison_pane( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.comparison_pane_json(&query).map(Json)).await +} + +async fn optional_metadata( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.optional_metadata_json(&query).map(Json)).await +} + +async fn new_language_preview( + State(metadata): State>, + Query(query): Query, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.new_language_preview_json(&query).map(Json)).await +} + +async fn create_new_language( + State(metadata): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || metadata.create_new_language_json(request).map(Json)).await +} + +async fn apply_preview( + State(metadata): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || { + metadata + .apply_request_json(request, ApplyMode::Preview) + .map(Json) + }) + .await +} + +async fn apply_edits( + State(metadata): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + run_workbench_blocking(move || { + metadata + .apply_request_json(request, ApplyMode::Write) + .map(Json) + }) + .await +} + +async fn media_asset( + State(metadata): State>, + AxumPath(path): AxumPath, +) -> Result { + run_workbench_blocking(move || metadata.media_response(&path)).await +} + +async fn favicon() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +async fn run_workbench_blocking(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + task::spawn_blocking(operation) + .await + .map_err(|error| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("workbench task failed: {error}"), + ) + })? + .map_err(workbench_api_error) +} + +fn workbench_api_error(error: String) -> (StatusCode, String) { + let status = if error.starts_with("invalid ") + || error.starts_with("missing ") + || error.starts_with("no ") + || error.starts_with("unknown ") + { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, error) +} + +async fn embedded_asset(uri: Uri) -> Response { + let requested_path = uri.path().trim_start_matches('/'); + let asset_path = if requested_path.is_empty() { + "index.html" + } else { + requested_path + }; + + if let Some(file) = EMBEDDED_WORKBENCH_ASSETS.get_file(asset_path) { + return embedded_file_response(asset_path, file.contents()); + } + + let index = EMBEDDED_WORKBENCH_ASSETS + .get_file("index.html") + .expect("embedded workbench index.html exists"); + embedded_file_response("index.html", index.contents()) +} + +fn embedded_file_response(path: &str, contents: &'static [u8]) -> Response { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, embedded_content_type(path)) + .body(Body::from(contents)) + .expect("embedded asset response is valid") +} + +fn embedded_content_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("css") => "text/css; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +struct WorkspaceMetadata { + manifest_path: PathBuf, + manifest_root: PathBuf, + media_root: Option, + cache: tokio::sync::RwLock, + apply_mutex: Mutex<()>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileSignature { + len: u64, + modified: Option, +} + +#[derive(Clone)] +struct CachedValue { + generation: u64, + value: T, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct SelectionCacheKey { + language_code: String, + target_label: String, + overlay_label: String, +} + +struct WorkspaceCache { + generation: u64, + manifest: FederatedDeckManifest, + file_signatures: BTreeMap>, + declared_media_paths: Option>>, + selected_contexts: BTreeMap>, +} + +impl WorkspaceCache { + fn new(manifest: FederatedDeckManifest) -> Self { + Self { + generation: 0, + manifest, + file_signatures: BTreeMap::new(), + declared_media_paths: None, + selected_contexts: BTreeMap::new(), + } + } + + fn clear_generation_caches(&mut self) { + self.declared_media_paths = None; + self.selected_contexts.clear(); + } +} + +impl WorkspaceMetadata { + fn load( + manifest_path: &Path, + manifest: FederatedDeckManifest, + media_root: Option, + ) -> Self { + Self { + manifest_path: manifest_path.to_path_buf(), + manifest_root: manifest_root(manifest_path), + media_root, + cache: tokio::sync::RwLock::new(WorkspaceCache::new(manifest)), + apply_mutex: Mutex::new(()), + } + } + + fn lock_apply(&self) -> Result, String> { + self.apply_mutex + .lock() + .map_err(|_| "workbench apply lock poisoned".to_owned()) + } + + fn current_manifest(&self) -> Result { + self.ensure_fresh_cache()?; + Ok(self.cache.blocking_read().manifest.clone()) + } + + fn ensure_fresh_cache(&self) -> Result<(), String> { + let (generation, manifest, previous_signatures) = { + let cache = self.cache.blocking_read(); + ( + cache.generation, + cache.manifest.clone(), + cache.file_signatures.clone(), + ) + }; + let current_signatures = self.tracked_file_signatures(&manifest); + if previous_signatures.is_empty() { + let mut cache = self.cache.blocking_write(); + if cache.file_signatures.is_empty() { + cache.file_signatures = self.tracked_file_signatures(&cache.manifest); + } + return Ok(()); + } + if current_signatures == previous_signatures { + return Ok(()); + } + + let manifest_changed = current_signatures.get(&self.manifest_path) + != previous_signatures.get(&self.manifest_path); + let refreshed_manifest = if manifest_changed { + read_manifest(&self.manifest_path)? + } else { + manifest + }; + let refreshed_signatures = self.tracked_file_signatures(&refreshed_manifest); + + let mut cache = self.cache.blocking_write(); + if cache.generation == generation && cache.file_signatures == previous_signatures { + cache.generation += 1; + cache.manifest = refreshed_manifest; + cache.file_signatures = refreshed_signatures; + cache.clear_generation_caches(); + } + Ok(()) + } + + fn invalidate_workspace_cache_after_write(&self) -> Result<(), String> { + let manifest = read_manifest(&self.manifest_path)?; + let signatures = self.tracked_file_signatures(&manifest); + let mut cache = self.cache.blocking_write(); + cache.generation += 1; + cache.manifest = manifest; + cache.file_signatures = signatures; + cache.clear_generation_caches(); + Ok(()) + } + + fn tracked_file_signatures( + &self, + manifest: &FederatedDeckManifest, + ) -> BTreeMap> { + let mut paths = vec![ + self.manifest_path.clone(), + self.manifest_root.join(&manifest.base), + ]; + paths.extend( + manifest + .overlays + .values() + .map(|overlay| self.manifest_root.join(&overlay.file)), + ); + paths.sort(); + paths.dedup(); + paths + .into_iter() + .map(|path| { + let signature = fs::metadata(&path).ok().map(|metadata| FileSignature { + len: metadata.len(), + modified: metadata.modified().ok(), + }); + (path, signature) + }) + .collect() + } + + fn workspace_json(&self) -> Result { + let manifest = self.current_manifest()?; + let fingerprints = file_fingerprints(&self.manifest_path, &self.manifest_root, &manifest)?; + Ok(json!({ + "manifest": self.manifest_path.display().to_string(), + "manifest_root": self.manifest_root.display().to_string(), + "languages": languages_json(&manifest), + "target_labels": target_labels_json(&manifest), + "targets": targets_json(&manifest), + "translation_profile": { + "structural_fields": manifest.translation_profile.structural_fields, + "metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "metadata_paths": manifest.translation_profile.metadata_paths, + "metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "metadata_category_order": manifest.translation_profile.metadata_category_order, + }, + "fingerprints": fingerprints, + })) + } + + fn note_list_json(&self, query: &NoteListQuery) -> Result { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(note_list_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + pagination, + )) + } + + fn note_detail_json(&self, query: &NoteDetailQuery) -> Result { + let manifest = self.current_manifest()?; + let note = query + .note + .as_deref() + .ok_or_else(|| "invalid note detail request: missing note".to_owned())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + let detail = note_pivot_json_from_context(&context, &manifest, None, Some(note)); + if detail["notes"].as_array().is_none_or(Vec::is_empty) { + return Err(format!("unknown note {note:?}")); + } + Ok(detail) + } + + fn card_list_json(&self, query: &CardListQuery) -> Result { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(card_list_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + query.content_group.as_deref(), + pagination, + )) + } + + fn source_string_list_json(&self, query: &SourceStringListQuery) -> Result { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(source_string_list_json_from_context( + &context, + &manifest, + query.content_group.as_deref(), + query.status.as_deref(), + pagination, + )) + } + + fn optional_metadata_list_json( + &self, + query: &OptionalMetadataListQuery, + ) -> Result { + let manifest = self.current_manifest()?; + let pagination = parse_pagination(query.limit.as_deref(), query.offset.as_deref())?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(optional_metadata_list_json_from_context( + &context, &manifest, pagination, + )) + } + + fn note_pivot_json(&self, query: &NotePivotQuery) -> Result { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(note_pivot_json_from_context( + &context, + &manifest, + query.filter.as_deref(), + query.note.as_deref(), + )) + } + + fn source_string_pivot_json(&self, query: &SourceStringPivotQuery) -> Result { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(source_string_pivot_json_from_context( + &context, + &manifest, + query.source.as_deref(), + query.content_group.as_deref(), + query.status.as_deref(), + )) + } + + fn card_pivot_json(&self, query: &CardPivotQuery) -> Result { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(card_pivot_json_from_context( + &context, + &manifest, + query.card.as_deref(), + query.filter.as_deref(), + query.content_group.as_deref(), + )) + } + + fn optional_metadata_json(&self, query: &OptionalMetadataQuery) -> Result { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(optional_metadata_json_from_context(&context, &manifest)) + } + + fn comparison_pane_json(&self, query: &ComparisonPaneQuery) -> Result { + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + query.language.as_deref(), + query.target.as_deref(), + query.overlay.as_deref(), + )?; + Ok(json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + }, + "target_label": context.selection.target_label, + "content_groups": main_field_rows(&context.source_deck, &context.selection, &context.report.entries) + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(), + "note_pivot": note_pivot_json_from_context(&context, &manifest, None, None), + "source_string_pivot": source_string_pivot_json_from_context( + &context, + &manifest, + query.source.as_deref(), + query.content_group.as_deref(), + None, + ), + "card_pivot": card_pivot_json_from_context( + &context, + &manifest, + query.card.as_deref(), + None, + query.content_group.as_deref(), + ), + })) + } + + fn new_language_preview_json(&self, query: &NewLanguagePreviewQuery) -> Result { + let manifest = self.current_manifest()?; + let template = if let Some(template) = &query.template { + template.clone() + } else { + default_template_language(&manifest) + .ok_or_else(|| "missing template language".to_owned())? + }; + let code = query.code.as_deref().unwrap_or("new"); + let display_name = query.display_name.as_deref().unwrap_or(code); + let request = default_new_language_request(&manifest, code, display_name, &template)?; + let (updated, overlay_writes) = + apply_new_language_request(&self.manifest_root, &manifest, &request)?; + Ok(new_language_preview_response( + &updated, + &request, + &overlay_writes, + )) + } + + fn create_new_language_json(&self, request: NewLanguageRequest) -> Result { + let _apply_guard = self.lock_apply()?; + let manifest = self.current_manifest()?; + let (updated, overlay_writes) = + apply_new_language_request(&self.manifest_root, &manifest, &request)?; + let manifest_yaml = manifest::to_string(&updated); + manifest::from_str(&manifest_yaml) + .map_err(|error| format!("invalid generated manifest: {error}"))?; + + let mut writes = Vec::new(); + let mut overlay_write_targets = Vec::new(); + for (relative_path, overlay) in overlay_writes { + let relative = safe_new_language_relative_path(&relative_path)?; + let path = self.manifest_root.join(&relative); + let overlay_yaml = canonical_yaml::overlay_to_string(&overlay); + canonical_yaml::overlay_from_str(&overlay_yaml) + .map_err(|error| format!("invalid generated overlay {relative_path}: {error}"))?; + overlay_write_targets.push(AtomicFileWrite::text(path, relative_path, overlay_yaml)); + } + for write in &overlay_write_targets { + if write.path.exists() { + return Err(format!( + "invalid new language overlay file already exists: {}", + write.path.display() + )); + } + } + writes.extend(overlay_write_targets); + writes.push(AtomicFileWrite::text( + self.manifest_path.clone(), + workspace_path(&self.manifest_root, &self.manifest_path), + manifest_yaml, + )); + write_files_atomically(&writes)?; + self.invalidate_workspace_cache_after_write()?; + + Ok(json!({ + "created": true, + "language": request.code, + "workspace": self.workspace_json()?, + })) + } + + fn apply_request_json(&self, request: ApplyRequest, mode: ApplyMode) -> Result { + let _apply_guard = if mode == ApplyMode::Write { + Some(self.lock_apply()?) + } else { + None + }; + let manifest = self.current_manifest()?; + let context = self.selected_translation_context( + &manifest, + request.language.as_deref(), + request.target.as_deref(), + request.overlay.as_deref(), + )?; + if request.edits.is_empty() { + return Err("missing staged edits".to_owned()); + } + + let source_edits = request + .edits + .iter() + .filter(|edit| edit.kind == WorkbenchEditKind::Source) + .cloned() + .collect::>(); + let mut translation_groups = BTreeMap::< + (String, String, String), + (SelectedTranslationContext, Vec), + >::new(); + for edit in request + .edits + .iter() + .filter(|edit| edit.kind == WorkbenchEditKind::Translation) + { + let group_context = self.selected_translation_context( + &manifest, + edit.language.as_deref().or(request.language.as_deref()), + edit.target.as_deref().or(request.target.as_deref()), + edit.overlay.as_deref().or(request.overlay.as_deref()), + )?; + let key = ( + group_context.selection.language_code.clone(), + group_context.selection.target_label.clone(), + group_context.selection.overlay_label.clone(), + ); + translation_groups + .entry(key) + .or_insert_with(|| (group_context, Vec::new())) + .1 + .push(edit.clone()); + } + + let mut primary_overlay = read_overlay_for_rewrite(&context.selection.overlay_file)?; + let mut modified_base = context.base_deck.clone(); + let base_file = self.manifest_root.join(&manifest.base); + let mut source_plan = apply_staged_source_edits( + &mut modified_base, + &mut primary_overlay, + &source_edits, + &context, + &base_file, + &self.manifest_root, + )?; + + let mut changes = Vec::new(); + for change in std::mem::take(&mut source_plan.changed_entries) { + let file = if change["mode"] == "source" { + workspace_path(&self.manifest_root, &base_file) + } else { + context.selection.overlay_display_file.clone() + }; + changes.push(annotated_apply_change( + change, + &file, + &context, + &modified_base, + )); + } + + let mut validation_errors = + validate_modified_base_and_overlay(&context, &modified_base, &primary_overlay).errors; + let mut overlay_writes = BTreeMap::::new(); + if source_plan.overlay_changed { + overlay_writes.insert( + context.selection.overlay_file.clone(), + ( + context.selection.overlay_display_file.clone(), + primary_overlay.clone(), + true, + ), + ); + } + + for (_, (group_context, translation_edits)) in translation_groups { + let mut overlay = + if group_context.selection.overlay_file == context.selection.overlay_file { + primary_overlay.clone() + } else { + read_overlay_for_rewrite(&group_context.selection.overlay_file)? + }; + let context_after_source = context_with_modified_base_and_overlay( + &group_context, + modified_base.clone(), + &overlay, + )?; + let group_changes = apply_staged_edits_to_overlay( + &mut overlay, + &translation_edits, + &context_after_source, + )?; + for change in group_changes { + changes.push(annotated_apply_change( + change, + &group_context.selection.overlay_display_file, + &group_context, + &modified_base, + )); + } + let validation = + validate_modified_base_and_overlay(&group_context, &modified_base, &overlay); + validation_errors.extend(validation.errors); + overlay_writes.insert( + group_context.selection.overlay_file.clone(), + ( + group_context.selection.overlay_display_file.clone(), + overlay, + !translation_edits.is_empty(), + ), + ); + } + + if changes.is_empty() { + return Err("missing staged edits".to_owned()); + } + if mode == ApplyMode::Write && !validation_errors.is_empty() { + return Err("validation failed; preview before applying".to_owned()); + } + if mode == ApplyMode::Write { + let mut writes = source_apply_writes( + &source_plan, + &modified_base, + &base_file, + &self.manifest_root, + )?; + for (path, (display_file, overlay, changed)) in &overlay_writes { + if !changed { + continue; + } + let overlay_yaml = canonical_yaml::overlay_to_string(overlay); + canonical_yaml::overlay_from_str(&overlay_yaml).map_err(|error| { + format!("invalid generated translation overlay {display_file}: {error}") + })?; + writes.push(AtomicFileWrite::text( + path.clone(), + display_file.clone(), + overlay_yaml, + )); + } + write_files_atomically(&writes)?; + self.invalidate_workspace_cache_after_write()?; + } + + let mut affected_files = source_plan.affected_files_json(&self.manifest_root); + for (path, (display_file, _overlay, changed)) in &overlay_writes { + if *changed { + push_unique_affected_file( + &mut affected_files, + &self.manifest_root, + path, + Some(display_file), + ); + } + } + let file_groups = apply_file_groups_json(&changes); + let validation_ok = validation_errors.is_empty(); + + Ok(json!({ + "mode": mode.as_str(), + "applied": mode == ApplyMode::Write, + "language": context.selection.language_code, + "target_label": context.selection.target_label, + "target_id": context.selection.target_id, + "overlay_label": context.selection.overlay_label, + "overlay_id": context.selection.overlay_id, + "affected_files": affected_files, + "file_groups": file_groups, + "changed_entries": changes, + "validation": { + "ok": validation_ok, + "errors": validation_errors, + }, + })) + } + + fn media_response(&self, requested_path: &str) -> Result { + let requested_path = safe_media_relative_path(requested_path)?; + if !self.media_path_declared(&requested_path)? { + return Err(format!("unknown media asset {requested_path:?}")); + } + + let candidates = self.media_file_candidates(&requested_path); + if let Some(path) = candidates.iter().find(|path| path.is_file()) { + let bytes = fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, media_content_type(&requested_path)) + .body(Body::from(bytes)) + .map_err(|error| format!("failed to build media response: {error}")); + } + + let placeholder = missing_media_placeholder_svg(&requested_path); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/svg+xml") + .body(Body::from(placeholder)) + .map_err(|error| format!("failed to build missing media placeholder: {error}")) + } + + fn media_file_candidates(&self, requested_path: &str) -> Vec { + let mut candidates = vec![ + self.manifest_root.join(requested_path), + self.manifest_root.join("media").join(requested_path), + ]; + if let Some(media_root) = &self.media_root { + candidates.push(media_root.join(requested_path)); + candidates.push(media_root.join("media").join(requested_path)); + } + if let Some(deck_dir_name) = self + .manifest_root + .file_name() + .and_then(|name| name.to_str()) + { + for ancestor in self.manifest_root.ancestors() { + candidates.push( + ancestor + .join("external") + .join(deck_dir_name) + .join("media") + .join(requested_path), + ); + candidates.push( + ancestor + .join("external") + .join(deck_dir_name) + .join(requested_path), + ); + } + } + candidates + } + + fn media_path_declared(&self, requested_path: &str) -> Result { + self.ensure_fresh_cache()?; + let (generation, manifest, cached) = { + let cache = self.cache.blocking_read(); + ( + cache.generation, + cache.manifest.clone(), + cache.declared_media_paths.clone(), + ) + }; + if let Some(cached) = cached + && cached.generation == generation + { + return Ok(cached.value.contains(requested_path)); + } + + let mut cache = self.cache.blocking_write(); + if let Some(cached) = &cache.declared_media_paths + && cached.generation == cache.generation + { + return Ok(cached.value.contains(requested_path)); + } + if cache.generation != generation { + drop(cache); + return self.media_path_declared(requested_path); + } + let declared_media_paths = self.collect_declared_media_paths(&manifest)?; + let declared = declared_media_paths.contains(requested_path); + cache.declared_media_paths = Some(CachedValue { + generation, + value: declared_media_paths, + }); + Ok(declared) + } + + fn collect_declared_media_paths( + &self, + manifest: &FederatedDeckManifest, + ) -> Result, String> { + let mut paths = BTreeSet::new(); + for target_id in manifest.targets.keys() { + let plan = + plan_manifest_target_with_packages(&self.manifest_path, target_id, &[], &[])?; + let mut current = plan.base; + paths.extend(current.media.values().map(|media| media.path.clone())); + for (planned, overlay) in plan.overlays { + current = if overlay.translations.is_some() { + compose_lenient_translation_overlay(¤t, &overlay)? + } else { + current.compose(&[overlay]).map_err(|error| { + format!("failed to compose overlay {}: {error}", planned.id) + })? + }; + paths.extend(current.media.values().map(|media| media.path.clone())); + } + } + Ok(paths) + } + + fn selected_translation_context( + &self, + manifest: &FederatedDeckManifest, + language: Option<&str>, + target_label: Option<&str>, + overlay_label: Option<&str>, + ) -> Result { + let selection = + self.select_translation_target(manifest, language, target_label, overlay_label)?; + let key = SelectionCacheKey { + language_code: selection.language_code.clone(), + target_label: selection.target_label.clone(), + overlay_label: selection.overlay_label.clone(), + }; + let (generation, cached) = { + let cache = self.cache.blocking_read(); + (cache.generation, cache.selected_contexts.get(&key).cloned()) + }; + if let Some(cached) = cached + && cached.generation == generation + { + return Ok(cached.value); + } + + let context = self.build_selected_translation_context(selection)?; + let mut cache = self.cache.blocking_write(); + if let Some(cached) = cache.selected_contexts.get(&key) + && cached.generation == cache.generation + { + return Ok(cached.value.clone()); + } + if cache.generation == generation { + cache.selected_contexts.insert( + key, + CachedValue { + generation, + value: context.clone(), + }, + ); + } + Ok(context) + } + + fn build_selected_translation_context( + &self, + selection: WorkbenchSelection, + ) -> Result { + let plan = plan_manifest_target_with_packages( + &self.manifest_path, + &selection.target_id, + &[], + &[], + )?; + let mut current = plan.base.clone(); + let mut selected_source_deck = None; + let mut selected_report = None; + let mut selected_display_file = selection.overlay_display_file.clone(); + let mut selected_file = selection.overlay_file.clone(); + + for (planned, overlay) in &plan.overlays { + if planned.id == selection.overlay_id { + selected_display_file = planned.display_file.clone(); + selected_file = planned.file.clone(); + selected_source_deck = Some(current.clone()); + selected_report = current.translation_coverage(overlay); + } + current = if overlay.translations.is_some() { + compose_lenient_translation_overlay(¤t, overlay)? + } else { + current + .compose(std::slice::from_ref(overlay)) + .map_err(|error| format!("failed to compose overlay {}: {error}", planned.id))? + }; + } + + let Some(source_deck) = selected_source_deck else { + return Err(format!( + "target {} does not include translation overlay {}", + selection.target_id, selection.overlay_id + )); + }; + let Some(report) = selected_report else { + return Err(format!( + "overlay {} is not a translation overlay", + selection.overlay_id + )); + }; + Ok(SelectedTranslationContext { + selection: WorkbenchSelection { + overlay_file: selected_file, + overlay_display_file: selected_display_file, + ..selection + }, + base_deck: plan.base, + plan_overlays: plan.overlays, + source_deck, + target_deck: current, + report, + }) + } + + fn select_translation_target( + &self, + manifest: &FederatedDeckManifest, + language: Option<&str>, + target_label: Option<&str>, + overlay_label: Option<&str>, + ) -> Result { + let (language_code, language_entry) = if let Some(language) = language { + let entry = manifest + .languages + .get(language) + .ok_or_else(|| format!("unknown language {language:?}"))?; + (language.to_owned(), entry) + } else { + manifest + .languages + .iter() + .find(|(_, entry)| !entry.source && !entry.translation_overlays.is_empty()) + .map(|(code, entry)| (code.clone(), entry)) + .ok_or_else(|| { + "no target language with translation overlays is configured".to_owned() + })? + }; + if language_entry.source { + return Err(format!( + "invalid source language {language_code:?}; choose a target language" + )); + } + if language_entry.translation_overlays.is_empty() { + return Err(format!( + "language {language_code:?} has no translation overlays" + )); + } + + let target_label = target_label + .map(str::to_owned) + .or_else(|| { + (!language_entry.primary_target.is_empty()) + .then(|| language_entry.primary_target.clone()) + }) + .or_else(|| language_entry.targets.keys().next().cloned()) + .ok_or_else(|| format!("language {language_code:?} has no targets"))?; + let target_id = language_entry + .targets + .get(&target_label) + .ok_or_else(|| { + format!("language {language_code:?} has no target label {target_label:?}") + })? + .clone(); + + let overlay_label = overlay_label + .map(str::to_owned) + .or_else(|| { + language_entry + .translation_overlays + .contains_key("base") + .then(|| "base".to_owned()) + }) + .or_else(|| language_entry.translation_overlays.keys().next().cloned()) + .ok_or_else(|| format!("language {language_code:?} has no translation overlays"))?; + let overlay_id = language_entry + .translation_overlays + .get(&overlay_label) + .ok_or_else(|| { + format!("language {language_code:?} has no overlay label {overlay_label:?}") + })? + .clone(); + let overlay_file = manifest + .overlays + .get(&overlay_id) + .map(|overlay| self.manifest_root.join(&overlay.file)) + .ok_or_else(|| format!("overlay {overlay_id:?} is not in the manifest catalog"))?; + + let overlay_badges = language_entry + .translation_overlays + .iter() + .map(|(label, id)| OverlayBadge { + label: label.clone(), + id: id.clone(), + active: label == &overlay_label, + }) + .collect(); + + Ok(WorkbenchSelection { + language_code, + language_display_name: language_entry.display_name.clone(), + target_label, + target_id, + overlay_label, + overlay_id, + overlay_file, + overlay_display_file: String::new(), + overlay_badges, + structural_fields: manifest + .translation_profile + .structural_fields + .iter() + .cloned() + .collect(), + metadata_categories: manifest.translation_profile.metadata_categories.clone(), + metadata_exclude_paths: manifest.translation_profile.metadata_exclude_paths.clone(), + }) + } +} + +fn annotated_apply_change( + mut change: Value, + file: &str, + context: &SelectedTranslationContext, + deck: &CanonicalDeck, +) -> Value { + let path = change["path"].as_str().unwrap_or("").to_owned(); + change["file"] = json!(file); + change["file_kind"] = json!(if change["mode"] == "source" { + "canonical_deck" + } else { + "translation_overlay" + }); + change["language"] = json!(&context.selection.language_code); + change["target_label"] = json!(&context.selection.target_label); + change["overlay_label"] = json!(&context.selection.overlay_label); + change["content_groups"] = json!(content_groups_for_apply_path(deck, &path)); + change +} + +fn content_groups_for_apply_path(deck: &CanonicalDeck, path: &str) -> BTreeSet { + let mut groups = path + .strip_prefix("notes.") + .and_then(|suffix| suffix.split_once(".fields.")) + .and_then(|(note_id, _)| StableId::new(note_id.to_owned()).ok()) + .map(|note_id| content_group_badges_for_note(deck, ¬e_id)) + .unwrap_or_default(); + if groups.is_empty() { + groups.insert("workspace".to_owned()); + } + groups +} + +fn apply_file_groups_json(changes: &[Value]) -> Value { + let mut grouped = BTreeMap::)>::new(); + for change in changes { + let file = change["file"].as_str().unwrap_or("unknown").to_owned(); + let file_kind = change["file_kind"].as_str().unwrap_or("unknown").to_owned(); + let content_groups = change["content_groups"] + .as_array() + .into_iter() + .flatten() + .filter_map(|group| group.as_str()) + .collect::>(); + let (_, groups) = grouped + .entry(file) + .or_insert_with(|| (file_kind, BTreeMap::new())); + if content_groups.is_empty() { + *groups.entry("workspace".to_owned()).or_insert(0) += 1; + } else { + for group in content_groups { + *groups.entry(group.to_owned()).or_insert(0) += 1; + } + } + } + json!( + grouped + .into_iter() + .map(|(file, (kind, groups))| json!({ + "file": file, + "kind": kind, + "content_groups": groups + .into_iter() + .map(|(name, change_count)| json!({ + "name": name, + "change_count": change_count, + })) + .collect::>(), + })) + .collect::>() + ) +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NoteListQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NoteDetailQuery { + language: Option, + target: Option, + overlay: Option, + note: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CardListQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + content_group: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct SourceStringListQuery { + language: Option, + target: Option, + overlay: Option, + content_group: Option, + status: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct OptionalMetadataListQuery { + language: Option, + target: Option, + overlay: Option, + limit: Option, + offset: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NotePivotQuery { + language: Option, + target: Option, + overlay: Option, + filter: Option, + note: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct SourceStringPivotQuery { + language: Option, + target: Option, + overlay: Option, + source: Option, + content_group: Option, + status: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct CardPivotQuery { + language: Option, + target: Option, + overlay: Option, + card: Option, + filter: Option, + content_group: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct OptionalMetadataQuery { + language: Option, + target: Option, + overlay: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct ComparisonPaneQuery { + language: Option, + target: Option, + overlay: Option, + source: Option, + card: Option, + content_group: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct NewLanguagePreviewQuery { + code: Option, + display_name: Option, + template: Option, +} + +const DEFAULT_LIST_LIMIT: usize = 50; +const MAX_LIST_LIMIT: usize = 200; + +#[derive(Clone, Copy, Debug)] +struct Pagination { + limit: usize, + offset: usize, +} + +fn parse_pagination(limit: Option<&str>, offset: Option<&str>) -> Result { + let limit = match limit { + Some(raw) => raw.parse::().map_err(|_| { + format!( + "invalid pagination limit {raw:?}: expected an integer from 1 to {MAX_LIST_LIMIT}" + ) + })?, + None => DEFAULT_LIST_LIMIT, + }; + if limit == 0 || limit > MAX_LIST_LIMIT { + return Err(format!( + "invalid pagination limit {limit}: expected a value from 1 to {MAX_LIST_LIMIT}" + )); + } + let offset = match offset { + Some(raw) => raw.parse::().map_err(|_| { + format!("invalid pagination offset {raw:?}: expected a non-negative integer") + })?, + None => 0, + }; + Ok(Pagination { limit, offset }) +} + +fn paginate(items: &[T], pagination: Pagination) -> (Vec, bool) { + let total = items.len(); + let page = items + .iter() + .skip(pagination.offset) + .take(pagination.limit) + .cloned() + .collect::>(); + let has_more = pagination.offset.saturating_add(pagination.limit) < total; + (page, has_more) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageRequest { + code: String, + display_name: String, + template_language: String, + primary_target: String, + groups: Vec, + targets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageGroupRequest { + label: String, + template_overlay_id: String, + overlay_id: String, + file: String, + selected: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct NewLanguageTargetRequest { + label: String, + target_id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ApplyRequest { + language: Option, + target: Option, + overlay: Option, + edits: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct StagedWorkbenchEdit { + #[serde(default = "default_workbench_edit_kind")] + kind: WorkbenchEditKind, + language: Option, + target: Option, + overlay: Option, + path: String, + source: String, + value: String, + #[serde(default = "default_edit_mode")] + mode: EditMode, + context_path: Option, + #[serde(default = "default_source_edit_scope")] + scope: SourceEditScope, + #[serde(default = "default_source_impact_action")] + impact_action: SourceImpactAction, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum WorkbenchEditKind { + Translation, + Source, +} + +fn default_workbench_edit_kind() -> WorkbenchEditKind { + WorkbenchEditKind::Translation +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum EditMode { + Direct, + Contextual, + NoChange, +} + +fn default_edit_mode() -> EditMode { + EditMode::Direct +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SourceEditScope { + Field, + AllOccurrences, +} + +fn default_source_edit_scope() -> SourceEditScope { + SourceEditScope::Field +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum SourceImpactAction { + StaleTranslation, + MigrateKey, +} + +fn default_source_impact_action() -> SourceImpactAction { + SourceImpactAction::StaleTranslation +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ApplyMode { + Preview, + Write, +} + +impl ApplyMode { + fn as_str(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Write => "write", + } + } +} + +#[derive(Clone, Debug)] +struct WorkbenchSelection { + language_code: String, + language_display_name: String, + target_label: String, + target_id: String, + overlay_label: String, + overlay_id: String, + overlay_file: PathBuf, + overlay_display_file: String, + overlay_badges: Vec, + structural_fields: BTreeSet, + metadata_categories: Vec, + metadata_exclude_paths: Vec, +} + +#[derive(Clone, Debug)] +struct OverlayBadge { + label: String, + id: String, + active: bool, +} + +#[derive(Clone)] +struct SelectedTranslationContext { + selection: WorkbenchSelection, + base_deck: CanonicalDeck, + plan_overlays: Vec<(crate::io::PlannedOverlay, Overlay)>, + source_deck: CanonicalDeck, + target_deck: CanonicalDeck, + report: brain_brew_core::TranslationCoverageReport, +} + +#[derive(Clone, Debug, Serialize)] +struct ApplyValidation { + ok: bool, + errors: Vec, +} + +fn default_template_language(manifest: &FederatedDeckManifest) -> Option { + manifest + .languages + .iter() + .find(|(_, language)| !language.source && !language.translation_overlays.is_empty()) + .map(|(code, _)| code.clone()) +} + +fn default_new_language_request( + manifest: &FederatedDeckManifest, + code: &str, + display_name: &str, + template_language: &str, +) -> Result { + let template = manifest + .languages + .get(template_language) + .ok_or_else(|| format!("unknown template language {template_language:?}"))?; + if template.source || template.translation_overlays.is_empty() { + return Err(format!( + "invalid template language {template_language:?}; choose a target language" + )); + } + + let groups = template + .translation_overlays + .iter() + .map(|(label, template_overlay_id)| { + default_new_language_group(code, label, template_overlay_id) + }) + .collect(); + let targets = template + .targets + .keys() + .map(|label| NewLanguageTargetRequest { + label: label.clone(), + target_id: format!("{code}-{label}"), + }) + .collect(); + + Ok(NewLanguageRequest { + code: code.to_owned(), + display_name: display_name.to_owned(), + template_language: template_language.to_owned(), + primary_target: template.primary_target.clone(), + groups, + targets, + }) +} + +fn default_new_language_group( + code: &str, + label: &str, + template_overlay_id: &str, +) -> NewLanguageGroupRequest { + let (overlay_id, file) = if label == "base" { + ( + format!("overlay.translation.{code}"), + format!("overlays/languages/{code}.yaml"), + ) + } else { + ( + format!("overlay.translation.{label}.{code}"), + format!("overlays/languages/{label}/{code}.yaml"), + ) + }; + NewLanguageGroupRequest { + label: label.to_owned(), + template_overlay_id: template_overlay_id.to_owned(), + overlay_id, + file, + selected: true, + } +} + +fn apply_new_language_request( + manifest_root: &Path, + manifest: &FederatedDeckManifest, + request: &NewLanguageRequest, +) -> Result<(FederatedDeckManifest, Vec<(String, Overlay)>), String> { + validate_new_language_code(&request.code)?; + if request.display_name.trim().is_empty() { + return Err("invalid new language display name: expected a non-empty value".to_owned()); + } + if manifest.languages.contains_key(&request.code) { + return Err(format!( + "invalid new language code {:?}: language already exists", + request.code + )); + } + let template = manifest + .languages + .get(&request.template_language) + .ok_or_else(|| format!("unknown template language {:?}", request.template_language))?; + if template.source || template.translation_overlays.is_empty() { + return Err(format!( + "invalid template language {:?}; choose a target language", + request.template_language + )); + } + + let template_translation_ids = template + .translation_overlays + .values() + .cloned() + .collect::>(); + let selected_groups = selected_new_language_groups(template, request)?; + if selected_groups.is_empty() { + return Err( + "invalid new language scaffold: select at least one translation overlay group" + .to_owned(), + ); + } + let selected_template_to_new = selected_groups + .iter() + .map(|group| (group.template_overlay_id.clone(), group.overlay_id.clone())) + .collect::>(); + + let mut updated = manifest.clone(); + let mut overlay_writes = Vec::new(); + for group in &selected_groups { + validate_stable_id("overlay id", &group.overlay_id)?; + if manifest.overlays.contains_key(&group.overlay_id) { + return Err(format!( + "invalid new language overlay id {:?}: overlay already exists", + group.overlay_id + )); + } + let relative_path = safe_new_language_relative_path(&group.file)?; + let absolute_path = manifest_root.join(&relative_path); + if absolute_path.exists() { + return Err(format!( + "invalid new language overlay file already exists: {}", + absolute_path.display() + )); + } + let template_overlay = manifest + .overlays + .get(&group.template_overlay_id) + .ok_or_else(|| format!("unknown template overlay {:?}", group.template_overlay_id))?; + let depends_on = template_overlay + .depends_on + .iter() + .filter_map(|dependency| { + if let Some(replacement) = selected_template_to_new.get(dependency) { + Some(replacement.clone()) + } else if template_translation_ids.contains(dependency) { + None + } else { + Some(dependency.clone()) + } + }) + .collect(); + updated.overlays.insert( + group.overlay_id.clone(), + OverlayManifestEntry { + file: group.file.clone(), + kind: Some("translation".to_owned()), + depends_on, + }, + ); + overlay_writes.push(( + group.file.clone(), + Overlay { + id: StableId::new(group.overlay_id.clone()).expect("validated stable id"), + kind: OverlayKind::Translation, + translations: Some(TranslationDictionary::default()), + deck_change: None, + note_changes: BTreeMap::new(), + note_type_changes: BTreeMap::new(), + media_changes: BTreeMap::new(), + }, + )); + } + + let mut language_targets = BTreeMap::new(); + for target in &request.targets { + validate_stable_id("target id", &target.target_id)?; + if manifest.targets.contains_key(&target.target_id) { + return Err(format!( + "invalid new language target id {:?}: target already exists", + target.target_id + )); + } + let template_target_id = template.targets.get(&target.label).ok_or_else(|| { + format!( + "invalid new language target label {:?}: not found on template language", + target.label + ) + })?; + let template_target = manifest + .targets + .get(template_target_id) + .ok_or_else(|| format!("unknown template target {template_target_id:?}"))?; + let overlays = template_target + .overlays + .iter() + .filter_map(|overlay_id| { + if let Some(replacement) = selected_template_to_new.get(overlay_id) { + Some(replacement.clone()) + } else if template_translation_ids.contains(overlay_id) { + None + } else { + Some(overlay_id.clone()) + } + }) + .collect::>(); + updated.targets.insert( + target.target_id.clone(), + BuildTarget { + extends: template_target.extends.clone(), + overlays, + translation_coverage: Default::default(), + exports: TargetExports::default(), + }, + ); + language_targets.insert(target.label.clone(), target.target_id.clone()); + } + if language_targets.is_empty() { + return Err("invalid new language scaffold: expected at least one target".to_owned()); + } + if !language_targets.contains_key(&request.primary_target) { + return Err(format!( + "invalid new language primary target {:?}: not found in target labels", + request.primary_target + )); + } + + let translation_overlays = selected_groups + .iter() + .map(|group| (group.label.clone(), group.overlay_id.clone())) + .collect(); + updated.languages.insert( + request.code.clone(), + LanguageManifestEntry { + display_name: request.display_name.clone(), + source: false, + translation_overlays, + primary_target: request.primary_target.clone(), + targets: language_targets, + }, + ); + + let manifest_yaml = manifest::to_string(&updated); + manifest::from_str(&manifest_yaml) + .map_err(|error| format!("invalid generated manifest: {error}"))?; + for target in &request.targets { + updated + .expand_target(&target.target_id) + .map_err(|error| format!("invalid generated target {:?}: {error}", target.target_id))?; + } + + Ok((updated, overlay_writes)) +} + +fn selected_new_language_groups<'a>( + template: &LanguageManifestEntry, + request: &'a NewLanguageRequest, +) -> Result, String> { + let mut seen_labels = BTreeSet::new(); + let mut selected = Vec::new(); + for group in &request.groups { + if !seen_labels.insert(group.label.clone()) { + return Err(format!( + "invalid new language overlay group {:?}: duplicate label", + group.label + )); + } + let Some(template_overlay_id) = template.translation_overlays.get(&group.label) else { + return Err(format!( + "invalid new language overlay group {:?}: not found on template language", + group.label + )); + }; + if template_overlay_id != &group.template_overlay_id { + return Err(format!( + "invalid new language overlay group {:?}: template overlay changed", + group.label + )); + } + if group.selected { + selected.push(group); + } + } + Ok(selected) +} + +fn new_language_preview_response( + manifest: &FederatedDeckManifest, + request: &NewLanguageRequest, + overlay_writes: &[(String, Overlay)], +) -> Value { + let overlay_files = overlay_writes + .iter() + .map(|(path, overlay)| { + json!({ + "path": path, + "contents": canonical_yaml::overlay_to_string(overlay), + }) + }) + .collect::>(); + let mut affected_files = vec![json!({ "path": "brainbrew.yaml" })]; + affected_files.extend( + overlay_writes + .iter() + .map(|(path, _)| json!({ "path": path })), + ); + json!({ + "validation": { "ok": true, "errors": Vec::::new() }, + "draft": request, + "language": { + "code": &request.code, + "display_name": &request.display_name, + "template_language": &request.template_language, + "primary_target": &request.primary_target, + }, + "groups": &request.groups, + "targets": &request.targets, + "affected_files": affected_files, + "overlay_files": overlay_files, + "manifest_yaml": manifest::to_string(manifest), + }) +} + +fn validate_new_language_code(code: &str) -> Result<(), String> { + if code.is_empty() + || !code + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(format!( + "invalid new language code {code:?}: expected letters, numbers, '-' or '_'" + )); + } + Ok(()) +} + +fn validate_stable_id(kind: &str, value: &str) -> Result<(), String> { + StableId::new(value.to_owned()) + .map(|_| ()) + .map_err(|error| format!("invalid new language {kind} {value:?}: {error}")) +} + +fn safe_new_language_relative_path(path: &str) -> Result { + let candidate = Path::new(path); + if path.is_empty() || path.starts_with('~') || candidate.is_absolute() { + return Err(format!("invalid new language file path {path:?}")); + } + if !candidate + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) + { + return Err(format!("invalid new language file path {path:?}")); + } + Ok(candidate.to_path_buf()) +} + +fn note_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + pagination: Pagination, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let progress = main_progress(&context.source_deck, context, &entries_by_path); + let metadata_progress = optional_metadata_progress(&optional_metadata_rows(context, manifest)); + let rows = note_navigation_rows(context, filter); + let total = rows.len(); + let (page, has_more) = paginate(&rows, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + }, + "progress": progress, + "metadata_progress": metadata_progress, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn card_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + content_group_filter: Option<&str>, + pagination: Pagination, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let all_cards = produced_card_rows(context, &rows); + let progress_total = all_cards.len(); + let progress_missing = all_cards + .iter() + .filter(|card| card.status == "missing") + .count(); + let progress_stale = all_cards + .iter() + .filter(|card| card.status == "stale") + .count(); + let progress_complete = progress_total.saturating_sub(progress_missing + progress_stale); + let content_groups = all_cards + .iter() + .flat_map(|card| card.content_group_badges.iter().cloned()) + .collect::>(); + let cards = all_cards + .into_iter() + .filter(|card| card_matches_filter(card, filter)) + .filter(|card| { + content_group_filter.is_none_or(|filter| { + card.content_group_badges + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let total = cards.len(); + let summaries = cards.iter().map(card_summary_json).collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + }, + "progress": { + "total": progress_total, + "complete": progress_complete, + "missing": progress_missing, + "stale": progress_stale, + "needs_work": progress_missing + progress_stale, + "percent": progress_percent(progress_complete, progress_total), + }, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn source_string_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + content_group_filter: Option<&str>, + status_filter: Option<&str>, + pagination: Pagination, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let status_filter = status_filter.filter(|filter| *filter != "all"); + let all_rows = source_string_rows(context) + .into_iter() + .filter(|row| !row.structural && !row.source.is_empty()) + .collect::>(); + let all_groups = source_string_groups(&context.source_deck, &all_rows); + let progress_total = all_groups.len(); + let progress_missing = all_groups + .iter() + .filter(|group| group.status == "missing") + .count(); + let progress_stale = all_groups + .iter() + .filter(|group| group.status == "stale") + .count(); + let progress_complete = all_groups + .iter() + .filter(|group| group.status == "complete") + .count(); + let content_groups = all_rows + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(); + let rows = all_rows + .into_iter() + .filter(|row| { + content_group_filter.is_none_or(|filter| { + content_group_badges_for_note(&context.source_deck, &row.note_id) + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let source_counts = rows + .iter() + .fold(BTreeMap::::new(), |mut counts, row| { + *counts.entry(row.source.clone()).or_insert(0) += 1; + counts + }); + let grouped = source_string_groups(&context.source_deck, &rows) + .into_iter() + .filter(|group| status_filter.is_none_or(|filter| filter == group.status)) + .collect::>(); + let total = grouped.len(); + let summaries = grouped + .iter() + .map(|group| source_string_summary_json(group, &source_counts)) + .collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + "status": status_filter.unwrap_or("all"), + "statuses": ["all", "missing", "stale", "complete"], + }, + "progress": { + "total": progress_total, + "complete": progress_complete, + "missing": progress_missing, + "stale": progress_stale, + "needs_work": progress_missing + progress_stale, + "percent": progress_percent(progress_complete, progress_total), + }, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + }) +} + +fn optional_metadata_list_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + pagination: Pagination, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let items = optional_metadata_rows(context, manifest); + let total = items.len(); + let summaries = items + .iter() + .map(optional_metadata_summary_json) + .collect::>(); + let (page, has_more) = paginate(&summaries, pagination); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "main_progress": main_progress(&context.source_deck, context, &entries_by_path), + "metadata_progress": optional_metadata_progress(&items), + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + "has_more": has_more, + "rows": page, + "profile_metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "profile_metadata_paths": manifest.translation_profile.metadata_paths, + "profile_metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "profile_metadata_category_order": manifest.translation_profile.metadata_category_order, + }) +} + +fn note_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + filter: Option<&str>, + selected_note: Option<&str>, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let source_counts = main_field_source_counts(&context.source_deck, &context.selection, context); + let progress = main_progress(&context.source_deck, context, &entries_by_path); + let metadata_progress = optional_metadata_progress(&optional_metadata_rows(context, manifest)); + let notes = note_pivot_notes_json( + &context.source_deck, + &context.target_deck, + context, + &entries_by_path, + &source_counts, + filter, + selected_note, + ); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "overlay_badges": overlay_badges_json(context), + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + }, + "progress": progress, + "metadata_progress": metadata_progress, + "notes": notes, + "stale_entries": stale_entries_json(&context.report.entries), + }) +} + +fn optional_metadata_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Value { + let entries_by_path = context + .report + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let items = optional_metadata_rows(context, manifest); + let metadata_progress = optional_metadata_progress(&items); + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "main_progress": main_progress(&context.source_deck, context, &entries_by_path), + "metadata_progress": metadata_progress, + "items": items.iter().map(optional_metadata_item_json).collect::>(), + "profile_metadata_categories": manifest.translation_profile.metadata_categories.iter().map(metadata_category_json).collect::>(), + "profile_metadata_paths": manifest.translation_profile.metadata_paths, + "profile_metadata_exclude_paths": manifest.translation_profile.metadata_exclude_paths, + "profile_metadata_category_order": manifest.translation_profile.metadata_category_order, + }) +} + +fn source_string_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + selected_source: Option<&str>, + content_group_filter: Option<&str>, + status_filter: Option<&str>, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let status_filter = status_filter.filter(|filter| *filter != "all"); + let rows = source_string_rows(context) + .into_iter() + .filter(|row| !row.structural && !row.source.is_empty()) + .filter(|row| { + content_group_filter.is_none_or(|filter| { + content_group_badges_for_note(&context.source_deck, &row.note_id) + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let source_counts = rows + .iter() + .fold(BTreeMap::::new(), |mut counts, row| { + *counts.entry(row.source.clone()).or_insert(0) += 1; + counts + }); + let grouped = source_string_groups(&context.source_deck, &rows); + let filtered_groups = grouped + .into_iter() + .filter(|group| status_filter.is_none_or(|filter| filter == group.status)) + .collect::>(); + let selected_source = selected_source + .filter(|source| filtered_groups.iter().any(|group| group.source == *source)) + .map(str::to_owned) + .or_else(|| filtered_groups.first().map(|group| group.source.clone())); + let strings = filtered_groups + .iter() + .map(|group| { + json!({ + "source": group.source, + "status": group.status, + "main_completion_status": group.status, + "occurrence_count": group.occurrence_count, + "complete_count": group.complete_count, + "missing_count": group.missing_count, + "stale_count": group.stale_count, + "target_preview": group.target_preview, + "content_group_badges": group.content_group_badges, + "direct_recommended": true, + "direct_applies_to": source_counts.get(&group.source).copied().unwrap_or(group.occurrence_count), + "selected": selected_source.as_deref() == Some(group.source.as_str()), + }) + }) + .collect::>(); + let occurrences = selected_source + .as_deref() + .map(|source| { + rows.iter() + .filter(|row| row.source == source) + .map(|row| source_string_occurrence_json(context, row)) + .collect::>() + }) + .unwrap_or_default(); + let content_groups = rows + .iter() + .flat_map(|row| content_group_badges_for_note(&context.source_deck, &row.note_id)) + .collect::>(); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + "status": status_filter.unwrap_or("all"), + "statuses": ["all", "missing", "stale", "complete"], + }, + "strings": strings, + "selected_source": selected_source, + "occurrences": occurrences, + }) +} + +fn card_pivot_json_from_context( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, + selected_card: Option<&str>, + filter: Option<&str>, + content_group_filter: Option<&str>, +) -> Value { + let content_group_filter = content_group_filter.filter(|filter| *filter != "all"); + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let cards = produced_card_rows(context, &rows) + .into_iter() + .filter(|card| card_matches_filter(card, filter)) + .filter(|card| { + content_group_filter.is_none_or(|filter| { + card.content_group_badges + .iter() + .any(|badge| badge == filter) + }) + }) + .collect::>(); + let selected_card_id = selected_card + .filter(|card_id| cards.iter().any(|card| card.card_id == *card_id)) + .map(str::to_owned) + .or_else(|| cards.first().map(|card| card.card_id.clone())); + let selected = selected_card_id + .as_deref() + .and_then(|card_id| cards.iter().find(|card| card.card_id == card_id)) + .map(|card| card_detail_json(context, card)); + let content_groups = cards + .iter() + .flat_map(|card| card.content_group_badges.iter().cloned()) + .collect::>(); + let total = cards.len(); + let missing = cards.iter().filter(|card| card.status == "missing").count(); + let stale = cards.iter().filter(|card| card.status == "stale").count(); + let complete = total.saturating_sub(missing + stale); + + json!({ + "language": { + "code": context.selection.language_code, + "display_name": context.selection.language_display_name, + }, + "target": { + "label": context.selection.target_label, + "id": context.selection.target_id, + }, + "overlay": { + "label": context.selection.overlay_label, + "id": context.selection.overlay_id, + "file": context.selection.overlay_display_file, + }, + "selection_options": selection_options_json(context, manifest), + "filters": { + "active": filter.unwrap_or("all"), + "available": ["all", "missing", "stale", "needs_work"], + "content_group": content_group_filter.unwrap_or("all"), + "content_groups": content_groups, + }, + "progress": { + "total": total, + "complete": complete, + "missing": missing, + "stale": stale, + }, + "cards": cards.iter().map(card_summary_json).collect::>(), + "selected_card_id": selected_card_id, + "selected_card": selected, + }) +} + +#[derive(Clone, Debug)] +struct ProducedCardRow { + card_id: String, + note_id: StableId, + note_type_id: StableId, + template_id: StableId, + template_name: String, + title: String, + status: String, + field_rows: Vec, + content_group_badges: BTreeSet, +} + +fn produced_card_rows( + context: &SelectedTranslationContext, + rows: &[MainFieldRow], +) -> Vec { + let rows_by_note_field = rows + .iter() + .map(|row| ((row.note_id.clone(), row.field_id.clone()), row.clone())) + .collect::>(); + let mut cards = Vec::new(); + for (note_id, note) in &context.source_deck.notes { + let Some(note_type) = context.source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + for template in ¬e_type.card_templates { + let used_fields = card_used_field_ids(note_type, template); + let field_rows = used_fields + .iter() + .filter_map(|field_id| { + rows_by_note_field + .get(&(note_id.clone(), field_id.clone())) + .cloned() + }) + .collect::>(); + let status = card_status(&field_rows); + let card_id = format!("{note_id}::{}", template.id); + cards.push(ProducedCardRow { + card_id, + note_id: note_id.clone(), + note_type_id: note.note_type_id.clone(), + template_id: template.id.clone(), + template_name: template.name.clone(), + title: note_title(Some(note), Some(note_type)), + status: status.to_owned(), + field_rows, + content_group_badges: content_group_badges_for_note(&context.source_deck, note_id), + }); + } + } + cards +} + +fn card_used_field_ids(note_type: &NoteType, template: &CardTemplate) -> BTreeSet { + let template_text = format!("{}\n{}", template.question_format, template.answer_format); + let mut fields = BTreeSet::new(); + for field in ¬e_type.fields { + let name = &field.name; + let markers = [ + format!("{{{{{name}}}}}"), + format!("{{{{#{name}}}}}"), + format!("{{{{^{name}}}}}"), + format!("{{{{type:{name}}}}}"), + ]; + if markers.iter().any(|marker| template_text.contains(marker)) { + fields.insert(field.id.clone()); + } + } + if fields.is_empty() { + fields.extend(note_type.fields.iter().map(|field| field.id.clone())); + } + fields +} + +fn card_status(rows: &[MainFieldRow]) -> &'static str { + if rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) { + "missing" + } else if rows.iter().any(|row| is_stale_category(row.category)) { + "stale" + } else { + "complete" + } +} + +fn card_matches_filter(card: &ProducedCardRow, filter: Option<&str>) -> bool { + match filter.unwrap_or("all") { + "missing" => card.status == "missing", + "stale" => card.status == "stale", + "needs_work" | "needs-work" => matches!(card.status.as_str(), "missing" | "stale"), + _ => true, + } +} + +fn card_summary_json(card: &ProducedCardRow) -> Value { + json!({ + "card_id": card.card_id, + "note_id": card.note_id.to_string(), + "note_type_id": card.note_type_id.to_string(), + "template_id": card.template_id.to_string(), + "template_name": card.template_name, + "title": card.title, + "status": card.status, + "field_count": card.field_rows.len(), + "content_group_badges": card.content_group_badges, + }) +} + +fn card_detail_json(context: &SelectedTranslationContext, card: &ProducedCardRow) -> Value { + let source_note = context.source_deck.notes.get(&card.note_id); + let target_note = context.target_deck.notes.get(&card.note_id).or(source_note); + let source_note_type = context.source_deck.note_types.get(&card.note_type_id); + let target_note_type = target_note + .and_then(|note| context.target_deck.note_types.get(¬e.note_type_id)) + .or(source_note_type); + let target_fields = target_note.map(|note| ¬e.fields); + let fields = card + .field_rows + .iter() + .map(|row| { + let target = target_fields + .and_then(|fields| fields.get(&row.field_id)) + .cloned() + .unwrap_or_else(|| row.translated.clone()); + json!({ + "path": row.path, + "note_id": row.note_id.to_string(), + "note_type_id": row.note_type_id.to_string(), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "source": row.source, + "target": target, + "status": row.category.as_str(), + "structural": row.structural, + "editable": !row.structural, + "source_editable": true, + "context_path": contextual_path_for_row(row, &context.report.entries), + "controls": ["direct", "contextual", "no_change"], + }) + }) + .collect::>(); + json!({ + "card_id": card.card_id, + "note_id": card.note_id.to_string(), + "note_type_id": card.note_type_id.to_string(), + "template_id": card.template_id.to_string(), + "template_name": card.template_name, + "title": card.title, + "status": card.status, + "content_group_badges": card.content_group_badges, + "fields": fields, + "source_preview": source_note.and_then(|note| source_note_type.map(|note_type| render_single_note_card(&context.source_deck, note, note_type, &card.template_id))), + "target_preview": target_note.and_then(|note| target_note_type.map(|note_type| render_single_note_card(&context.target_deck, note, note_type, &card.template_id))), + }) +} + +fn render_single_note_card( + deck: &CanonicalDeck, + note: &Note, + note_type: &NoteType, + template_id: &StableId, +) -> Value { + let rendered_deck = deck.render_variables().unwrap_or_else(|_| deck.clone()); + let rendered_note = rendered_deck.notes.get(¬e.id).unwrap_or(note); + let rendered_note_type = rendered_deck + .note_types + .get(¬e_type.id) + .unwrap_or(note_type); + let cards = rendered_note_type + .card_templates + .iter() + .filter(|template| &template.id == template_id) + .map(|template| render_card(rendered_note, rendered_note_type, template)) + .collect::>(); + json!({ + "styling": rendered_note_type.styling, + "cards": cards, + }) +} + +fn source_string_rows(context: &SelectedTranslationContext) -> Vec { + let structural_fields = structural_field_set(&context.selection, &context.source_deck); + context + .source_deck + .translation_context(&context.report) + .units + .into_iter() + .filter_map(|unit| { + let note_id = unit.note_id?; + let note_type_id = unit.note_type_id?; + let field_id = unit.field_id?; + let field_name = unit.field_name.unwrap_or_else(|| field_id.to_string()); + Some(MainFieldRow { + note_id, + note_type_id, + structural: structural_fields.contains(field_id.as_str()), + field_id, + field_name, + path: unit.path, + source: unit.source.clone(), + category: unit.category, + translated: unit.translated.unwrap_or(unit.source), + }) + }) + .collect() +} + +#[derive(Clone, Debug)] +struct SourceStringGroup { + source: String, + status: String, + occurrence_count: usize, + complete_count: usize, + missing_count: usize, + stale_count: usize, + target_preview: Option, + content_group_badges: BTreeSet, +} + +fn source_string_groups( + source_deck: &CanonicalDeck, + rows: &[MainFieldRow], +) -> Vec { + let mut groups = BTreeMap::>::new(); + for row in rows { + groups.entry(row.source.clone()).or_default().push(row); + } + groups + .into_iter() + .map(|(source, rows)| { + let occurrence_count = rows.len(); + let complete_count = rows + .iter() + .filter(|row| is_complete_translation_category(row.category)) + .count(); + let missing_count = rows + .iter() + .filter(|row| row.category == TranslationCoverageCategory::UntranslatedFallback) + .count(); + let stale_count = rows + .iter() + .filter(|row| is_stale_category(row.category)) + .count(); + let status = if missing_count > 0 { + "missing" + } else if stale_count > 0 { + "stale" + } else { + "complete" + } + .to_owned(); + let target_preview = rows + .iter() + .copied() + .find(|row| row.translated != row.source) + .or_else(|| rows.first().copied()) + .map(|row| row.translated.clone()); + let content_group_badges = rows + .iter() + .flat_map(|row| content_group_badges_for_note(source_deck, &row.note_id)) + .collect::>(); + SourceStringGroup { + source, + status, + occurrence_count, + complete_count, + missing_count, + stale_count, + target_preview, + content_group_badges, + } + }) + .collect() +} + +fn source_string_occurrence_json( + context: &SelectedTranslationContext, + row: &MainFieldRow, +) -> Value { + let note = context.source_deck.notes.get(&row.note_id); + let target_note = context.target_deck.notes.get(&row.note_id).or(note); + let source_note_type = context.source_deck.note_types.get(&row.note_type_id); + let target_note_type = target_note + .and_then(|note| context.target_deck.note_types.get(¬e.note_type_id)) + .or(source_note_type); + json!({ + "path": row.path, + "source": row.source, + "target": if row.path.contains(".message.") { + row.translated.clone() + } else { + target_note + .and_then(|note| note.fields.get(&row.field_id)) + .cloned() + .unwrap_or_else(|| row.translated.clone()) + }, + "status": row.category.as_str(), + "note_id": row.note_id.to_string(), + "note_title": note_title(note, source_note_type), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "friendly_context": format!("{} · {}", note_title(note, source_note_type), row.field_name), + "context_path": contextual_path_for_row(row, &context.report.entries), + "content_group_badges": content_group_badges_for_note(&context.source_deck, &row.note_id), + "direct_recommended": true, + "controls": ["direct", "contextual", "no_change"], + "source_preview": note.and_then(|note| source_note_type.map(|note_type| render_note_cards(&context.source_deck, note, note_type))), + "target_preview": target_note.and_then(|note| target_note_type.map(|note_type| render_note_cards(&context.target_deck, note, note_type))), + }) +} + +fn note_title(note: Option<&Note>, note_type: Option<&NoteType>) -> String { + let Some(note) = note else { + return "unknown note".to_owned(); + }; + if let Some(note_type) = note_type { + for field in ¬e_type.fields { + if let Some(value) = note + .fields + .get(&field.id) + .and_then(|value| title_candidate(value)) + { + return value; + } + } + } + note.fields + .values() + .find_map(|value| title_candidate(value)) + .unwrap_or_else(|| note.id.to_string()) +} + +fn title_candidate(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.trim_start().starts_with('<') { + return None; + } + Some(value.to_owned()) +} + +fn content_group_badges_for_note( + source_deck: &CanonicalDeck, + note_id: &StableId, +) -> BTreeSet { + let mut badges = BTreeSet::new(); + if let Some(note) = source_deck.notes.get(note_id) { + badges.insert(note.note_type_id.to_string()); + badges.extend(note.tags.iter().cloned()); + } + badges +} + +fn is_complete_translation_category(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) +} + +fn overlay_badges_json(context: &SelectedTranslationContext) -> Value { + json!( + context + .selection + .overlay_badges + .iter() + .map(|badge| json!({ + "label": badge.label, + "id": badge.id, + "active": badge.active, + })) + .collect::>() + ) +} + +fn metadata_category_json(category: &MetadataCategory) -> Value { + json!({ + "key": category.key, + "label": category.label, + "paths": category.paths, + }) +} + +fn selection_options_json( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Value { + let languages = manifest + .languages + .iter() + .filter(|(_, entry)| !entry.source && !entry.translation_overlays.is_empty()) + .map(|(code, entry)| { + json!({ + "code": code, + "display_name": entry.display_name, + "active": code == &context.selection.language_code, + }) + }) + .collect::>(); + + let Some(language_entry) = manifest.languages.get(&context.selection.language_code) else { + return json!({ + "languages": languages, + "targets": [], + "overlays": [], + }); + }; + + let targets = language_entry + .targets + .iter() + .map(|(label, id)| { + json!({ + "label": label, + "id": id, + "active": label == &context.selection.target_label, + }) + }) + .collect::>(); + let overlays = language_entry + .translation_overlays + .iter() + .map(|(label, id)| { + json!({ + "label": label, + "id": id, + "active": label == &context.selection.overlay_label, + }) + }) + .collect::>(); + + json!({ + "languages": languages, + "targets": targets, + "overlays": overlays, + }) +} + +fn main_field_source_counts( + source_deck: &CanonicalDeck, + _selection: &WorkbenchSelection, + context: &SelectedTranslationContext, +) -> BTreeMap { + let mut counts = BTreeMap::new(); + for row in main_field_rows(source_deck, &context.selection, &context.report.entries) { + *counts.entry(row.source).or_insert(0) += 1; + } + counts +} + +#[derive(Clone, Debug)] +struct MainFieldRow { + note_id: StableId, + note_type_id: StableId, + field_id: StableId, + field_name: String, + path: String, + source: String, + structural: bool, + category: TranslationCoverageCategory, + translated: String, +} + +fn main_field_rows( + source_deck: &CanonicalDeck, + selection: &WorkbenchSelection, + entries: &[TranslationCoverageEntry], +) -> Vec { + let structural_fields = structural_field_set(selection, source_deck); + let entries_by_path = entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect::>(); + let mut rows = Vec::new(); + for (note_id, note) in &source_deck.notes { + let Some(note_type) = source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + for field in ¬e_type.fields { + let source = note.fields.get(&field.id).cloned().unwrap_or_default(); + if source.is_empty() { + continue; + } + let path = format!("notes.{note_id}.fields.{}", field.id); + let structural = structural_fields.contains(field.id.as_str()); + let entry = entries_by_path.get(path.as_str()).copied(); + rows.push(MainFieldRow { + note_id: note_id.clone(), + note_type_id: note.note_type_id.clone(), + field_id: field.id.clone(), + field_name: field.name.clone(), + path, + source: source.clone(), + structural, + category: entry + .map(|entry| entry.category) + .unwrap_or(TranslationCoverageCategory::UntranslatedFallback), + translated: entry + .and_then(|entry| entry.translated.clone()) + .unwrap_or(source), + }); + } + } + rows +} + +fn structural_field_set( + selection: &WorkbenchSelection, + _source_deck: &CanonicalDeck, +) -> BTreeSet { + selection.structural_fields.clone() +} + +fn main_progress( + source_deck: &CanonicalDeck, + context: &SelectedTranslationContext, + _entries_by_path: &BTreeMap<&str, &TranslationCoverageEntry>, +) -> Value { + let rows = main_field_rows(source_deck, &context.selection, &context.report.entries) + .into_iter() + .filter(|row| !row.structural) + .collect::>(); + let total = rows.len(); + let complete = rows + .iter() + .filter(|row| { + matches!( + row.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) + }) + .count(); + let missing = rows + .iter() + .filter(|row| row.category == TranslationCoverageCategory::UntranslatedFallback) + .count(); + let stale = rows + .iter() + .filter(|row| is_stale_category(row.category)) + .count(); + let percent = progress_percent(complete, total); + json!({ + "complete": complete, + "total": total, + "missing": missing, + "stale": stale, + "needs_work": missing + stale, + "percent": percent, + }) +} + +fn progress_percent(complete: usize, total: usize) -> usize { + complete + .checked_mul(100) + .and_then(|value| value.checked_div(total)) + .unwrap_or(100) +} + +#[derive(Clone, Debug)] +struct OptionalMetadataRow { + path: String, + source: String, + translated: String, + category: TranslationCoverageCategory, + metadata_category: String, + metadata_category_key: String, + profile_metadata: bool, + warning: Option, +} + +fn optional_metadata_rows( + context: &SelectedTranslationContext, + manifest: &FederatedDeckManifest, +) -> Vec { + let main_paths = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ) + .into_iter() + .filter(|row| !row.structural) + .map(|row| row.path) + .collect::>(); + let mut rows = context + .report + .entries + .iter() + .filter(|entry| !main_paths.contains(entry.path.as_str())) + .filter(|entry| { + !path_matches_any( + &manifest.translation_profile.metadata_exclude_paths, + &entry.path, + ) + }) + .filter_map(|entry| { + let (metadata_category_key, metadata_category) = metadata_category_for_path( + &manifest.translation_profile.metadata_categories, + &entry.path, + )?; + let profile_metadata = + path_matches_any(&manifest.translation_profile.metadata_paths, &entry.path); + let warning = is_stale_category(entry.category).then(|| match &entry.old_source { + Some(old) => format!("stale: source changed from {old:?}"), + None => "stale metadata".to_owned(), + }); + Some(OptionalMetadataRow { + path: entry.path.clone(), + source: entry.source.clone(), + translated: entry + .translated + .clone() + .unwrap_or_else(|| entry.source.clone()), + category: entry.category, + metadata_category: metadata_category.to_owned(), + metadata_category_key: metadata_category_key.to_owned(), + profile_metadata, + warning, + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + optional_metadata_category_rank( + &left.metadata_category_key, + &manifest.translation_profile.metadata_category_order, + &manifest.translation_profile.metadata_categories, + ) + .cmp(&optional_metadata_category_rank( + &right.metadata_category_key, + &manifest.translation_profile.metadata_category_order, + &manifest.translation_profile.metadata_categories, + )) + .then_with(|| left.path.cmp(&right.path)) + .then_with(|| left.source.cmp(&right.source)) + }); + rows +} + +fn optional_metadata_progress(rows: &[OptionalMetadataRow]) -> Value { + let total = rows.len(); + let complete = rows + .iter() + .filter(|row| optional_row_status(row) == "complete") + .count(); + let missing = rows + .iter() + .filter(|row| optional_row_status(row) == "missing") + .count(); + let stale = rows + .iter() + .filter(|row| optional_row_status(row) == "stale") + .count(); + json!({ + "complete": complete, + "total": total, + "missing": missing, + "stale": stale, + "needs_work": missing + stale, + }) +} + +fn optional_metadata_item_json(row: &OptionalMetadataRow) -> Value { + json!({ + "path": row.path, + "source": row.source, + "target": row.translated, + "status": optional_row_status(row), + "coverage_category": format!("{:?}", row.category), + "metadata_category": row.metadata_category, + "metadata_category_key": row.metadata_category_key, + "profile_metadata": row.profile_metadata, + "warning": row.warning, + "editable": true, + }) +} + +fn optional_row_status(row: &OptionalMetadataRow) -> &'static str { + if is_stale_category(row.category) { + "stale" + } else if row.category == TranslationCoverageCategory::UntranslatedFallback { + "missing" + } else { + "complete" + } +} + +fn metadata_category_for_path<'a>( + categories: &'a [MetadataCategory], + path: &str, +) -> Option<(&'a str, &'a str)> { + categories + .iter() + .filter_map(|category| { + category + .paths + .iter() + .filter(|pattern| path_matches_pattern(pattern, path)) + .map(|pattern| pattern.len()) + .max() + .map(|matched_len| (matched_len, category)) + }) + .max_by_key(|(matched_len, _)| *matched_len) + .map(|(_, category)| (category.key.as_str(), category.label.as_str())) +} + +fn optional_metadata_category_rank( + category_key: &str, + configured_order: &[String], + categories: &[MetadataCategory], +) -> usize { + if let Some(index) = configured_order + .iter() + .position(|ordered| ordered == category_key) + { + return index; + } + configured_order.len() + + categories + .iter() + .position(|category| category.key == category_key) + .unwrap_or(categories.len()) +} + +fn path_matches_any(patterns: &[String], path: &str) -> bool { + patterns + .iter() + .any(|pattern| path_matches_pattern(pattern, path)) +} + +fn path_matches_pattern(pattern: &str, path: &str) -> bool { + let pattern = pattern.as_bytes(); + let path = path.as_bytes(); + let (mut pattern_index, mut path_index) = (0, 0); + let (mut star_index, mut star_path_index) = (None, 0); + + while path_index < path.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == path[path_index] { + pattern_index += 1; + path_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_path_index = path_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_path_index += 1; + path_index = star_path_index; + } else { + return false; + } + } + + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + + pattern_index == pattern.len() +} + +fn note_navigation_rows(context: &SelectedTranslationContext, filter: Option<&str>) -> Vec { + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let rows_by_note = rows.into_iter().fold( + BTreeMap::>::new(), + |mut rows_by_note, row| { + rows_by_note + .entry(row.note_id.clone()) + .or_default() + .push(row); + rows_by_note + }, + ); + let mut notes = Vec::new(); + for (index, (note_id, note)) in context.source_deck.notes.iter().enumerate() { + let field_rows = rows_by_note.get(note_id).cloned().unwrap_or_default(); + let note_entries = context + .report + .entries + .iter() + .filter(|entry| entry.path.starts_with(&format!("notes.{note_id}."))) + .cloned() + .collect::>(); + if !note_matches_filter(&field_rows, ¬e_entries, filter) { + continue; + } + let title = note_title( + Some(note), + context.source_deck.note_types.get(¬e.note_type_id), + ); + let field_count = field_rows.len(); + let translatable_field_count = field_rows.iter().filter(|row| !row.structural).count(); + let missing_count = field_rows + .iter() + .filter(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) + .count(); + let stale_count = note_entries + .iter() + .filter(|entry| is_stale_category(entry.category)) + .count(); + let complete_count = translatable_field_count.saturating_sub(missing_count + stale_count); + notes.push(json!({ + "index": index, + "note_id": note_id.to_string(), + "note_type_id": note.note_type_id.to_string(), + "title": title, + "status": note_status(&field_rows, ¬e_entries), + "field_count": field_count, + "translatable_field_count": translatable_field_count, + "complete_count": complete_count, + "missing_count": missing_count, + "stale_count": stale_count, + "content_group_badges": content_group_badges_for_note(&context.source_deck, note_id), + })); + } + notes +} + +fn source_string_summary_json( + group: &SourceStringGroup, + source_counts: &BTreeMap, +) -> Value { + json!({ + "source": group.source, + "status": group.status, + "main_completion_status": group.status, + "occurrence_count": group.occurrence_count, + "complete_count": group.complete_count, + "missing_count": group.missing_count, + "stale_count": group.stale_count, + "target_preview": group.target_preview, + "content_group_badges": group.content_group_badges, + "direct_recommended": true, + "direct_applies_to": source_counts.get(&group.source).copied().unwrap_or(group.occurrence_count), + }) +} + +fn optional_metadata_summary_json(row: &OptionalMetadataRow) -> Value { + json!({ + "path": row.path, + "source": row.source, + "status": optional_row_status(row), + "coverage_category": format!("{:?}", row.category), + "metadata_category": row.metadata_category, + "metadata_category_key": row.metadata_category_key, + "profile_metadata": row.profile_metadata, + "warning": row.warning, + "editable": true, + }) +} + +fn note_pivot_notes_json( + source_deck: &CanonicalDeck, + target_deck: &CanonicalDeck, + context: &SelectedTranslationContext, + _entries_by_path: &BTreeMap<&str, &TranslationCoverageEntry>, + source_counts: &BTreeMap, + filter: Option<&str>, + selected_note: Option<&str>, +) -> Value { + let rows = main_field_rows(source_deck, &context.selection, &context.report.entries); + let rows_by_note = rows.into_iter().fold( + BTreeMap::>::new(), + |mut rows_by_note, row| { + rows_by_note + .entry(row.note_id.clone()) + .or_default() + .push(row); + rows_by_note + }, + ); + let mut notes = Vec::new(); + for (index, (note_id, note)) in source_deck.notes.iter().enumerate() { + if selected_note.is_some_and(|selected| selected != note_id.as_str()) { + continue; + } + let field_rows = rows_by_note.get(note_id).cloned().unwrap_or_default(); + let note_entries = context + .report + .entries + .iter() + .filter(|entry| entry.path.starts_with(&format!("notes.{note_id}."))) + .cloned() + .collect::>(); + if !note_matches_filter(&field_rows, ¬e_entries, filter) { + continue; + } + let Some(note_type) = source_deck.note_types.get(¬e.note_type_id) else { + continue; + }; + let target_note = target_deck.notes.get(note_id).unwrap_or(note); + let fields = field_rows + .iter() + .map(|row| { + let target = target_note + .fields + .get(&row.field_id) + .cloned() + .unwrap_or_else(|| row.translated.clone()); + json!({ + "path": row.path, + "note_id": row.note_id.to_string(), + "note_type_id": row.note_type_id.to_string(), + "field_id": row.field_id.to_string(), + "field_name": row.field_name, + "source": row.source, + "target": target, + "status": row.category.as_str(), + "occurrence_count": source_counts.get(&row.source).copied().unwrap_or(1), + "structural": row.structural, + "editable": !row.structural, + "source_editable": true, + "context_path": contextual_path_for_row(row, &context.report.entries), + "controls": ["direct", "contextual", "no_change"], + }) + }) + .collect::>(); + let source_preview = render_note_cards(source_deck, note, note_type); + let target_note_type = target_deck + .note_types + .get(&target_note.note_type_id) + .unwrap_or(note_type); + let target_preview = render_note_cards(target_deck, target_note, target_note_type); + let title = note_title(Some(note), Some(note_type)); + notes.push(json!({ + "index": index, + "note_id": note_id.to_string(), + "note_type_id": note.note_type_id.to_string(), + "title": title, + "status": note_status(&field_rows, ¬e_entries), + "fields": fields, + "source_preview": source_preview, + "target_preview": target_preview, + })); + } + json!(notes) +} + +fn contextual_path_for_row(row: &MainFieldRow, entries: &[TranslationCoverageEntry]) -> String { + let Some(note_context) = note_context_prefix(&row.path) else { + return row.path.clone(); + }; + let note_path_prefix = format!("{note_context}."); + let same_source_in_note = entries + .iter() + .filter(|entry| { + entry.source == row.source + && (entry.path == note_context || entry.path.starts_with(¬e_path_prefix)) + }) + .count(); + if same_source_in_note <= 1 { + note_context + } else { + row.path.clone() + } +} + +fn note_context_prefix(path: &str) -> Option { + path.split_once(".fields.") + .map(|(note_context, _)| note_context.to_owned()) +} + +fn note_matches_filter( + rows: &[MainFieldRow], + entries: &[TranslationCoverageEntry], + filter: Option<&str>, +) -> bool { + match filter.unwrap_or("all") { + "missing" => rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }), + "stale" => entries + .iter() + .any(|entry| is_stale_category(entry.category)), + "needs_work" | "needs-work" => { + rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) || entries + .iter() + .any(|entry| is_stale_category(entry.category)) + } + _ => true, + } +} + +fn note_status(rows: &[MainFieldRow], entries: &[TranslationCoverageEntry]) -> &'static str { + if rows.iter().any(|row| { + !row.structural && row.category == TranslationCoverageCategory::UntranslatedFallback + }) { + "missing" + } else if entries + .iter() + .any(|entry| is_stale_category(entry.category)) + { + "stale" + } else { + "complete" + } +} + +fn stale_entries_json(entries: &[TranslationCoverageEntry]) -> Value { + json!( + entries + .iter() + .filter(|entry| is_stale_category(entry.category)) + .map(|entry| json!({ + "path": entry.path, + "source": entry.source, + "old_source": entry.old_source, + "target": entry.translated, + "status": entry.category.as_str(), + })) + .collect::>() + ) +} + +fn is_stale_category(category: TranslationCoverageCategory) -> bool { + matches!( + category, + TranslationCoverageCategory::StaleDirectKey + | TranslationCoverageCategory::StaleContextualKey + | TranslationCoverageCategory::StaleTranslation + | TranslationCoverageCategory::StaleNoChangeKey + | TranslationCoverageCategory::StaleTargetAdaptation + | TranslationCoverageCategory::StaleVariableKey + | TranslationCoverageCategory::StaleAdapterIdKey + | TranslationCoverageCategory::InvalidTargetAdaptation + ) +} + +fn render_note_cards(deck: &CanonicalDeck, note: &Note, note_type: &NoteType) -> Value { + let rendered_deck = deck.render_variables().unwrap_or_else(|_| deck.clone()); + let rendered_note = rendered_deck.notes.get(¬e.id).unwrap_or(note); + let rendered_note_type = rendered_deck + .note_types + .get(¬e_type.id) + .unwrap_or(note_type); + let cards = rendered_note_type + .card_templates + .iter() + .map(|template| render_card(rendered_note, rendered_note_type, template)) + .collect::>(); + json!({ + "styling": rendered_note_type.styling, + "cards": cards, + }) +} + +fn render_card(note: &Note, note_type: &NoteType, template: &CardTemplate) -> Value { + let question = render_template_side(&template.question_format, note, note_type, ""); + let answer = render_template_side(&template.answer_format, note, note_type, &question); + json!({ + "template_id": template.id.to_string(), + "template_name": template.name, + "question_html": question, + "answer_html": answer, + }) +} + +fn render_template_side( + template: &str, + note: &Note, + note_type: &NoteType, + front_side: &str, +) -> String { + let mut rendered = template.replace("{{FrontSide}}", front_side); + for field in ¬e_type.fields { + let value = note.fields.get(&field.id).map(String::as_str).unwrap_or(""); + rendered = render_conditional_sections(&rendered, &field.name, value); + } + for field in ¬e_type.fields { + let value = note.fields.get(&field.id).map(String::as_str).unwrap_or(""); + let preview_value = format!( + "{}", + html_attribute_escape(field.id.as_str()), + value + ); + rendered = rendered.replace(&format!("{{{{{}}}}}", field.name), &preview_value); + rendered = rendered.replace(&format!("{{{{type:{}}}}}", field.name), ""); + } + rewrite_media_sources(&rendered) +} + +fn render_conditional_sections(template: &str, field_name: &str, value: &str) -> String { + let mut rendered = template.to_owned(); + rendered = replace_section( + &rendered, + &format!("{{{{#{field_name}}}}}"), + &format!("{{{{/{field_name}}}}}"), + !value.is_empty(), + ); + replace_section( + &rendered, + &format!("{{{{^{field_name}}}}}"), + &format!("{{{{/{field_name}}}}}"), + value.is_empty(), + ) +} + +fn replace_section(template: &str, open: &str, close: &str, keep: bool) -> String { + let mut output = template.to_owned(); + while let Some(start) = output.find(open) { + let content_start = start + open.len(); + let Some(close_offset) = output[content_start..].find(close) else { + break; + }; + let close_start = content_start + close_offset; + let close_end = close_start + close.len(); + let replacement = if keep { + output[content_start..close_start].to_owned() + } else { + String::new() + }; + output.replace_range(start..close_end, &replacement); + } + output +} + +fn html_attribute_escape(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn rewrite_media_sources(html: &str) -> String { + rewrite_media_source_quotes( + &rewrite_media_source_quotes(html, "src=\"", '"'), + "src='", + '\'', + ) +} + +fn rewrite_media_source_quotes(html: &str, marker: &str, quote: char) -> String { + let mut output = String::new(); + let mut rest = html; + while let Some(start) = rest.find(marker) { + let (before, after_marker) = rest.split_at(start + marker.len()); + output.push_str(before); + let Some(end) = after_marker.find(quote) else { + output.push_str(after_marker); + return output; + }; + let (path, after_path) = after_marker.split_at(end); + if is_external_or_already_routed_media(path) { + output.push_str(path); + } else { + output.push_str("/api/media/"); + output.push_str(path.trim_start_matches('/')); + } + rest = after_path; + } + output.push_str(rest); + output +} + +fn is_external_or_already_routed_media(path: &str) -> bool { + path.starts_with("/api/media/") + || path.starts_with("http://") + || path.starts_with("https://") + || path.starts_with("data:") +} + +fn safe_media_relative_path(path: &str) -> Result { + let path = path.trim_start_matches('/'); + if path.is_empty() + || path.contains("..") + || path.starts_with('~') + || Path::new(path).is_absolute() + { + return Err(format!("invalid media path {path:?}")); + } + Ok(path.to_owned()) +} + +fn missing_media_placeholder_svg(path: &str) -> String { + let label = html_attribute_escape(path); + format!( + "Missing media asset{label}" + ) +} + +fn media_content_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + { + Some("gif") => "image/gif", + Some("jpg" | "jpeg") => "image/jpeg", + Some("png") => "image/png", + Some("svg") => "image/svg+xml", + Some("webp") => "image/webp", + _ => "application/octet-stream", + } +} + +fn read_overlay_for_rewrite(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()) +} + +#[derive(Clone, Debug)] +struct AtomicFileWrite { + path: PathBuf, + display_path: String, + contents: Vec, +} + +impl AtomicFileWrite { + fn text(path: PathBuf, display_path: impl Into, contents: String) -> Self { + Self { + path, + display_path: display_path.into(), + contents: contents.into_bytes(), + } + } +} + +#[derive(Debug)] +struct PreparedAtomicWrite<'a> { + write: &'a AtomicFileWrite, + temp_path: PathBuf, +} + +fn write_files_atomically(writes: &[AtomicFileWrite]) -> Result<(), String> { + if writes.is_empty() { + return Ok(()); + } + validate_atomic_write_batch(writes)?; + for index in 0..writes.len() { + maybe_fail_atomic_write_hook("validate", index)?; + } + atomic_write_trace("transaction_begin", None, None); + + let result = write_files_atomically_inner(writes); + atomic_write_trace("transaction_end", None, None); + result +} + +fn write_files_atomically_inner(writes: &[AtomicFileWrite]) -> Result<(), String> { + let mut prepared = Vec::new(); + for (index, write) in writes.iter().enumerate() { + maybe_fail_atomic_write_hook("temp", index)?; + let parent = write.path.parent().ok_or_else(|| { + format!( + "failed to prepare {}: target has no parent directory", + write.display_path + ) + })?; + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + let temp_path = atomic_temp_path(&write.path, index)?; + atomic_write_trace("temp_write", Some(write), Some(&temp_path)); + let mut temp = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|error| { + format!( + "failed to create temporary file for {} at {}: {error}", + write.display_path, + temp_path.display() + ) + })?; + temp.write_all(&write.contents).map_err(|error| { + format!( + "failed to write temporary file for {} at {}: {error}", + write.display_path, + temp_path.display() + ) + })?; + temp.sync_all().map_err(|error| { + format!( + "failed to fsync temporary file for {} at {}: {error}", + write.display_path, + temp_path.display() + ) + })?; + drop(temp); + fsync_directory(parent)?; + prepared.push(PreparedAtomicWrite { write, temp_path }); + } + + let mut updated = Vec::::new(); + for (index, prepared_write) in prepared.iter().enumerate() { + if let Err(error) = maybe_fail_atomic_write_hook("rename", index) { + return Err(rename_phase_error(error, &updated, &prepared[index..])); + } + atomic_write_trace( + "rename", + Some(prepared_write.write), + Some(&prepared_write.temp_path), + ); + if let Err(error) = fs::rename(&prepared_write.temp_path, &prepared_write.write.path) { + return Err(rename_phase_error( + format!( + "failed to rename {} over {}: {error}", + prepared_write.temp_path.display(), + prepared_write.write.display_path + ), + &updated, + &prepared[index..], + )); + } + if let Some(parent) = prepared_write.write.path.parent() { + fsync_directory(parent)?; + } + updated.push(prepared_write.write.display_path.clone()); + } + Ok(()) +} + +fn validate_atomic_write_batch(writes: &[AtomicFileWrite]) -> Result<(), String> { + let mut seen = BTreeSet::new(); + for write in writes { + if write.path.as_os_str().is_empty() { + return Err("invalid atomic write target: empty path".to_owned()); + } + if !seen.insert(write.path.clone()) { + return Err(format!( + "invalid atomic write batch: duplicate target {}", + write.path.display() + )); + } + } + Ok(()) +} + +fn atomic_temp_path(path: &Path, index: usize) -> Result { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid atomic write target {}", path.display()))?; + let suffix = format!( + ".brainbrew-tmp-{}-{}-{index}", + std::process::id(), + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + ); + Ok(path.with_file_name(format!("{file_name}{suffix}"))) +} + +fn fsync_directory(path: &Path) -> Result<(), String> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("failed to fsync directory {}: {error}", path.display())) +} + +fn rename_phase_error( + cause: String, + updated: &[String], + not_updated_writes: &[PreparedAtomicWrite<'_>], +) -> String { + let not_updated = not_updated_writes + .iter() + .map(|write| write.write.display_path.clone()) + .collect::>(); + format!( + "atomic write rename phase failed: {cause}; updated files: {}; not updated files: {}", + format_file_list(updated), + format_file_list(¬_updated), + ) +} + +fn format_file_list(files: &[String]) -> String { + if files.is_empty() { + "(none)".to_owned() + } else { + files.join(", ") + } +} + +fn maybe_fail_atomic_write_hook(phase: &str, index: usize) -> Result<(), String> { + let env_name = match phase { + "validate" => "BRAINBREW_ATOMIC_WRITE_FAIL_VALIDATE_INDEX", + "temp" => "BRAINBREW_ATOMIC_WRITE_FAIL_TEMP_INDEX", + "rename" => "BRAINBREW_ATOMIC_WRITE_FAIL_RENAME_INDEX", + _ => return Ok(()), + }; + if std::env::var(env_name) + .ok() + .and_then(|value| value.parse::().ok()) + == Some(index) + { + return Err(format!( + "injected atomic write {phase} failure at write index {index}" + )); + } + if phase == "rename" + && let Ok(value) = std::env::var("BRAINBREW_ATOMIC_WRITE_SLEEP_BEFORE_RENAME_MS") + && let Ok(milliseconds) = value.parse::() + && milliseconds > 0 + { + std::thread::sleep(std::time::Duration::from_millis(milliseconds)); + } + Ok(()) +} + +fn atomic_write_trace(event: &str, write: Option<&AtomicFileWrite>, temp_path: Option<&Path>) { + let Ok(trace_path) = std::env::var("BRAINBREW_ATOMIC_WRITE_TRACE") else { + return; + }; + let mut line = event.to_owned(); + if let Some(write) = write { + line.push(' '); + line.push_str(&write.display_path); + } + if let Some(temp_path) = temp_path { + line.push_str(" temp="); + line.push_str(&temp_path.display().to_string()); + } + line.push('\n'); + let _ = OpenOptions::new() + .create(true) + .append(true) + .open(trace_path) + .and_then(|mut file| file.write_all(line.as_bytes())); +} + +#[derive(Default)] +struct SourceApplyPlan { + changed_entries: Vec, + overlay_changed: bool, + deck_file_changed: bool, + deck_yaml_output: Option, + include_writes: BTreeMap, + affected_files: BTreeMap, +} + +impl SourceApplyPlan { + fn affected_files_json(&self, _root: &Path) -> Vec { + self.affected_files + .iter() + .map(|(absolute_path, display_path)| { + json!({ + "path": display_path, + "absolute_path": absolute_path.display().to_string(), + }) + }) + .collect() + } +} + +fn apply_staged_source_edits( + modified_base: &mut CanonicalDeck, + overlay: &mut Overlay, + edits: &[StagedWorkbenchEdit], + context: &SelectedTranslationContext, + base_file: &Path, + manifest_root: &Path, +) -> Result { + let mut plan = SourceApplyPlan::default(); + if edits.is_empty() { + return Ok(plan); + } + + let raw_deck_yaml = fs::read_to_string(base_file) + .map_err(|error| format!("{}: {error}", base_file.display()))?; + let raw_has_includes = raw_deck_yaml.contains("!include"); + let mut raw_deck_value = if raw_has_includes { + Some( + serde_yaml::from_str::(&raw_deck_yaml).map_err(|error| { + format!( + "failed to parse {} while preserving includes: {error}", + base_file.display() + ) + })?, + ) + } else { + None + }; + + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let row_by_path = rows + .iter() + .map(|row| (row.path.clone(), row.clone())) + .collect::>(); + let mut changed_paths = BTreeSet::new(); + + for edit in edits { + if !edit.path.starts_with("notes.") || !edit.path.contains(".fields.") { + return Err(format!( + "invalid source note-field edit path {:?}", + edit.path + )); + } + if edit.source.is_empty() { + return Err(format!("invalid empty source for {}", edit.path)); + } + let Some(anchor_row) = row_by_path.get(&edit.path) else { + return Err(format!( + "invalid source edit path {:?}; choose an editable source note field", + edit.path + )); + }; + if anchor_row.source != edit.source { + return Err(format!( + "invalid source {:?} for {}; expected {:?}", + edit.source, edit.path, anchor_row.source + )); + } + let target_rows = match edit.scope { + SourceEditScope::Field => vec![anchor_row.clone()], + SourceEditScope::AllOccurrences => rows + .iter() + .filter(|row| row.source == edit.source) + .cloned() + .collect::>(), + }; + let target_path_set = target_rows + .iter() + .map(|row| row.path.clone()) + .collect::>(); + let old_source_remains = rows + .iter() + .any(|row| row.source == edit.source && !target_path_set.contains(row.path.as_str())); + + for row in &target_rows { + set_deck_note_field(modified_base, &row.path, &edit.source, &edit.value)?; + changed_paths.insert(row.path.clone()); + if let Some(raw_deck_value) = raw_deck_value.as_mut() { + match deck_field_include_path(raw_deck_value, &row.path)? { + Some(include_path) => { + let resolved = + resolve_workbench_include_path(manifest_root, &include_path)?; + plan.include_writes.insert(resolved, edit.value.clone()); + } + None => { + set_deck_field_yaml_scalar( + raw_deck_value, + &row.path, + &edit.source, + &edit.value, + )?; + plan.deck_file_changed = true; + } + } + } else { + plan.deck_file_changed = true; + } + plan.changed_entries.push(json!({ + "mode": "source", + "path": row.path, + "source": edit.source, + "old": edit.source, + "new": edit.value, + "scope": edit.scope, + })); + } + + plan.overlay_changed |= apply_source_translation_impact( + overlay, + edit, + &target_rows, + old_source_remains, + context, + &mut plan.changed_entries, + )?; + } + + if plan.deck_file_changed { + plan.affected_files.insert( + base_file.to_path_buf(), + workspace_path(manifest_root, base_file), + ); + } + for path in plan.include_writes.keys() { + plan.affected_files + .insert(path.clone(), workspace_path(manifest_root, path)); + } + if let Some(raw_deck_value) = raw_deck_value + && plan.deck_file_changed + { + plan.deck_yaml_output = Some(serde_yaml::to_string(&raw_deck_value).map_err(|error| { + format!( + "failed to serialize {} while preserving includes: {error}", + base_file.display() + ) + })?); + } + Ok(plan) +} + +fn apply_source_translation_impact( + overlay: &mut Overlay, + edit: &StagedWorkbenchEdit, + target_rows: &[MainFieldRow], + old_source_remains: bool, + context: &SelectedTranslationContext, + changed_entries: &mut Vec, +) -> Result { + let mut changed = false; + if target_rows.is_empty() { + return Ok(false); + } + let use_global_impact = edit.scope == SourceEditScope::AllOccurrences + && !old_source_remains + && target_rows.iter().all(|row| { + matches!( + row.category, + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation + ) + }) + && target_rows.iter().all(|row| { + row.category != TranslationCoverageCategory::StaleTranslation + || context + .report + .entries + .iter() + .find(|entry| entry.path == row.path && entry.source == row.source) + .and_then(|entry| entry.context.as_deref()) + .is_none() + }) + && target_rows + .iter() + .map(target_text_for_source_impact) + .collect::>>() + .is_some_and(|targets| targets.len() <= 1); + let impact_rows = if use_global_impact { + vec![target_rows[0].clone()] + } else { + target_rows.to_vec() + }; + let translations = overlay.translations.get_or_insert_with(Default::default); + for row in impact_rows { + let Some(target) = target_text_for_source_impact(&row) else { + continue; + }; + let context_path = if use_global_impact { + None + } else { + Some(contextual_path_for_row(&row, &context.report.entries)) + }; + match edit.impact_action { + SourceImpactAction::StaleTranslation => { + if context_path.is_none() { + translations.direct.remove(&edit.source); + translations.no_change.remove(&edit.source); + remove_contextual_source_everywhere(translations, &edit.source); + } else { + remove_contextual_source_for_path(translations, &row.path, &edit.source); + } + upsert_stale_translation( + translations, + StaleTranslation { + old_source: edit.source.clone(), + new_source: edit.value.clone(), + target: target.clone(), + context: context_path.clone(), + }, + ); + changed_entries.push(json!({ + "mode": "stale_translation", + "path": row.path, + "old_source": edit.source, + "new_source": edit.value, + "target": target, + "context": context_path, + "impact_action": "stale_translation", + "available_impact_actions": ["stale_translation", "migrate_key"], + })); + changed = true; + } + SourceImpactAction::MigrateKey => { + if let Some(context_path) = &context_path { + remove_contextual_source_for_path(translations, &row.path, &edit.source); + translations + .contextual + .entry(context_path.clone()) + .or_default() + .insert(edit.value.clone(), target.clone()); + remove_stale_translations_for_path_source(translations, &row.path, &edit.value); + } else { + translations.direct.remove(&edit.source); + translations.no_change.remove(&edit.source); + remove_contextual_source_everywhere(translations, &edit.source); + translations + .direct + .insert(edit.value.clone(), target.clone()); + remove_stale_translations_for_path_source(translations, &row.path, &edit.value); + } + changed_entries.push(json!({ + "mode": "migrate_key", + "path": row.path, + "old_source": edit.source, + "new_source": edit.value, + "target": target, + "context": context_path, + "impact_action": "migrate_key", + "available_impact_actions": ["stale_translation", "migrate_key"], + })); + changed = true; + } + } + } + Ok(changed) +} + +fn target_text_for_source_impact(row: &MainFieldRow) -> Option { + match row.category { + TranslationCoverageCategory::DirectTranslation + | TranslationCoverageCategory::ContextualTranslation + | TranslationCoverageCategory::NoChange + | TranslationCoverageCategory::StaleTranslation => Some(row.translated.clone()), + _ => None, + } +} + +fn upsert_stale_translation(translations: &mut TranslationDictionary, record: StaleTranslation) { + translations.stale_translations.retain(|existing| { + !(existing.old_source == record.old_source + && existing.new_source == record.new_source + && existing.context == record.context) + }); + translations.stale_translations.push(record); +} + +fn remove_contextual_source_everywhere(translations: &mut TranslationDictionary, source: &str) { + let contexts = translations + .contextual + .iter() + .filter(|(_, replacements)| replacements.contains_key(source)) + .map(|(context_path, _)| context_path.clone()) + .collect::>(); + for context_path in contexts { + if let Some(replacements) = translations.contextual.get_mut(&context_path) { + replacements.remove(source); + if replacements.is_empty() { + translations.contextual.remove(&context_path); + } + } + } +} + +fn set_deck_note_field( + deck: &mut CanonicalDeck, + path: &str, + expected_source: &str, + value: &str, +) -> Result<(), String> { + let (note_id, field_id) = note_field_path(path)?; + let note_id = StableId::new(note_id).map_err(|error| error.to_string())?; + let field_id = StableId::new(field_id).map_err(|error| error.to_string())?; + let note = deck + .notes + .get_mut(¬e_id) + .ok_or_else(|| format!("source edit path {path:?} is not in the canonical deck file"))?; + let current = note.fields.get(&field_id).cloned().unwrap_or_default(); + if current != expected_source { + return Err(format!( + "invalid source {:?} for {}; expected canonical deck value {:?}", + expected_source, path, current + )); + } + note.fields.insert(field_id, value.to_owned()); + Ok(()) +} + +fn note_field_path(path: &str) -> Result<(&str, &str), String> { + let Some(rest) = path.strip_prefix("notes.") else { + return Err(format!("invalid note-field path {path:?}")); + }; + let Some((note_id, field_id)) = rest.split_once(".fields.") else { + return Err(format!("invalid note-field path {path:?}")); + }; + if note_id.is_empty() || field_id.is_empty() { + return Err(format!("invalid note-field path {path:?}")); + } + Ok((note_id, field_id)) +} + +fn deck_field_include_path( + value: &serde_yaml::Value, + path: &str, +) -> Result, String> { + let field = deck_field_yaml_value(value, path)?; + match field { + serde_yaml::Value::Tagged(tagged) if tagged.tag == "include" => match &tagged.value { + serde_yaml::Value::String(path) => Ok(Some(path.clone())), + _ => Err(format!("invalid !include at {path}: path must be a string")), + }, + _ => Ok(None), + } +} + +fn set_deck_field_yaml_scalar( + value: &mut serde_yaml::Value, + path: &str, + expected_source: &str, + new_value: &str, +) -> Result<(), String> { + let field = deck_field_yaml_value_mut(value, path)?; + match field { + serde_yaml::Value::String(current) if current == expected_source => { + *current = new_value.to_owned(); + Ok(()) + } + serde_yaml::Value::String(current) => Err(format!( + "invalid source {:?} for {}; expected YAML value {:?}", + expected_source, path, current + )), + serde_yaml::Value::Tagged(tagged) if tagged.tag == "include" => Ok(()), + _ => Err(format!( + "source edit path {path:?} is not a scalar note field in deck YAML" + )), + } +} + +fn deck_field_yaml_value<'a>( + value: &'a serde_yaml::Value, + path: &str, +) -> Result<&'a serde_yaml::Value, String> { + let (note_id, field_id) = note_field_path(path)?; + yaml_mapping_get(value, "notes") + .and_then(|notes| yaml_mapping_get(notes, note_id)) + .and_then(|note| yaml_mapping_get(note, "fields")) + .and_then(|fields| yaml_mapping_get(fields, field_id)) + .ok_or_else(|| format!("source edit path {path:?} is not present in deck YAML")) +} + +fn deck_field_yaml_value_mut<'a>( + value: &'a mut serde_yaml::Value, + path: &str, +) -> Result<&'a mut serde_yaml::Value, String> { + let (note_id, field_id) = note_field_path(path)?; + yaml_mapping_get_mut(value, "notes") + .and_then(|notes| yaml_mapping_get_mut(notes, note_id)) + .and_then(|note| yaml_mapping_get_mut(note, "fields")) + .and_then(|fields| yaml_mapping_get_mut(fields, field_id)) + .ok_or_else(|| format!("source edit path {path:?} is not present in deck YAML")) +} + +fn yaml_mapping_get<'a>(value: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { + let serde_yaml::Value::Mapping(mapping) = value else { + return None; + }; + mapping.get(serde_yaml::Value::String(key.to_owned())) +} + +fn yaml_mapping_get_mut<'a>( + value: &'a mut serde_yaml::Value, + key: &str, +) -> Option<&'a mut serde_yaml::Value> { + let serde_yaml::Value::Mapping(mapping) = value else { + return None; + }; + mapping.get_mut(serde_yaml::Value::String(key.to_owned())) +} + +fn resolve_workbench_include_path(root: &Path, include_path: &str) -> Result { + let requested = Path::new(include_path); + if requested.is_absolute() || include_path.contains("..") { + return Err(format!( + "source edit include path {include_path:?} is not a safe package-root-relative path" + )); + } + Ok(root.join(requested)) +} + +fn source_apply_writes( + plan: &SourceApplyPlan, + modified_base: &CanonicalDeck, + base_file: &Path, + manifest_root: &Path, +) -> Result, String> { + let mut writes = Vec::new(); + if plan.deck_file_changed { + let output = match &plan.deck_yaml_output { + Some(output) => output.clone(), + None => canonical_yaml::to_string(modified_base).map_err(|error| error.to_string())?, + }; + if plan.deck_yaml_output.is_some() { + serde_yaml::from_str::(&output).map_err(|error| { + format!( + "invalid generated source deck {}: {error}", + base_file.display() + ) + })?; + } else { + canonical_yaml::from_str(&output).map_err(|error| { + format!( + "invalid generated source deck {}: {error}", + base_file.display() + ) + })?; + } + writes.push(AtomicFileWrite::text( + base_file.to_path_buf(), + workspace_path(manifest_root, base_file), + output, + )); + } + for (path, value) in &plan.include_writes { + writes.push(AtomicFileWrite::text( + path.clone(), + workspace_path(manifest_root, path), + value.clone(), + )); + } + Ok(writes) +} + +fn push_unique_affected_file( + files: &mut Vec, + root: &Path, + absolute_path: &Path, + display_path: Option<&str>, +) { + let absolute = absolute_path.display().to_string(); + if files + .iter() + .any(|file| file["absolute_path"].as_str() == Some(absolute.as_str())) + { + return; + } + files.push(json!({ + "path": display_path + .map(str::to_owned) + .unwrap_or_else(|| workspace_path(root, absolute_path)), + "absolute_path": absolute, + })); +} + +fn editable_sources_by_path(context: &SelectedTranslationContext) -> BTreeMap { + let mut sources = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ) + .into_iter() + .filter(|row| !row.structural) + .map(|row| (row.path, row.source)) + .collect::>(); + for row in source_string_rows(context) + .into_iter() + .filter(|row| !row.structural) + { + sources.entry(row.path).or_insert(row.source); + } + for entry in &context.report.entries { + if entry.source.is_empty() + || path_matches_any(&context.selection.metadata_exclude_paths, &entry.path) + || metadata_category_for_path(&context.selection.metadata_categories, &entry.path) + .is_none() + { + continue; + } + sources + .entry(entry.path.clone()) + .or_insert_with(|| entry.source.clone()); + } + sources +} + +fn contextual_path_for_edit( + context: &SelectedTranslationContext, + edit: &StagedWorkbenchEdit, +) -> Result { + let rows = main_field_rows( + &context.source_deck, + &context.selection, + &context.report.entries, + ); + let source_rows = if rows + .iter() + .any(|row| row.path == edit.path && row.source == edit.source && !row.structural) + { + rows + } else { + source_string_rows(context) + }; + let row = source_rows + .iter() + .find(|row| row.path == edit.path && row.source == edit.source && !row.structural); + if let Some(row) = row { + if let Some(context_path) = &edit.context_path { + if context_path == &row.path + || row + .path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) + { + return Ok(context_path.clone()); + } + return Err(format!( + "invalid contextual edit context {:?} for {}", + context_path, edit.path + )); + } + return Ok(contextual_path_for_row(row, &context.report.entries)); + } + + if !path_matches_any(&context.selection.metadata_exclude_paths, &edit.path) + && metadata_category_for_path(&context.selection.metadata_categories, &edit.path).is_some() + && context + .report + .entries + .iter() + .any(|entry| entry.path == edit.path && entry.source == edit.source) + { + if let Some(context_path) = &edit.context_path { + if context_path == &edit.path + || edit + .path + .strip_prefix(context_path) + .is_some_and(|suffix| suffix.starts_with('.')) + { + return Ok(context_path.clone()); + } + return Err(format!( + "invalid contextual edit context {:?} for {}", + context_path, edit.path + )); + } + return Ok(edit.path.clone()); + } + + Err(format!("invalid contextual edit path {:?}", edit.path)) +} + +fn apply_staged_edits_to_overlay( + overlay: &mut Overlay, + edits: &[StagedWorkbenchEdit], + context: &SelectedTranslationContext, +) -> Result, String> { + let editable_sources = editable_sources_by_path(context); + let translations = overlay.translations.get_or_insert_with(Default::default); + let mut changed = Vec::new(); + for edit in edits { + if edit.source.is_empty() { + return Err(format!("invalid empty source for {}", edit.path)); + } + let Some(expected_source) = editable_sources.get(edit.path.as_str()) else { + return Err(format!( + "invalid edit path {:?}; choose an editable translation source in the selected target", + edit.path + )); + }; + if expected_source != &edit.source { + return Err(format!( + "invalid source {:?} for {}; expected {:?}", + edit.source, edit.path, expected_source + )); + } + match edit.mode { + EditMode::Direct => { + translations.no_change.remove(&edit.source); + remove_contextual_source_for_path(translations, &edit.path, &edit.source); + remove_stale_translations_for_path_source(translations, &edit.path, &edit.source); + let old = translations + .direct + .insert(edit.source.clone(), edit.value.clone()); + changed.push(json!({ + "mode": "direct", + "path": edit.path, + "source": edit.source, + "old": old, + "new": edit.value, + })); + } + EditMode::Contextual => { + translations.no_change.remove(&edit.source); + let context_path = contextual_path_for_edit(context, edit)?; + remove_stale_translations_for_path_source(translations, &edit.path, &edit.source); + let replacements = translations + .contextual + .entry(context_path.clone()) + .or_default(); + let old = replacements.insert(edit.source.clone(), edit.value.clone()); + changed.push(json!({ + "mode": "contextual", + "path": context_path, + "field_path": edit.path, + "source": edit.source, + "old": old, + "new": edit.value, + })); + } + EditMode::NoChange => { + translations.direct.remove(&edit.source); + remove_contextual_source_for_path(translations, &edit.path, &edit.source); + let inserted = translations.no_change.insert(edit.source.clone()); + changed.push(json!({ + "mode": "no_change", + "path": edit.path, + "source": edit.source, + "old": if inserted { Value::Null } else { json!(edit.source) }, + "new": edit.source, + })); + } + } + } + Ok(changed) +} + +fn remove_stale_translations_for_path_source( + translations: &mut TranslationDictionary, + path: &str, + source: &str, +) { + translations.stale_translations.retain(|record| { + !(record.new_source == source + && record.context.as_deref().is_none_or(|context| { + path == context + || path + .strip_prefix(context) + .is_some_and(|suffix| suffix.starts_with('.')) + })) + }); +} + +fn remove_contextual_source_for_path( + translations: &mut TranslationDictionary, + path: &str, + source: &str, +) { + let contexts = translations + .contextual + .iter() + .filter(|(context_path, replacements)| { + replacements.contains_key(source) + && (context_path.as_str() == path || path.starts_with(&format!("{context_path}."))) + }) + .map(|(context_path, _)| context_path.clone()) + .collect::>(); + for context_path in contexts { + if let Some(replacements) = translations.contextual.get_mut(&context_path) { + replacements.remove(source); + if replacements.is_empty() { + translations.contextual.remove(&context_path); + } + } + } +} + +fn validate_modified_base_and_overlay( + context: &SelectedTranslationContext, + modified_base: &CanonicalDeck, + modified_overlay: &Overlay, +) -> ApplyValidation { + match context_with_modified_base_and_overlay(context, modified_base.clone(), modified_overlay) { + Ok(updated_context) => { + let mut errors = Vec::new(); + match updated_context.target_deck.render_variables() { + Ok(rendered) => { + let report = validate_deck_content(&rendered); + if !report.is_empty() { + errors.push(format!( + "content validation failed for target {}: {report}", + updated_context.selection.target_id + )); + } + } + Err(error) => errors.push(format!( + "content validation failed for target {}: {error}", + updated_context.selection.target_id + )), + } + ApplyValidation { + ok: errors.is_empty(), + errors, + } + } + Err(error) => ApplyValidation { + ok: false, + errors: vec![error], + }, + } +} + +fn context_with_modified_base_and_overlay( + context: &SelectedTranslationContext, + modified_base: CanonicalDeck, + modified_overlay: &Overlay, +) -> Result { + let mut current = modified_base.clone(); + let mut selected_source_deck = None; + let mut selected_report = None; + for (planned, overlay) in &context.plan_overlays { + let active_overlay = if planned.id == context.selection.overlay_id { + modified_overlay + } else { + overlay + }; + if planned.id == context.selection.overlay_id { + selected_source_deck = Some(current.clone()); + selected_report = current.translation_coverage(active_overlay); + } + current = if active_overlay.translations.is_some() { + compose_lenient_translation_overlay(¤t, active_overlay)? + } else { + current + .compose(std::slice::from_ref(active_overlay)) + .map_err(|error| format!("failed to compose overlay {}: {error}", planned.id))? + }; + } + let Some(source_deck) = selected_source_deck else { + return Err(format!( + "target {} does not include translation overlay {}", + context.selection.target_id, context.selection.overlay_id + )); + }; + let Some(report) = selected_report else { + return Err(format!( + "overlay {} is not a translation overlay", + context.selection.overlay_id + )); + }; + Ok(SelectedTranslationContext { + selection: context.selection.clone(), + base_deck: modified_base, + plan_overlays: context.plan_overlays.clone(), + source_deck, + target_deck: current, + report, + }) +} + +fn languages_json(manifest: &FederatedDeckManifest) -> Value { + let languages = manifest + .languages + .iter() + .map(|(code, language)| (code.clone(), language_json(language))) + .collect::>(); + Value::Object(languages) +} + +fn language_json(language: &LanguageManifestEntry) -> Value { + json!({ + "display_name": language.display_name, + "source": language.source, + "translation_overlays": language.translation_overlays, + "primary_target": language.primary_target, + "targets": language.targets, + }) +} + +fn target_labels_json(manifest: &FederatedDeckManifest) -> Value { + let mut labels = BTreeMap::>::new(); + for (language, entry) in &manifest.languages { + for (label, target) in &entry.targets { + labels.entry(target.clone()).or_default().push(json!({ + "language": language, + "label": label, + })); + } + } + json!(labels) +} + +fn targets_json(manifest: &FederatedDeckManifest) -> Value { + let targets = manifest + .targets + .iter() + .map(|(name, target)| { + ( + name.clone(), + json!({ + "extends": target.extends, + "overlays": target.overlays, + }), + ) + }) + .collect::>(); + Value::Object(targets) +} + +fn file_fingerprints( + manifest_path: &Path, + root: &Path, + manifest: &FederatedDeckManifest, +) -> Result, String> { + let mut files = vec![manifest_path.to_path_buf(), root.join(&manifest.base)]; + for overlay in manifest.overlays.values() { + files.push(root.join(&overlay.file)); + } + files.sort(); + files.dedup(); + files + .into_iter() + .map(|path| file_fingerprint(root, &path)) + .collect() +} + +fn file_fingerprint(root: &Path, path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?; + let hash = Sha256::digest(&bytes); + Ok(json!({ + "path": workspace_path(root, path), + "sha256": format!("{hash:x}"), + })) +} + +fn workspace_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn open_browser(url: &str) { + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("cmd"); + command.args(["/C", "start", "", url]); + command + }; + + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(url); + command + }; + + #[cfg(all(unix, not(target_os = "macos")))] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(url); + command + }; + + let _ = command.spawn(); +} diff --git a/crates/brain-brew-cli/src/help.rs b/crates/brain-brew-cli/src/help.rs new file mode 100644 index 0000000..d129273 --- /dev/null +++ b/crates/brain-brew-cli/src/help.rs @@ -0,0 +1,89 @@ +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", + " media Verify and refresh declared media asset hashes\n", + " targets List manifest targets\n", + " translations Report/apply translation coverage (aliases: translate, translation)\n", + " workbench Serve the local Deck Workbench browser UI and JSON API\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 media hash --manifest brainbrew.yaml --all-targets --media-root media/\n", + " brainbrew media images-to-refs --manifest brainbrew.yaml --all-targets\n", + " brainbrew export crowdanki --manifest brainbrew.yaml --target de-extended --media-root media/\n", + " brainbrew translate --manifest brainbrew.yaml --target de-standard\n", + " brainbrew workbench serve --manifest brainbrew.yaml\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\nExperimental: lock/package federation works, but brainbrew.lock and the brainbrew lock CLI surface may change incompatibly.\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", + ), + "media" => Some( + "Usage:\n brainbrew media hash [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/] --media-root media/\n brainbrew media images-to-refs [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/]\n\nExamples:\n brainbrew media hash --manifest brainbrew.yaml --all-targets --media-root media/\n brainbrew media hash --manifest brainbrew.yaml --target en-standard --media-root media/\n brainbrew media images-to-refs --manifest brainbrew.yaml --all-targets\n\n`media hash` computes SHA-256 hashes for declared media files and writes missing or stale values back to deck/overlay source YAML using the include-preserving canonical formatter.\n\n`media images-to-refs` converts whole-field strict `` source values to `!image ` when PATH maps to exactly one declared media ID. Non-strict fields, undeclared paths, and duplicate path declarations stay raw and are counted in the report.\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", + ), + "translations" | "translate" | "translation" => Some( + "Usage:\n brainbrew translate [--manifest brainbrew.yaml] [--target | --all-targets] [--language ] [--overlay ] [--note ] [--field ] [--source ] [--duplicates] [--status ] [--path-prefix ] [--apply | --summary | --context] [--full] [--interactive | --no-interactive] [--json]\n brainbrew translate [--manifest brainbrew.yaml] [--target | --all-targets] [--language ] [--overlay ] [--note ] [--field ] [--status stale] [--path-prefix ] --resolve (confirm | replace) [--old-source ] [--new-source ] [--stale-context ] [--translation ] [--json]\n\nInteractive examples:\n brainbrew translate\n brainbrew translate --manifest fixtures/ultimate-geography/brainbrew.yaml\n brainbrew translate --interactive\n\nScriptable examples:\n brainbrew translations --manifest brainbrew.yaml --target da-standard\n brainbrew translations --manifest brainbrew.yaml --all-targets --language de\n brainbrew translations --manifest brainbrew.yaml --target de-standard --note note.finland --field field.country\n brainbrew translations --manifest brainbrew.yaml --target de-standard --context --source Georgia\n brainbrew translations --manifest brainbrew.yaml --target da-standard --apply\n brainbrew translations --manifest brainbrew.yaml --target da-standard --resolve confirm --old-source 'Old text' --new-source 'New text'\n brainbrew translations --manifest brainbrew.yaml --target da-standard --resolve replace --old-source 'Old text' --new-source 'New text' --translation 'New translation'\n brainbrew translations --manifest brainbrew.yaml --all-targets --summary --json\n brainbrew translations --manifest brainbrew.yaml --target da-standard --json\n\nReport mode is the default and never edits files. In an interactive terminal, missing choices open arrow-key selectors for target, scope, and mode; language and overlay selectors appear only when they disambiguate the selected targets. The human report is translator-focused by default: structural/media/tag fallbacks are hidden and summarized. Use --full to include every scalar fallback. --context shows source and target strings in note/field/card context; combine it with --note, --field, --source, --duplicates, --status, and --language to navigate the translator context view. --summary exports compact per-language/per-overlay counts and de-duplicates identical reports across target variants; human summary output uses narrow aligned columns by default, and --summary --full adds overlay/file columns. --apply inserts source->source translation stubs for missing text fallbacks in non-interactive mode, while interactive apply lets you select rows with Space/Enter and choose one action for all selected rows (mark no-change, direct/contextual stub, ignore-path, skip) or decide per row. --resolve confirm promotes matching stale translations under the current source text; --resolve replace first uses the same stale-record resolution path, then installs the provided --translation. Omit --old-source/--new-source within a narrow target/overlay/scope to batch-resolve all matching current-base stale records. Use --path-prefix with paths from a diff to scope a changed-base review.\n", + ), + "workbench" => Some( + "Usage:\n brainbrew workbench serve [--manifest brainbrew.yaml] [--port ] [--no-open] [--dev-assets ] [--media-root ]\n\nExamples:\n brainbrew workbench serve --manifest brainbrew.yaml\n brainbrew workbench serve --manifest brainbrew.yaml --port 0 --no-open\n brainbrew workbench serve --manifest brainbrew.yaml --dev-assets target/workbench-ui\n brainbrew workbench serve --manifest brainbrew.yaml --media-root media/\n\nStarts a localhost Deck Workbench server bound to 127.0.0.1. Port 0 selects an available port. Release builds serve embedded UI assets from the brainbrew binary; --dev-assets serves a development asset directory such as the output of `devenv shell workbench-ui-watch`. Use --media-root to serve declared media assets from a directory outside the manifest root. The JSON API exposes health and workspace metadata for the browser UI.\n", + ), + "verify" => Some( + "Usage:\n brainbrew verify [--manifest brainbrew.yaml] (--all-targets | --target ) [--include package/brainbrew.yaml ...] [--package-root packages/] [--media-root media/] [--translation-coverage lenient|strict] [--skip-content-validation]\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 brainbrew verify --manifest brainbrew.yaml --target de-release --translation-coverage strict\n brainbrew verify --manifest brainbrew.yaml --target legacy-sloppy-html --skip-content-validation\n\nBy default verify structurally checks rendered deck descriptions and card templates as lightweight HTML fragments, and note-type styling as balanced CSS. Use --skip-content-validation as an escape hatch for legacy Anki content that renders correctly despite a false positive.\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..edfa79a --- /dev/null +++ b/crates/brain-brew-cli/src/io.rs @@ -0,0 +1,643 @@ +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, media_map, source_includes}; +use serde_json::json; +use serde_yaml::Value; + +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 source_includes::format_preserving_file_includes(input, canonical_yaml::format_str) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("deck: {error}")), + } + match source_includes::format_preserving_file_includes( + input, + canonical_yaml::overlay_format_str, + ) { + 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}")), + } + match media_map::format_str(input) { + Ok(formatted) => return Ok(formatted), + Err(error) => errors.push(format!("media map: {error}")), + } + Err(format!( + "unrecognized Brain Brew source file ({})", + errors.join("; ") + )) +} + +pub(crate) fn format_source_at(_path: &Path, input: &str) -> Result { + format_source(input) +} + +pub(crate) fn read_deck(path: &Path) -> Result { + let context = source_context_for_path(path)?; + read_deck_with_context(path, &context) +} + +fn read_deck_from_package( + path: &Path, + package_root: &Path, + include_roots: &[PathBuf], +) -> Result { + read_deck_with_context( + path, + &SourceContext { + root: package_root.to_path_buf(), + include_roots: include_roots.to_vec(), + }, + ) +} + +fn read_deck_with_context(path: &Path, context: &SourceContext) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let input = resolve_source_includes(&input, path, context)?; + canonical_yaml::from_str(&input).map_err(|error| error.to_string()) +} + +fn read_overlay_from_package( + path: &Path, + package_root: &Path, + include_roots: &[PathBuf], +) -> Result { + read_overlay_with_context( + path, + &SourceContext { + root: package_root.to_path_buf(), + include_roots: include_roots.to_vec(), + }, + ) +} + +fn read_overlay_with_context(path: &Path, context: &SourceContext) -> Result { + let input = fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?; + let input = resolve_source_includes(&input, path, context)?; + 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_deck_and_overlays( + deck_path: &Path, + overlay_paths: &[String], +) -> Result<(CanonicalDeck, Vec<(String, Overlay)>), String> { + let context = source_context_for_path(deck_path)?; + let deck = read_deck_with_context(deck_path, &context)?; + let overlays = overlay_paths + .iter() + .map(|path| { + Ok(( + path.clone(), + read_overlay_with_context(Path::new(path), &context)?, + )) + }) + .collect::, String>>()?; + Ok((deck, overlays)) +} + +pub(crate) fn read_and_compose_deck( + deck_path: &Path, + overlay_paths: &[String], +) -> Result { + let (deck, overlays) = read_deck_and_overlays(deck_path, overlay_paths)?; + deck.compose( + &overlays + .into_iter() + .map(|(_, overlay)| overlay) + .collect::>(), + ) + .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) package_root: PathBuf, + pub(crate) include_roots: Vec, +} + +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, + include_roots: Vec, + 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)?; + let root = manifest_root(path); + let include_roots = include_roots_from_manifest(&root, &manifest); + Ok(LoadedManifest { + path: path.clone(), + root, + include_roots, + 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_from_package( + &loaded.root.join(&loaded.manifest.base), + &loaded.root, + &loaded.include_roots, + )?, + ) + }; + + let planned_overlays = self.expand_target_overlays(manifest_index, target)?; + let overlays = planned_overlays + .into_iter() + .map(|planned| { + let overlay = read_overlay_from_package( + &planned.file, + &planned.package_root, + &planned.include_roots, + )?; + 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, + package_root: loaded.root.clone(), + include_roots: loaded.include_roots.clone(), + }); + 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 = source_includes::format_preserving_file_includes(&input, format)?; + 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() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() +} + +pub(crate) struct SourceContext { + pub(crate) root: PathBuf, + pub(crate) include_roots: Vec, +} + +pub(crate) fn source_context_for_path(path: &Path) -> Result { + let root = nearest_manifest_root(path).unwrap_or_else(|| manifest_root(path)); + let manifest_path = root.join("brainbrew.yaml"); + let include_roots = if manifest_path.exists() { + let manifest = read_manifest(&manifest_path)?; + include_roots_from_manifest(&root, &manifest) + } else { + Vec::new() + }; + Ok(SourceContext { + root, + include_roots, + }) +} + +fn nearest_manifest_root(path: &Path) -> Option { + let mut current = path.parent()?; + loop { + if current.join("brainbrew.yaml").exists() { + return Some(current.to_path_buf()); + } + current = current.parent()?; + } +} + +pub(crate) fn include_roots_from_manifest( + root: &Path, + manifest: &manifest::FederatedDeckManifest, +) -> Vec { + manifest + .include_roots + .iter() + .map(|include_root| root_relative_path(root, Path::new(include_root))) + .collect() +} + +pub(crate) fn top_level_media_include_path(value: &Value) -> Result, String> { + let Some(media_value) = value + .as_mapping() + .and_then(|mapping| mapping.get(Value::String("media".to_owned()))) + else { + return Ok(None); + }; + let Value::Tagged(tagged) = media_value else { + return Ok(None); + }; + if tagged.tag != "include" { + return Ok(None); + } + let Value::String(path) = &tagged.value else { + return Err("media !include path must be a scalar string".to_owned()); + }; + Ok(Some(path.clone())) +} + +pub(crate) fn resolve_include_target_for_context( + include_path: &str, + context: &SourceContext, +) -> Result { + source_includes::resolve_include_target(include_path, &context.root, &context.include_roots) + .map_err(|error| error.to_string()) +} + +fn resolve_source_includes( + input: &str, + path: &Path, + context: &SourceContext, +) -> Result { + source_includes::resolve_file_includes(input, path, &context.root, &context.include_roots) + .map_err(|error| error.to_string()) +} diff --git a/crates/brain-brew-cli/src/main.rs b/crates/brain-brew-cli/src/main.rs new file mode 100644 index 0000000..f4887cb --- /dev/null +++ b/crates/brain-brew-cli/src/main.rs @@ -0,0 +1,116 @@ +//! 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() { + let args = env::args().skip(1).collect::>(); + let json_error_output = json_error_output_requested(&args); + if let Err(error) = run(&args) { + if error != output::JSON_ERROR_ALREADY_PRINTED { + if json_error_output { + output::print_json_error(&error); + } else { + output::print_error(&error); + } + } + process::exit(1); + } +} + +fn run(args: &[String]) -> Result<(), String> { + let Some(command) = args.first().map(String::as_str) else { + print_usage(); + return Ok(()); + }; + + let command = canonical_command(command); + + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + if command == "--help" || command == "-h" { + print_usage(); + return Ok(()); + } + if 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..]), + "media" => commands::media::run(&args[1..]), + "targets" => commands::targets::run(&args[1..]), + "translations" => commands::translations::run(&args[1..]), + "verify" => commands::verify::run(&args[1..]), + "workbench" => commands::workbench::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(unknown_command_error(other)), + } +} + +fn canonical_command(command: &str) -> &str { + match command { + "translate" | "translation" => "translations", + other => other, + } +} + +fn json_error_output_requested(args: &[String]) -> bool { + let Some(command) = args.first().map(|command| canonical_command(command)) else { + return false; + }; + matches!(command, "validate" | "explain" | "diff" | "targets") + && args.iter().any(|arg| arg == "--json") +} + +fn unknown_command_error(command: &str) -> String { + if command.starts_with("translat") || levenshtein_one_or_two(command, "translations") { + format!("unknown command {command:?}\n\nDid you mean:\n brainbrew translations") + } else { + format!("unknown command {command:?}") + } +} + +fn levenshtein_one_or_two(left: &str, right: &str) -> bool { + let mut previous = (0..=right.len()).collect::>(); + let mut current = vec![0; right.len() + 1]; + for (left_index, left_char) in left.chars().enumerate() { + current[0] = left_index + 1; + for (right_index, right_char) in right.chars().enumerate() { + let substitution = usize::from(left_char != right_char); + current[right_index + 1] = (previous[right_index + 1] + 1) + .min(current[right_index] + 1) + .min(previous[right_index] + substitution); + } + std::mem::swap(&mut previous, &mut current); + } + previous[right.len()] <= 2 +} + +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..62792dd --- /dev/null +++ b/crates/brain-brew-cli/src/media_assets.rs @@ -0,0 +1,77 @@ +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_references(deck: &CanonicalDeck) -> Result<(), String> { + let report = media::reference_report(deck); + for warning in &report.warnings { + eprintln!("warning: {}", warning.message); + } + if report.errors.is_empty() { + Ok(()) + } else { + Err(media::MediaValidationReport { + errors: report.errors, + } + .to_string()) + } +} + +pub(crate) fn validate_media_assets(deck: &CanonicalDeck, media_root: &Path) -> Result<(), String> { + validate_media_references(deck)?; + 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); + match fs::read(&full_path) { + Ok(bytes) => { + assets.insert(media.path.clone(), bytes); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("{}: {error}", full_path.display())), + } + } + 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..d7880f0 --- /dev/null +++ b/crates/brain-brew-cli/src/output.rs @@ -0,0 +1,187 @@ +use std::env; +use std::io::{self, IsTerminal}; + +use brain_brew_core::{CanonicalDeck, SemanticChangeKind, SemanticDiff}; +use brain_brew_formats::manifest; +use serde_json::{Value, json}; + +pub(crate) const JSON_ERROR_ALREADY_PRINTED: &str = "__brainbrew_json_error_already_printed"; + +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 print_json_error(message: &str) { + print_json_error_value(json!({"message": message})); +} + +pub(crate) fn print_json_error_value(error: Value) { + println!( + "{}", + serde_json::to_string_pretty(&json!({"error": error})).unwrap() + ); +} + +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..47a8e41 --- /dev/null +++ b/crates/brain-brew-cli/src/overlay_draft.rs @@ -0,0 +1,301 @@ +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()), + message: None, + images: None, + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + } + + let mut tags = BTreeMap::new(); + for tag in left_note.tags.difference(&right_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Remove, + expected_base: Some(ExpectedBase::EntityPresent), + }, + ); + } + for tag in right_note.tags.difference(&left_note.tags) { + tags.insert( + tag.clone(), + TagChange { + intent: ChangeIntent::Add, + expected_base: None, + }, + ); + } + + let adapter_ids = adapter_id_changes(&left_note.adapter_ids, &right_note.adapter_ids); + + if !fields.is_empty() || !tags.is_empty() || !adapter_ids.is_empty() { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Merge, + note: None, + variables: BTreeMap::new(), + fields, + tags, + adapter_ids, + expected_base: None, + }, + ); + } + } + + for (note_id, right_note) in &right.notes { + if !left.notes.contains_key(note_id) { + overlay.note_changes.insert( + note_id.clone(), + NoteChange { + intent: ChangeIntent::Add, + note: Some(right_note.clone()), + variables: BTreeMap::new(), + fields: BTreeMap::new(), + tags: BTreeMap::new(), + adapter_ids: BTreeMap::new(), + expected_base: None, + }, + ); + } + } +} + +fn draft_media_changes(left: &CanonicalDeck, right: &CanonicalDeck, overlay: &mut Overlay) { + for (media_id, left_media) in &left.media { + match right.media.get(media_id) { + Some(right_media) if left_media != right_media => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Replace, + media: Some(right_media.clone()), + expected_base: Some(ExpectedBase::Value(media_reference_summary( + left_media, + ))), + }, + ); + } + Some(_) => {} + None => { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Remove, + media: None, + expected_base: Some(ExpectedBase::Value(media_reference_summary( + left_media, + ))), + }, + ); + } + } + } + + for (media_id, right_media) in &right.media { + if !left.media.contains_key(media_id) { + overlay.media_changes.insert( + media_id.clone(), + MediaChange { + intent: ChangeIntent::Add, + media: Some(right_media.clone()), + expected_base: None, + }, + ); + } + } +} + +fn adapter_id_changes(left: &AdapterIds, right: &AdapterIds) -> BTreeMap { + let left = left + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let right = right + .iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + let mut changes = BTreeMap::new(); + + for (key, left_value) in &left { + match right.get(key) { + Some(right_value) if left_value != right_value => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Replace, + value: Some(right_value.clone()), + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + Some(_) => {} + None => { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Remove, + value: None, + expected_base: Some(ExpectedBase::Value(left_value.clone())), + }, + ); + } + } + } + + for (key, right_value) in &right { + if !left.contains_key(key) { + changes.insert( + key.clone(), + AdapterIdChange { + intent: ChangeIntent::Add, + value: Some(right_value.clone()), + expected_base: None, + }, + ); + } + } + + changes +} + +fn media_reference_summary(media: &MediaReference) -> String { + format!("path={};sha256={}", media.path, media.sha256) +} + +fn replace_property_change(before: &str, after: &str) -> PropertyChange { + PropertyChange { + intent: ChangeIntent::Replace, + value: Some(after.to_owned()), + expected_base: Some(ExpectedBase::Value(before.to_owned())), + } +} diff --git a/crates/brain-brew-cli/src/package_resolver.rs b/crates/brain-brew-cli/src/package_resolver.rs new file mode 100644 index 0000000..9648106 --- /dev/null +++ b/crates/brain-brew-cli/src/package_resolver.rs @@ -0,0 +1,93 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use brain_brew_formats::manifest; + +/// Discover local Federated Deck package manifests and validate their package dependencies. +pub(crate) fn discover_package_manifests(roots: &[PathBuf]) -> Result, String> { + let mut paths = BTreeSet::new(); + for root in roots { + collect_manifests(root, &mut paths)?; + } + Ok(paths.into_iter().collect()) +} + +pub(crate) fn validate_package_dependencies( + packages: &[(&PathBuf, &manifest::FederatedDeckManifest)], +) -> Result<(), String> { + let mut by_id = BTreeMap::new(); + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + if let Some(previous) = by_id.insert(package.id.clone(), (*path, package.version.clone())) { + return Err(format!( + "duplicate package id {} in {} and {}", + package.id, + previous.0.display(), + path.display() + )); + } + } + + for (path, manifest) in packages { + let Some(package) = &manifest.package else { + continue; + }; + for dependency in &package.depends_on { + let (dependency_id, expected_version) = parse_dependency(dependency); + let Some((dependency_path, actual_version)) = by_id.get(dependency_id) else { + return Err(format!( + "package dependency {dependency_id} required by {} in {} was not found", + package.id, + path.display() + )); + }; + if let Some(expected_version) = expected_version + && actual_version != expected_version + { + return Err(format!( + "package dependency {dependency_id}@{expected_version} required by {} in {} resolved to version {} in {}", + package.id, + path.display(), + actual_version, + dependency_path.display() + )); + } + } + } + + Ok(()) +} + +fn collect_manifests(root: &Path, paths: &mut BTreeSet) -> Result<(), String> { + let metadata = fs::metadata(root).map_err(|error| format!("{}: {error}", root.display()))?; + if metadata.is_file() { + if root.file_name().and_then(|name| name.to_str()) == Some("brainbrew.yaml") { + paths.insert(root.to_path_buf()); + } + return Ok(()); + } + + let manifest = root.join("brainbrew.yaml"); + if manifest.exists() { + paths.insert(manifest); + } + + for entry in fs::read_dir(root).map_err(|error| format!("{}: {error}", root.display()))? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if path.is_dir() { + collect_manifests(&path, paths)?; + } + } + + Ok(()) +} + +fn parse_dependency(dependency: &str) -> (&str, Option<&str>) { + dependency + .split_once('@') + .map_or((dependency, None), |(id, version)| (id, Some(version))) +} diff --git a/crates/brain-brew-cli/tests/cli.rs b/crates/brain-brew-cli/tests/cli.rs new file mode 100644 index 0000000..6e25ac2 --- /dev/null +++ b/crates/brain-brew-cli/tests/cli.rs @@ -0,0 +1,7038 @@ +use std::collections::BTreeSet; +use std::fs; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdout, Command, Stdio}; +use std::thread; +use std::time::{Duration, 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 workbench_help_includes_serve_entrypoint() { + let top = run(["--help"]); + assert!(top.status.success(), "stderr: {}", stderr(&top)); + assert!(stdout(&top).contains("workbench")); + + let command = run(["workbench", "--help"]); + assert!(command.status.success(), "stderr: {}", stderr(&command)); + let out = stdout(&command); + assert!(out.contains("brainbrew workbench serve --manifest brainbrew.yaml")); + assert!(out.contains("--port")); + assert!(out.contains("--no-open")); + assert!(out.contains("--dev-assets")); + assert!(out.contains("target/workbench-ui")); + + let serve = run(["workbench", "serve", "--help"]); + assert!(serve.status.success(), "stderr: {}", stderr(&serve)); + assert!(stdout(&serve).contains("brainbrew workbench serve --manifest brainbrew.yaml")); +} + +#[test] +fn workbench_serve_exposes_workspace_metadata_api() { + let dir = temp_dir("workbench-api"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let health = get_json(&server.url("/api/health")); + assert_eq!(health["status"], "ok"); + assert_eq!( + health["manifest"], + dir.join("brainbrew.yaml").display().to_string() + ); + + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["manifest"], + dir.join("brainbrew.yaml").display().to_string() + ); + assert_eq!(workspace["languages"]["en"]["display_name"], "English"); + assert_eq!(workspace["languages"]["en"]["source"], true); + assert_eq!( + workspace["languages"]["da"]["translation_overlays"]["base"], + "overlay.translation.da" + ); + assert_eq!( + workspace["languages"]["da"]["targets"]["standard"], + "da-standard" + ); + assert_eq!( + workspace["target_labels"]["da-standard"][0]["language"], + "da" + ); + assert_eq!( + workspace["target_labels"]["da-standard"][0]["label"], + "standard" + ); + assert_eq!( + workspace["translation_profile"]["structural_fields"][0], + "field.flag" + ); + let fingerprints = workspace["fingerprints"].as_array().unwrap(); + assert!( + fingerprints + .iter() + .any(|entry| entry["path"] == "brainbrew.yaml") + ); + assert!( + fingerprints + .iter() + .any(|entry| entry["path"] == "deck.yaml") + ); + assert!(fingerprints.iter().any(|entry| entry["path"] == "da.yaml")); + assert!( + fingerprints + .iter() + .all(|entry| entry["sha256"].as_str().unwrap().len() == 64) + ); + let original_da_fingerprint = fingerprints + .iter() + .find(|entry| entry["path"] == "da.yaml") + .unwrap()["sha256"] + .as_str() + .unwrap() + .to_owned(); + + fs::write( + dir.join("da.yaml"), + r#"id: overlay.translation.da +kind: translation +translations: + direct: + Finland: Suomi +"#, + ) + .unwrap(); + let updated = get_json(&server.url("/api/workspace")); + let updated_da_fingerprint = updated["fingerprints"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["path"] == "da.yaml") + .unwrap()["sha256"] + .as_str() + .unwrap(); + assert_ne!(updated_da_fingerprint, original_da_fingerprint); +} + +#[test] +fn workbench_new_language_scaffold_preview_write_and_initial_edit() { + let dir = temp_dir("workbench-new-language"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let preview = get_json(&server.url( + "/api/workbench/new-language-preview?template=da&code=nb&display_name=Norwegian%20Bokmal", + )); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["language"]["code"], "nb"); + assert_eq!(preview["groups"][0]["label"], "base"); + assert_eq!(preview["groups"][0]["selected"], true); + assert_eq!(preview["groups"][0]["overlay_id"], "overlay.translation.nb"); + assert_eq!(preview["groups"][0]["file"], "overlays/languages/nb.yaml"); + assert_eq!(preview["targets"][0]["target_id"], "nb-standard"); + assert!( + !dir.join("overlays/languages/nb.yaml").exists(), + "preview must not write overlay files" + ); + assert!( + !fs::read_to_string(dir.join("brainbrew.yaml")) + .unwrap() + .contains("nb-standard") + ); + + let created = post_json( + &server.url("/api/workbench/new-language"), + preview["draft"].clone(), + ); + assert_eq!(created["created"], true); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + assert!(manifest.contains("overlay.translation.nb:")); + assert!(manifest.contains("file: overlays/languages/nb.yaml")); + assert!(manifest.contains("nb-standard:")); + assert!(manifest.contains("nb:\n display_name: Norwegian Bokmal")); + let overlay = fs::read_to_string(dir.join("overlays/languages/nb.yaml")).unwrap(); + assert_eq!( + overlay, + "id: overlay.translation.nb\nkind: translation\ntranslations: {}\n" + ); + + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["languages"]["nb"]["display_name"], + "Norwegian Bokmal" + ); + assert_eq!( + workspace["languages"]["nb"]["translation_overlays"]["base"], + "overlay.translation.nb" + ); + assert!( + workspace["fingerprints"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "overlays/languages/nb.yaml") + ); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=nb&target=standard")); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 0); + assert_eq!(pivot["progress"]["missing"], 2); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "nb", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("overlays/languages/nb.yaml")).unwrap(); + assert!(overlay.contains("Helsinki: Helsingfors")); +} + +#[test] +fn workbench_new_language_scaffold_can_deselect_template_overlay_groups() { + let dir = temp_dir("workbench-new-language-groups"); + fs::write(dir.join("deck.yaml"), SAMPLE_CANONICAL_YAML).unwrap(); + fs::write( + dir.join("da.yaml"), + r#"id: overlay.translation.da +kind: translation +translations: + direct: + Finland: Finland +"#, + ) + .unwrap(); + fs::write( + dir.join("hardcore-da.yaml"), + r#"id: overlay.translation.hardcore.da +kind: translation +translations: + direct: + Helsinki: Helsingfors +"#, + ) + .unwrap(); + fs::write( + dir.join("brainbrew.yaml"), + r#"base: deck.yaml +overlays: + overlay.translation.da: + file: da.yaml + kind: translation + overlay.translation.hardcore.da: + file: hardcore-da.yaml + kind: translation +targets: + da-standard: + overlays: + - overlay.translation.da + - overlay.translation.hardcore.da + en-standard: + overlays: [] +languages: + da: + display_name: Danish + translation_overlays: + base: overlay.translation.da + hardcore: overlay.translation.hardcore.da + primary_target: standard + targets: + standard: da-standard + en: + display_name: English + source: true + primary_target: standard + targets: + standard: en-standard +translation_profile: + structural_fields: + - field.flag +"#, + ) + .unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let preview = get_json( + &server + .url("/api/workbench/new-language-preview?template=da&code=nb&display_name=Norwegian"), + ); + let groups = preview["groups"].as_array().unwrap(); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|group| group["selected"] == true)); + assert_eq!(groups[1]["label"], "hardcore"); + assert_eq!(groups[1]["overlay_id"], "overlay.translation.hardcore.nb"); + assert_eq!(groups[1]["file"], "overlays/languages/hardcore/nb.yaml"); + + let mut draft = preview["draft"].clone(); + draft["groups"][1]["selected"] = serde_json::json!(false); + let created = post_json(&server.url("/api/workbench/new-language"), draft); + assert_eq!(created["created"], true); + let manifest = fs::read_to_string(dir.join("brainbrew.yaml")).unwrap(); + assert!(manifest.contains("overlay.translation.nb:")); + assert!(!manifest.contains("overlay.translation.hardcore.nb")); + assert!(dir.join("overlays/languages/nb.yaml").exists()); + assert!(!dir.join("overlays/languages/hardcore/nb.yaml").exists()); + let workspace = get_json(&server.url("/api/workspace")); + assert_eq!( + workspace["languages"]["nb"]["translation_overlays"]["base"], + "overlay.translation.nb" + ); + assert!(workspace["languages"]["nb"]["translation_overlays"]["hardcore"].is_null()); +} + +#[test] +fn workbench_serve_uses_available_port_and_embedded_assets_by_default() { + let dir = temp_dir("workbench-default-assets"); + write_workbench_workspace(&dir); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--no-open", + ]); + + let response = ureq::get(&server.url("/")).call().expect("GET / succeeds"); + assert_eq!(response.status(), 200); + assert!( + response + .header("content-type") + .unwrap_or_default() + .starts_with("text/html") + ); + let index = response.into_string().unwrap(); + assert!(index.contains("Brain Brew Deck Workbench")); + assert!(index.contains("TrunkApplicationStarted")); + + let js_path = quoted_path_containing(&index, ".js"); + let js_response = ureq::get(&server.url(&js_path)) + .call() + .expect("GET embedded JS succeeds"); + assert_eq!(js_response.status(), 200); + assert!( + js_response + .header("content-type") + .unwrap_or_default() + .contains("javascript") + ); + assert!(js_response.into_string().unwrap().contains("wasm_bindgen")); + + let wasm_path = quoted_path_containing(&index, ".wasm"); + let wasm_response = ureq::get(&server.url(&wasm_path)) + .call() + .expect("GET embedded WASM succeeds"); + assert_eq!(wasm_response.status(), 200); + assert_eq!( + wasm_response.header("content-type").unwrap_or_default(), + "application/wasm" + ); + let mut wasm_bytes = Vec::new(); + wasm_response + .into_reader() + .read_to_end(&mut wasm_bytes) + .unwrap(); + assert!(wasm_bytes.starts_with(b"\0asm")); +} + +#[test] +fn workbench_navigation_lists_are_paginated_and_compact() { + let dir = temp_dir("workbench-navigation-lists"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let notes = get_json( + &server.url("/api/workbench/note-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(notes["language"]["code"], "da"); + assert_eq!(notes["target"]["label"], "standard"); + assert_eq!(notes["total"], 2); + assert_eq!(notes["limit"], 1); + assert_eq!(notes["offset"], 0); + assert_eq!(notes["has_more"], true); + assert_eq!(notes["progress"]["total"], 4); + assert_eq!(notes["rows"].as_array().unwrap().len(), 1); + assert!( + notes["rows"][0]["note_id"] + .as_str() + .unwrap() + .starts_with("note.") + ); + assert!(notes["rows"][0].get("fields").is_none()); + assert!(notes["rows"][0].get("source_preview").is_none()); + + let detail = get_json(&server.url(&format!( + "/api/workbench/note-detail?language=da&target=standard¬e={}", + notes["rows"][0]["note_id"].as_str().unwrap() + ))); + assert_eq!(detail["notes"].as_array().unwrap().len(), 1); + assert_eq!(detail["notes"][0]["note_id"], notes["rows"][0]["note_id"]); + assert!(detail["notes"][0]["fields"].as_array().unwrap().len() >= 2); + + let notes_page_2 = get_json( + &server.url("/api/workbench/note-list?language=da&target=standard&limit=1&offset=1"), + ); + assert_eq!(notes_page_2["total"], 2); + assert_eq!(notes_page_2["rows"].as_array().unwrap().len(), 1); + assert_eq!(notes_page_2["has_more"], false); + assert_ne!( + notes["rows"][0]["note_id"], + notes_page_2["rows"][0]["note_id"] + ); + + let cards = get_json( + &server.url("/api/workbench/card-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(cards["total"], 2); + assert_eq!(cards["progress"]["total"], 2); + assert_eq!(cards["rows"].as_array().unwrap().len(), 1); + assert!(cards["rows"][0]["card_id"].as_str().unwrap().contains("::")); + assert!(cards["rows"][0].get("fields").is_none()); + assert!(cards["rows"][0].get("source_preview").is_none()); + + let strings = get_json( + &server + .url("/api/workbench/source-string-list?language=da&target=standard&limit=1&offset=0"), + ); + assert_eq!(strings["total"], 3); + assert_eq!(strings["progress"]["total"], 3); + assert_eq!(strings["rows"].as_array().unwrap().len(), 1); + assert!(!strings["rows"][0]["source"].as_str().unwrap().is_empty()); + assert!(strings["rows"][0].get("occurrences").is_none()); + + let full_pivot = get_json(&server.url("/api/workbench/card-pivot?language=da&target=standard")); + assert_eq!(full_pivot["cards"].as_array().unwrap().len(), 2); + assert!(full_pivot["selected_card"].get("fields").is_some()); +} + +#[test] +fn workbench_navigation_titles_follow_note_type_field_order() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let notes = get_json( + &server.url("/api/workbench/note-list?language=de&target=standard&limit=8&offset=0"), + ); + let titles = notes["rows"] + .as_array() + .unwrap() + .iter() + .map(|row| row["title"].as_str().unwrap_or_default().to_owned()) + .collect::>(); + assert!(titles.iter().all(|title| !title.trim().is_empty())); + assert_eq!(titles.first().map(String::as_str), Some("Abkhazia")); + assert!(!titles.iter().any(|title| title == "Sukhumi")); +} + +#[test] +fn workbench_optional_metadata_list_is_paginated_and_keeps_full_totals() { + let dir = temp_dir("workbench-optional-metadata-list"); + write_workbench_optional_metadata_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let optional = + get_json(&server.url("/api/workbench/metadata-list?language=da&limit=1&offset=0")); + assert_eq!(optional["language"]["code"], "da"); + assert_eq!(optional["limit"], 1); + assert_eq!(optional["offset"], 0); + assert!(optional["total"].as_u64().unwrap() > 1); + assert_eq!(optional["has_more"], true); + assert_eq!(optional["rows"].as_array().unwrap().len(), 1); + assert_eq!(optional["main_progress"]["total"], 2); + assert_eq!(optional["metadata_progress"]["total"], optional["total"]); + assert!(optional["rows"][0]["path"].as_str().unwrap().contains('.')); + assert!(optional["rows"][0].get("target").is_none()); +} + +#[test] +fn workbench_navigation_lists_reject_invalid_pagination() { + let dir = temp_dir("workbench-navigation-list-errors"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + assert_get_status_contains( + &server.url("/api/workbench/note-list?limit=0"), + 400, + "invalid pagination limit", + ); + assert_get_status_contains( + &server.url("/api/workbench/source-string-list?offset=-1"), + 400, + "invalid pagination offset", + ); + assert_get_status_contains( + &server.url("/api/workbench/card-list?limit=9999"), + 400, + "invalid pagination limit", + ); +} + +#[test] +fn workbench_note_pivot_exposes_target_translation_data_and_media() { + let dir = temp_dir("workbench-note-pivot"); + write_workbench_workspace(&dir); + fs::create_dir_all(dir.join("media/flags")).unwrap(); + fs::write(dir.join("media/flags/fi.png"), b"png").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + assert_eq!(pivot["language"]["code"], "da"); + assert_eq!(pivot["target"]["id"], "da-standard"); + assert_eq!(pivot["overlay"]["id"], "overlay.translation.da"); + assert_eq!(pivot["selection_options"]["languages"][0]["code"], "da"); + assert_eq!( + pivot["selection_options"]["targets"][0]["label"], + "standard" + ); + assert_eq!(pivot["selection_options"]["overlays"][0]["label"], "base"); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 1); + assert_eq!(pivot["progress"]["missing"], 1); + assert_eq!(pivot["overlay_badges"][0]["label"], "base"); + let note = &pivot["notes"][0]; + assert_eq!(note["note_id"], "note.finland"); + assert!( + note["source_preview"]["cards"][0]["question_html"] + .as_str() + .unwrap() + .contains("Finland") + ); + assert!( + note["source_preview"]["cards"][0]["answer_html"] + .as_str() + .unwrap() + .contains("Helsinki") + ); + let capital = note["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap(); + assert_eq!(capital["status"], "untranslated_fallback"); + assert_eq!(capital["occurrence_count"], 1); + assert_eq!(capital["controls"][0], "direct"); + let flag = note["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.flag") + .unwrap(); + assert_eq!(flag["structural"], true); + assert_eq!(flag["editable"], false); + assert_eq!(flag["source_editable"], true); + + let missing = get_json(&server.url("/api/workbench/note-pivot?language=da&filter=missing")); + assert_eq!(missing["notes"].as_array().unwrap().len(), 1); + + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media succeeds"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/png" + ); +} + +#[test] +fn workbench_media_cache_refreshes_after_workspace_edit_and_rejects_undeclared_paths() { + let dir = temp_dir("workbench-media-cache-refresh"); + write_workbench_workspace(&dir); + fs::create_dir_all(dir.join("media/flags")).unwrap(); + fs::write(dir.join("media/flags/fi.png"), b"fi").unwrap(); + fs::write(dir.join("media/flags/se.png"), b"se").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let original = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("initial declared media request warms the cache"); + assert_eq!(original.status(), 200); + + std::thread::sleep(Duration::from_millis(20)); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + fs::write( + dir.join("deck.yaml"), + deck.replace( + " media.flags-fi-png:\n path: flags/fi.png\n sha256: ''", + " media.flags-fi-png:\n path: flags/fi.png\n sha256: ''\n media.flags-se-png:\n path: flags/se.png\n sha256: ''", + ), + ) + .unwrap(); + + let refreshed = ureq::get(&server.url("/api/media/flags/se.png")) + .call() + .expect("newly declared media succeeds after cache invalidation"); + assert_eq!(refreshed.status(), 200); + assert_get_status_contains( + &server.url("/api/media/flags/nope.png"), + 400, + "unknown media asset", + ); +} + +#[test] +fn workbench_media_can_load_from_media_root_or_placeholder() { + let dir = temp_dir("workbench-media-root"); + write_workbench_workspace(&dir); + let external_media = dir.join("external-media"); + fs::create_dir_all(external_media.join("flags")).unwrap(); + fs::write(external_media.join("flags/fi.png"), b"png").unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--media-root", + external_media.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media from media root succeeds"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/png" + ); + + let missing_dir = temp_dir("workbench-missing-media-placeholder"); + write_workbench_workspace(&missing_dir); + let missing_server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + missing_dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let placeholder = ureq::get(&missing_server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared missing media returns visible placeholder"); + assert_eq!(placeholder.status(), 200); + assert_eq!( + placeholder.header("content-type").unwrap_or_default(), + "image/svg+xml" + ); + let body = placeholder.into_string().unwrap(); + assert!(body.contains("Missing media asset")); + assert!(body.contains("flags/fi.png")); +} + +#[test] +fn workbench_media_auto_discovers_external_deck_media() { + let root = temp_dir("workbench-auto-external-media"); + let manifest_dir = root.join("projects/deck-fixture"); + fs::create_dir_all(&manifest_dir).unwrap(); + write_workbench_workspace(&manifest_dir); + let external_media = root.join("external/deck-fixture/media/flags"); + fs::create_dir_all(&external_media).unwrap(); + fs::write(external_media.join("fi.png"), b"png").unwrap(); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest_dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let media = ureq::get(&server.url("/api/media/flags/fi.png")) + .call() + .expect("GET declared media from inferred external media root succeeds"); + assert_eq!(media.status(), 200); + assert_eq!( + media.header("content-type").unwrap_or_default(), + "image/png" + ); +} + +#[test] +fn workbench_ug_detail_media_urls_serve_declared_bytes_from_media_root() { + let media_root = temp_dir("workbench-ug-media-diagnostics-root"); + write_ug_media_diagnostics_root(&media_root); + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + manifest.to_str().unwrap(), + "--media-root", + media_root.to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let note_detail = get_json( + &server.url("/api/workbench/note-detail?language=de&target=standard¬e=note.abkhazia"), + ); + let card_pivot = get_json(&server.url( + "/api/workbench/card-pivot?language=de&target=standard&card=note.abkhazia%3A%3Atemplate.flag-country", + )); + let mut media_paths = BTreeSet::new(); + collect_api_media_paths(¬e_detail, &mut media_paths); + collect_api_media_paths(&card_pivot, &mut media_paths); + + for expected in ["ug-flag-abkhazia.svg", "ug-map-abkhazia.png"] { + assert!( + media_paths.contains(expected), + "detail APIs did not expose expected media path {expected:?}; extracted paths: {media_paths:?}" + ); + } + assert!( + media_paths.len() >= 2, + "expected several unique UG media paths from detail APIs; extracted paths: {media_paths:?}" + ); + + for media_path in &media_paths { + assert_media_response_serves_declared_bytes(&server, &media_root, media_path); + } +} + +#[test] +fn workbench_optional_metadata_progress_and_apply_are_separate_from_main_fields() { + let dir = temp_dir("workbench-optional-metadata"); + write_workbench_optional_metadata_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da")); + assert_eq!(pivot["progress"]["total"], 2); + assert_eq!(pivot["progress"]["complete"], 2); + assert_eq!(pivot["progress"]["stale"], 0); + assert!(pivot["metadata_progress"]["stale"].as_u64().unwrap() >= 1); + + let optional = get_json(&server.url("/api/workbench/metadata?language=da")); + assert_eq!(optional["main_progress"]["complete"], 2); + let optional_items = optional["items"].as_array().unwrap(); + assert!( + optional_items.iter().all(|item| !item["path"] + .as_str() + .unwrap_or_default() + .contains(".adapter_ids.")), + "adapter IDs are identity metadata, not optional translation metadata: {optional_items:?}" + ); + let category_positions = optional_items + .iter() + .enumerate() + .map(|(index, item)| { + ( + item["metadata_category_key"].as_str().unwrap_or_default(), + index, + ) + }) + .collect::>(); + let note_type_position = category_positions + .iter() + .find_map(|(category, index)| (*category == "note-type-name").then_some(*index)) + .unwrap(); + let field_label_position = category_positions + .iter() + .find_map(|(category, index)| (*category == "field-label").then_some(*index)) + .unwrap(); + let template_position = category_positions + .iter() + .find_map(|(category, index)| (*category == "card-template-name").then_some(*index)) + .unwrap(); + assert!( + note_type_position < field_label_position && field_label_position < template_position, + "metadata should be grouped in maintainer-friendly category order: {category_positions:?}" + ); + let deck_name = optional_items + .iter() + .find(|item| item["path"] == "deck.name") + .unwrap(); + assert_eq!(deck_name["status"], "stale"); + assert!( + deck_name["warning"] + .as_str() + .unwrap() + .contains("Old Workbench") + ); + + let request = serde_json::json!({ + "language": "da", + "edits": [{ + "kind": "translation", + "path": "deck.name", + "source": "Ultimate Geography", + "value": "Arbejdsbord Røgtest", + "mode": "direct" + }] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["changed_entries"][0]["path"], "deck.name"); + assert!( + preview["file_groups"] + .as_array() + .unwrap() + .iter() + .any(|file| file["file"] == "da.yaml") + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Arbejdsbord Røgtest") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["applied"], true); + assert!( + fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Arbejdsbord Røgtest") + ); +} + +#[test] +fn workbench_apply_rejects_invalid_rendered_description_content() { + let dir = temp_dir("workbench-content-validation"); + write_workbench_optional_metadata_workspace(&dir); + let original_overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "edits": [{ + "kind": "translation", + "path": "deck.description", + "source": "A geography deck fixture.", + "value": "

unterminated", + "mode": "direct" + }] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["ok"], false); + let errors = preview["validation"]["errors"].as_array().unwrap(); + assert!(errors.iter().any(|error| { + error + .as_str() + .unwrap() + .contains("deck.description:1: unclosed HTML tag

") + })); + + let error = post_json_error(&server.url("/api/workbench/apply"), request); + assert_eq!(error.0, 500); + assert!(error.1.contains("validation failed")); + assert_eq!( + fs::read_to_string(dir.join("da.yaml")).unwrap(), + original_overlay + ); +} + +#[test] +fn workbench_comparison_pane_summarizes_note_source_string_and_card_context() { + let dir = temp_dir("workbench-comparison-pane"); + write_workbench_repeated_source_multi_language_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let comparison = get_json( + &server.url("/api/workbench/comparison-pane?language=nb&target=standard&overlay=base"), + ); + assert_eq!(comparison["language"]["code"], "nb"); + assert_eq!(comparison["target_label"], "standard"); + assert!( + comparison["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|group| group == "Europe") + ); + let strings = comparison["source_string_pivot"]["strings"] + .as_array() + .unwrap(); + let shared = strings + .iter() + .find(|string| string["source"] == "Shared capital") + .unwrap(); + assert_eq!(shared["target_preview"], "Felles hovedstad"); + assert_eq!(shared["occurrence_count"], 2); + assert!(comparison["card_pivot"]["cards"].as_array().unwrap().len() >= 2); + assert!(comparison["note_pivot"]["notes"].as_array().unwrap().len() >= 2); +} + +#[test] +fn workbench_apply_groups_multi_pane_edits_by_file_and_content_group() { + let dir = temp_dir("workbench-multi-pane-apply"); + write_multi_language_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "source", + "path": "notes.note.finland.fields.field.country", + "source": "Finland", + "value": "Finland source", + "scope": "field", + "impact_action": "stale_translation" + }, + { + "kind": "translation", + "language": "da", + "target": "standard", + "overlay": "base", + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors multi", + "mode": "direct" + }, + { + "kind": "translation", + "language": "nb", + "target": "standard", + "overlay": "base", + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsinki norsk", + "mode": "direct" + } + ] + }); + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["validation"]["ok"], true); + let affected = preview["affected_files"].as_array().unwrap(); + assert!(affected.iter().any(|file| file["path"] == "deck.yaml")); + assert!(affected.iter().any(|file| file["path"] == "da.yaml")); + assert!(affected.iter().any(|file| file["path"] == "nb.yaml")); + let groups = preview["file_groups"].as_array().unwrap(); + assert!(groups.iter().any(|group| { + group["file"] == "deck.yaml" + && group["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|content_group| content_group["name"] == "Europe") + })); + assert!(groups.iter().any(|group| group["file"] == "da.yaml")); + assert!(groups.iter().any(|group| group["file"] == "nb.yaml")); + assert!( + !fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("Finland source") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + assert!( + fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("field.country: Finland source") + ); + let da = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(da.contains("Helsinki: Helsingfors multi")); + assert!(da.contains("old_source: Finland")); + assert!(da.contains("new_source: Finland source")); + let nb = fs::read_to_string(dir.join("nb.yaml")).unwrap(); + assert!(nb.contains("Helsinki: Helsinki norsk")); +} + +#[test] +fn workbench_apply_validation_failure_leaves_all_targets_unchanged() { + let dir = temp_dir("workbench-atomic-validate"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + let original_da = fs::read(dir.join("da.yaml")).unwrap(); + let original_nb = fs::read(dir.join("nb.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_ATOMIC_WRITE_FAIL_VALIDATE_INDEX", "1")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic validation"), + ); + + assert_eq!(error.0, 500); + assert!( + error.1.contains("validation/serialization") || error.1.contains("validate"), + "unexpected error body: {}", + error.1 + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), original_da); + assert_eq!(fs::read(dir.join("nb.yaml")).unwrap(), original_nb); +} + +#[test] +fn workbench_apply_temp_write_failure_leaves_targets_unchanged() { + let dir = temp_dir("workbench-atomic-temp-fail"); + write_multi_language_workbench_workspace(&dir); + let original_deck = fs::read(dir.join("deck.yaml")).unwrap(); + let original_da = fs::read(dir.join("da.yaml")).unwrap(); + let original_nb = fs::read(dir.join("nb.yaml")).unwrap(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_ATOMIC_WRITE_FAIL_TEMP_INDEX", "1")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic temp"), + ); + + assert_eq!(error.0, 500); + assert!( + error.1.contains("write temporary") || error.1.contains("temp"), + "unexpected error body: {}", + error.1 + ); + assert_eq!(fs::read(dir.join("deck.yaml")).unwrap(), original_deck); + assert_eq!(fs::read(dir.join("da.yaml")).unwrap(), original_da); + assert_eq!(fs::read(dir.join("nb.yaml")).unwrap(), original_nb); +} + +#[test] +fn workbench_apply_rename_failure_reports_updated_and_not_updated_files() { + let dir = temp_dir("workbench-atomic-rename-fail"); + write_multi_language_workbench_workspace(&dir); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_ATOMIC_WRITE_FAIL_RENAME_INDEX", "1")], + ); + + let error = post_json_error( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic rename"), + ); + + assert_eq!(error.0, 500); + assert!(error.1.contains("rename phase failed"), "{}", error.1); + assert!(error.1.contains("updated files: deck.yaml"), "{}", error.1); + assert!( + error.1.contains("not updated files: da.yaml, nb.yaml") + || error.1.contains("not updated files: nb.yaml, da.yaml"), + "{}", + error.1 + ); +} + +#[test] +fn workbench_apply_uses_atomic_temp_rename_helper() { + let dir = temp_dir("workbench-atomic-trace"); + write_multi_language_workbench_workspace(&dir); + let trace_path = dir.join("atomic-trace.log"); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[("BRAINBREW_ATOMIC_WRITE_TRACE", trace_path.to_str().unwrap())], + ); + + let applied = post_json( + &server.url("/api/workbench/apply"), + atomic_multi_file_apply_request("atomic trace"), + ); + + assert_eq!(applied["applied"], true); + let trace = fs::read_to_string(trace_path).expect("atomic helper writes a trace"); + assert!(trace.contains("transaction_begin"), "trace: {trace}"); + assert!(trace.contains("temp_write"), "trace: {trace}"); + assert!(trace.contains("rename"), "trace: {trace}"); + assert!( + !trace.contains("write_target"), + "apply writes must not go directly to targets: {trace}" + ); +} + +#[test] +fn workbench_concurrent_apply_requests_are_serialized() { + let dir = temp_dir("workbench-atomic-concurrent"); + write_multi_language_workbench_workspace(&dir); + let trace_path = dir.join("atomic-concurrent-trace.log"); + let trace_path_text = trace_path.to_str().unwrap().to_owned(); + let server = spawn_workbench_server_with_env( + [ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ], + &[ + ("BRAINBREW_ATOMIC_WRITE_TRACE", trace_path_text.as_str()), + ("BRAINBREW_ATOMIC_WRITE_SLEEP_BEFORE_RENAME_MS", "150"), + ], + ); + let first_url = server.url("/api/workbench/apply"); + let second_url = first_url.clone(); + let first = atomic_translation_apply_request("first serialized"); + let second = atomic_translation_apply_request("second serialized"); + + let first_thread = thread::spawn(move || post_json(&first_url, first)); + let second_thread = thread::spawn(move || post_json(&second_url, second)); + assert_eq!(first_thread.join().unwrap()["applied"], true); + assert_eq!(second_thread.join().unwrap()["applied"], true); + + let trace = fs::read_to_string(trace_path).expect("atomic helper writes a trace"); + let mut active_transactions = 0usize; + for line in trace.lines() { + if line.contains("transaction_begin") { + assert_eq!( + active_transactions, 0, + "concurrent apply transactions overlapped: {trace}" + ); + active_transactions += 1; + } else if line.contains("transaction_end") { + active_transactions = active_transactions + .checked_sub(1) + .expect("transaction_end without transaction_begin"); + } + } + assert_eq!(active_transactions, 0, "unterminated transaction: {trace}"); +} + +#[test] +fn workbench_card_pivot_navigates_previews_and_applies_field_edit() { + let dir = temp_dir("workbench-card-pivot"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url("/api/workbench/card-pivot?language=da&target=standard")); + assert_eq!(pivot["progress"]["total"], 1); + assert_eq!(pivot["progress"]["missing"], 1); + assert_eq!( + pivot["cards"][0]["card_id"], + "note.finland::template.country-capital" + ); + assert_eq!(pivot["cards"][0]["status"], "missing"); + assert_eq!( + pivot["selected_card"]["template_id"], + "template.country-capital" + ); + assert!( + pivot["selected_card"]["source_preview"]["cards"][0]["question_html"] + .as_str() + .unwrap() + .contains("Finland") + ); + assert!( + pivot["selected_card"]["target_preview"]["cards"][0]["answer_html"] + .as_str() + .unwrap() + .contains("Helsinki") + ); + let capital = pivot["selected_card"]["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap(); + assert_eq!(capital["status"], "untranslated_fallback"); + assert_eq!(capital["editable"], true); + + let filtered = get_json(&server.url( + "/api/workbench/card-pivot?language=da&target=standard&filter=missing&content_group=Europe", + )); + assert_eq!(filtered["cards"].as_array().unwrap().len(), 1); + let no_match = get_json( + &server.url("/api/workbench/card-pivot?language=da&target=standard&content_group=Asia"), + ); + assert_eq!(no_match["cards"].as_array().unwrap().len(), 0); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors card", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + assert!( + fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Helsinki: Helsingfors card") + ); +} + +#[test] +fn workbench_source_string_pivot_supports_direct_contextual_and_no_change_edits() { + let dir = temp_dir("workbench-source-string"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url( + "/api/workbench/source-string-pivot?language=da&target=standard&source=Shared%20capital", + )); + let shared = pivot["strings"] + .as_array() + .unwrap() + .iter() + .find(|source| source["source"] == "Shared capital") + .unwrap(); + assert_eq!(shared["occurrence_count"], 2); + assert_eq!(shared["direct_applies_to"], 2); + assert_eq!(shared["status"], "complete"); + assert_eq!(pivot["occurrences"].as_array().unwrap().len(), 2); + assert!( + pivot["occurrences"][0]["friendly_context"] + .as_str() + .unwrap() + .contains("Capital") + ); + assert!( + pivot["filters"]["content_groups"] + .as_array() + .unwrap() + .iter() + .any(|group| group == "Europe") + ); + + let preview = post_json( + &server.url("/api/workbench/apply-preview"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Fælles hovedstad opdateret", + "mode": "direct" + }] + }), + ); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["changed_entries"][0]["mode"], "direct"); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("opdateret") + ); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finsk særskilt", + "mode": "contextual", + "context_path": "notes.note.finland.fields.field.capital" + }, + { + "kind": "translation", + "path": "notes.note.estonia.fields.field.country", + "source": "Estonia", + "value": "Estonia", + "mode": "contextual", + "context_path": "notes.note.estonia.fields.field.country" + }, + { + "kind": "translation", + "path": "notes.note.finland.fields.field.country", + "source": "Finland", + "value": "Finland", + "mode": "no_change" + } + ] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("no_change:")); + assert!(overlay.contains("- Finland")); + assert!(overlay.contains("finland.fields.field.capital")); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Finsk særskilt")); + assert!(overlay.contains("estonia.fields.field.country")); + assert!(overlay.contains("Estonia: Estonia")); +} + +#[test] +fn workbench_source_string_pivot_exposes_structured_message_components() { + let dir = temp_dir("workbench-source-string-structured"); + write_structured_message_translation_workspace(&dir); + fs::write( + dir.join("brainbrew.yaml"), + r#"base: deck.yaml +overlays: + overlay.translation.nb: + file: nb.yaml + kind: translation +targets: + nb-standard: + overlays: + - overlay.translation.nb + en-standard: + overlays: [] +languages: + nb: + display_name: Norwegian Bokmål + translation_overlays: + base: overlay.translation.nb + primary_target: standard + targets: + standard: nb-standard + en: + display_name: English + source: true + primary_target: standard + targets: + standard: en-standard +translation_profile: + structural_fields: + - field.flag + metadata_categories: + - key: deck-metadata + label: Deck metadata + paths: + - deck.name + - deck.description + metadata_paths: + - deck.* +"#, + ) + .unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let pivot = get_json(&server.url( + "/api/workbench/source-string-pivot?language=nb&target=standard&source=blue%20background%20with%20a%20white%20cross", + )); + assert_eq!( + pivot["selected_source"], + "blue background with a white cross" + ); + assert_eq!(pivot["occurrences"].as_array().unwrap().len(), 1); + let occurrence = &pivot["occurrences"][0]; + assert_eq!( + occurrence["path"], + "notes.note.finland.fields.field.flag-similarity.message.2" + ); + assert_eq!(occurrence["target"], "blå bakgrunn med hvitt kors"); + + let applied = post_json( + &server.url("/api/workbench/apply"), + serde_json::json!({ + "language": "nb", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "translation", + "path": "notes.note.finland.fields.field.flag-similarity.message.2", + "source": "blue background with a white cross", + "value": "blå bakgrunn med kvitt kors", + "mode": "direct" + }] + }), + ); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("nb.yaml")).unwrap(); + assert!(overlay.contains("blue background with a white cross:")); + assert!(overlay.contains("blå bakgrunn med kvitt kors")); +} + +#[test] +fn workbench_apply_preview_and_apply_write_translation_overlay() { + let dir = temp_dir("workbench-apply"); + write_workbench_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "direct" + }] + }); + + let rejected = ureq::post(&server.url("/api/workbench/apply-preview")) + .set("content-type", "application/json") + .send_string( + &serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Bogus", + "value": "Stale", + "mode": "direct" + }] + }) + .to_string(), + ) + .expect_err("stale staged source is rejected"); + match rejected { + ureq::Error::Status(status, response) => { + assert_eq!(status, 400); + assert!(response.into_string().unwrap().contains("invalid source")); + } + other => panic!("unexpected stale-source error: {other}"), + } + + let contextual_preview = post_json( + &server.url("/api/workbench/apply-preview"), + serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "path": "notes.note.finland.fields.field.capital", + "source": "Helsinki", + "value": "Helsingfors", + "mode": "contextual" + }] + }), + ); + assert_eq!(contextual_preview["validation"]["ok"], true); + assert_eq!( + contextual_preview["changed_entries"][0]["path"], + "notes.note.finland" + ); + assert_eq!( + contextual_preview["changed_entries"][0]["field_path"], + "notes.note.finland.fields.field.capital" + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("contextual") + ); + + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["mode"], "preview"); + assert_eq!(preview["applied"], false); + assert_eq!(preview["validation"]["ok"], true); + assert_eq!(preview["affected_files"][0]["path"], "da.yaml"); + assert_eq!(preview["changed_entries"][0]["source"], "Helsinki"); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("Helsingfors") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["mode"], "write"); + assert_eq!(applied["applied"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Helsinki: Helsingfors")); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + assert_eq!(pivot["progress"]["complete"], 2); + let capital = pivot["notes"][0]["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(capital["target"], "Helsingfors"); + assert_eq!(capital["status"], "direct_translation"); +} + +#[test] +fn workbench_source_edits_create_contextual_stale_translations_for_changed_occurrence() { + let dir = temp_dir("workbench-source-stale"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finnish capital", + "scope": "field", + "impact_action": "stale_translation" + }] + }); + + let preview = post_json(&server.url("/api/workbench/apply-preview"), request.clone()); + assert_eq!(preview["mode"], "preview"); + assert_eq!(preview["applied"], false); + assert_eq!(preview["validation"]["ok"], true); + assert!( + preview["affected_files"] + .as_array() + .unwrap() + .iter() + .any(|file| file["path"] == "deck.yaml") + ); + assert!( + preview["affected_files"] + .as_array() + .unwrap() + .iter() + .any(|file| file["path"] == "da.yaml") + ); + assert!( + preview["changed_entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry["mode"] == "stale_translation" + && entry["old_source"] == "Shared capital" + && entry["new_source"] == "Finnish capital" + && entry["target"] == "Fælles hovedstad" + && entry["context"] == "notes.note.finland" + }) + ); + assert!( + !fs::read_to_string(dir.join("deck.yaml")) + .unwrap() + .contains("Finnish capital") + ); + assert!( + !fs::read_to_string(dir.join("da.yaml")) + .unwrap() + .contains("stale_translations") + ); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["mode"], "write"); + assert_eq!(applied["applied"], true); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + assert!(deck.contains("field.capital: Finnish capital")); + assert!(deck.contains("field.capital: Shared capital")); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(overlay.contains("stale_translations:")); + assert!(overlay.contains("old_source: Shared capital")); + assert!(overlay.contains("new_source: Finnish capital")); + assert!(overlay.contains("target:")); + assert!(overlay.contains("context: notes.note.finland")); + + let pivot = get_json(&server.url("/api/workbench/note-pivot?language=da&target=standard")); + let finland_capital = pivot["notes"] + .as_array() + .unwrap() + .iter() + .find(|note| note["note_id"] == "note.finland") + .unwrap()["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(finland_capital["source"], "Finnish capital"); + assert_eq!(finland_capital["target"], "Fælles hovedstad"); + assert_eq!(finland_capital["status"], "stale_translation"); + let estonia_capital = pivot["notes"] + .as_array() + .unwrap() + .iter() + .find(|note| note["note_id"] == "note.estonia") + .unwrap()["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["field_id"] == "field.capital") + .unwrap() + .clone(); + assert_eq!(estonia_capital["source"], "Shared capital"); + assert_eq!(estonia_capital["status"], "direct_translation"); +} + +#[test] +fn workbench_source_edits_preserve_contextual_impacts_per_occurrence() { + let dir = temp_dir("workbench-source-contextual-stale"); + write_workbench_repeated_source_contextual_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Regional capital", + "scope": "all_occurrences", + "impact_action": "stale_translation" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let stale_entries = applied["changed_entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["mode"] == "stale_translation") + .collect::>(); + assert_eq!(stale_entries.len(), 2); + assert!(stale_entries.iter().any(|entry| { + entry["context"] == "notes.note.finland" && entry["target"] == "Finsk fælles" + })); + assert!(stale_entries.iter().any(|entry| { + entry["context"] == "notes.note.estonia" && entry["target"] == "Estisk fælles" + })); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("stale_translations:")); + assert!(overlay.contains("Finsk fælles")); + assert!(overlay.contains("Estisk fælles")); + assert!(!overlay.contains("contextual:")); + + let dir = temp_dir("workbench-source-contextual-migrate"); + write_workbench_repeated_source_contextual_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Regional capital", + "scope": "all_occurrences", + "impact_action": "migrate_key" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let migrated_entries = applied["changed_entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["mode"] == "migrate_key") + .collect::>(); + assert_eq!(migrated_entries.len(), 2); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Regional capital:")); + assert!(overlay.contains("Finsk fælles")); + assert!(overlay.contains("Estisk fælles")); + assert!(!overlay.contains("Shared capital:")); + assert!(!overlay.contains("stale_translations")); +} + +#[test] +fn workbench_source_edits_can_migrate_keys_change_all_and_preserve_includes() { + let dir = temp_dir("workbench-source-migrate"); + write_workbench_repeated_source_workspace(&dir); + fs::create_dir_all(dir.join("content")).unwrap(); + fs::write(dir.join("content/finland-capital.txt"), "Shared capital").unwrap(); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap().replacen( + "field.capital: Shared capital", + "field.capital: !include content/finland-capital.txt", + 1, + ); + fs::write(dir.join("deck.yaml"), deck).unwrap(); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [{ + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Migrated capital", + "scope": "all_occurrences", + "impact_action": "migrate_key" + }] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let deck = fs::read_to_string(dir.join("deck.yaml")).unwrap(); + assert!(deck.contains("field.capital: !include content/finland-capital.txt")); + assert!(deck.contains("field.capital: Migrated capital")); + assert_eq!( + fs::read_to_string(dir.join("content/finland-capital.txt")).unwrap(), + "Migrated capital" + ); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Migrated capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(!overlay.contains("Shared capital:")); + assert!(!overlay.contains("stale_translations")); +} + +#[test] +fn workbench_mixed_source_then_translation_apply_uses_new_source_state() { + let dir = temp_dir("workbench-source-mixed"); + write_workbench_repeated_source_workspace(&dir); + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + ]); + + let request = serde_json::json!({ + "language": "da", + "target": "standard", + "overlay": "base", + "edits": [ + { + "kind": "source", + "path": "notes.note.finland.fields.field.capital", + "source": "Shared capital", + "value": "Finnish capital", + "scope": "field", + "impact_action": "stale_translation" + }, + { + "kind": "translation", + "path": "notes.note.finland.fields.field.capital", + "source": "Finnish capital", + "value": "Finsk hovedstad", + "mode": "contextual" + } + ] + }); + + let applied = post_json(&server.url("/api/workbench/apply"), request); + assert_eq!(applied["validation"]["ok"], true); + let overlay = fs::read_to_string(dir.join("da.yaml")).unwrap(); + assert!(overlay.contains("Shared capital:")); + assert!(overlay.contains("Fælles hovedstad")); + assert!(overlay.contains("Finnish capital:")); + assert!(overlay.contains("Finsk hovedstad")); + assert!(!overlay.contains("stale_translations")); +} + +#[test] +fn workbench_serve_can_use_dev_asset_directory() { + let dir = temp_dir("workbench-dev-assets"); + write_workbench_workspace(&dir); + let assets = dir.join("assets"); + fs::create_dir_all(&assets).unwrap(); + fs::write( + assets.join("index.html"), + "

dev workbench shell
", + ) + .unwrap(); + + let server = spawn_workbench_server([ + "workbench", + "serve", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--port", + "0", + "--no-open", + "--dev-assets", + assets.to_str().unwrap(), + ]); + + let response = ureq::get(&server.url("/")).call().expect("GET / succeeds"); + assert_eq!(response.status(), 200); + assert!( + response + .into_string() + .unwrap() + .contains("dev workbench shell") + ); +} + +#[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("path: ../ultimate-geography")); + assert!(!lock_source.contains(&ug.canonicalize().unwrap().display().to_string())); + 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 verify_checks_rendered_html_and_css_content_with_escape_hatch() { + let dir = temp_dir("verify-content-validation"); + write_manifest_workspace(&dir); + fs::write( + dir.join("deck.yaml"), + SAMPLE_CANONICAL_YAML.replace( + " styling: |\n .card { font-family: sans-serif; }\n", + " styling: |\n .card { color: red;\n", + ), + ) + .unwrap(); + + let failed = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + ]); + assert!(!failed.status.success()); + let err = stderr(&failed); + assert!(err.contains("content validation failed for target patched-via-dependency")); + assert!(err.contains("note_types.note-type.country.styling:1: unmatched '{'")); + + let skipped = run([ + "verify", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--skip-content-validation", + ]); + assert!(skipped.status.success(), "stderr: {}", stderr(&skipped)); +} + +#[test] +fn translate_aliases_run_translation_reports() { + let dir = temp_dir("translate-aliases"); + write_translation_workspace(&dir); + + for command in ["translate", "translation"] { + let output = run([ + command, + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--json", + ]); + + assert!( + output.status.success(), + "{command} stderr: {}", + stderr(&output) + ); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert_eq!(json["reports"][0]["target"], "da-standard"); + } +} + +#[test] +fn unknown_translate_like_command_suggests_translations() { + let output = run(["translatons"]); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("unknown command")); + assert!(err.contains("Did you mean:")); + assert!(err.contains("brainbrew translations")); +} + +#[test] +fn translations_missing_manifest_lists_nearby_manifests() { + let dir = temp_dir("translations-missing-manifest"); + let workspace = dir.join("decks/sample"); + fs::create_dir_all(&workspace).unwrap(); + write_translation_workspace(&workspace); + + let output = run_in_dir(["translations", "--no-interactive"], &dir); + + assert!(!output.status.success()); + let err = stderr(&output); + assert!(err.contains("No Brain Brew manifest found at brainbrew.yaml")); + assert!(err.contains("Found possible manifests:")); + assert!(err.contains("decks/sample/brainbrew.yaml")); + assert!(err.contains("brainbrew translations --manifest decks/sample/brainbrew.yaml")); +} + +#[test] +fn translations_help_does_not_advertise_rejected_static_editor_flags() { + let output = run(["translations", "--help"]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(!out.contains("--web-editor")); + assert!(!out.contains("--apply-editor-edits")); + assert!(!out.contains("translator editor")); +} + +#[test] +fn translations_rejects_rejected_static_editor_flags() { + let dir = temp_dir("translations-rejected-static-editor-flags"); + write_translation_workspace(&dir); + + let web = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--web-editor", + "--out", + dir.join("editor.html").to_str().unwrap(), + ]); + assert!(!web.status.success()); + assert!(stderr(&web).contains("unexpected translations argument \"--web-editor\"")); + + let apply = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--apply-editor-edits", + dir.join("edits.json").to_str().unwrap(), + ]); + assert!(!apply.status.success()); + assert!(stderr(&apply).contains("unexpected translations argument \"--apply-editor-edits\"")); +} + +#[test] +fn translations_reports_when_selected_target_has_no_dictionary_overlays() { + let manifest = workspace_root().join("fixtures/ug-style/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "full-demo", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("No translation dictionary coverage reports matched")); + assert!(out.contains("translation.es")); + assert!(out.contains("translation-sv.yaml")); + assert!(out.contains("do not use a `translations:` dictionary")); +} + +#[test] +fn translations_context_view_shows_missing_note_field_card_context() { + let dir = temp_dir("translations-context-missing"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--context", + "--source", + "Sweden", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("Translation context for target da-standard language da")); + assert!(out.contains("missing_translation notes.note.sweden.fields.field.country")); + assert!(out.contains("note: note.sweden")); + assert!(out.contains("field: field.country (Country)")); + assert!(out.contains("note fields (source/en | target/da):")); + assert!(out.contains("* field.country (Country)")); + assert!(out.contains("cards: template.country-capital [question]")); + assert!(out.contains("source/en")); + assert!(out.contains("target/da")); + assert!(out.contains("Sweden")); +} + +#[test] +fn translations_context_view_shows_ug_repeated_country_info_occurrences() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "Island of Indonesia.", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("duplicate source group: 3 occurrence(s)")); + assert!(out.contains("note: note.bali")); + assert!(out.contains("note: note.java")); + assert!(out.contains("note: note.sumatra")); + assert!(out.contains("field: field.country-info (Country info)")); + assert!(out.contains("template.capital-country [answer]")); + assert!(out.contains("template.country-capital [question+answer]")); + assert!(out.contains("Island of Indonesia.")); + assert!(out.contains("Isla de Indonesia.")); +} + +#[test] +fn translations_context_view_wraps_long_ug_flag_similarity_context() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "blue background, red and white cross", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("note: note.faroe-islands")); + assert!(out.contains("field: field.flag-similarity (Flag similarity)")); + assert!(out.contains("template.flag-country [answer]")); + assert!(out.contains("Iceland (blue background,")); + assert!(out.contains("Islandia (fondo azul,")); + assert!(out.contains("Noruega")); +} + +#[test] +fn translations_context_view_json_exposes_reusable_context_model() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "es-standard", + "--overlay", + "overlay.translation.es", + "--context", + "--source", + "Autonomous community of Spain.", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let unit = &json["contexts"][0]["units"][0]; + assert_eq!(unit["status"], "direct_translation"); + assert_eq!(unit["note_id"], "note.canary-islands"); + assert_eq!(unit["field_id"], "field.country-info"); + assert_eq!(unit["field_name"], "Country info"); + assert_eq!(unit["note_fields"][0]["field_id"], "field.country"); + assert_eq!(unit["note_fields"][0]["source"], "Canary Islands"); + assert_eq!(unit["note_fields"][0]["translated"], "Canarias"); + assert_eq!(unit["source"], "Autonomous community of Spain."); + assert_eq!(unit["translated"], "Comunidad autónoma de España."); + let card_templates = unit["card_templates"].as_array().unwrap(); + assert!(card_templates.len() >= 4); + let country_capital = card_templates + .iter() + .find(|card| card["template_id"] == "template.country-capital") + .expect("country-capital card context is present"); + assert!( + country_capital["question_format"] + .as_str() + .unwrap() + .contains("{{Country info}}") + ); + assert!( + country_capital["answer_format"] + .as_str() + .unwrap() + .contains("{{Capital}}") + ); +} + +#[test] +fn translations_context_view_json_shows_structured_message_components() { + let dir = temp_dir("translations-context-structured-message"); + write_structured_message_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "nb-standard", + "--context", + "--source", + "blue background", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let unit = &json["contexts"][0]["units"][0]; + assert_eq!( + unit["path"], + "notes.note.finland.fields.field.flag-similarity.message.2" + ); + assert_eq!(unit["status"], "contextual_translation"); + assert_eq!( + unit["message"]["source"], + "Iceland (blue background with a white cross), Norway (red background with a blue cross)" + ); + assert_eq!( + unit["message"]["translated"], + "Island (blå bakgrunn med hvitt kors), Norge (rød bakgrunn med blått kors)" + ); + let components = unit["message"]["components"].as_array().unwrap(); + assert_eq!(components[0]["kind"], "field_ref"); + assert_eq!( + components[0]["reference"], + "notes.note.iceland.fields.field.country" + ); + assert_eq!(components[0]["source"], "Iceland"); + assert_eq!(components[0]["translated"], "Island"); + assert_eq!(components[2]["kind"], "text"); + assert_eq!( + components[2]["source"], + "blue background with a white cross" + ); + assert_eq!(components[2]["translated"], "blå bakgrunn med hvitt kors"); +} + +#[test] +fn translations_context_apply_uses_existing_translation_apply_path() { + let dir = temp_dir("translations-context-apply"); + write_translation_workspace(&dir); + let overlay_path = dir.join("da.yaml"); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--target", + "da-standard", + "--context", + "--source", + "Sweden", + "--apply", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let updated = fs::read_to_string(overlay_path).unwrap(); + assert!(updated.contains(" Sweden: Sweden\n")); +} + +#[test] +fn translations_context_view_can_filter_duplicate_missing_and_language() { + let dir = temp_dir("translations-context-filters"); + write_translation_workspace(&dir); + let deck_path = dir.join("deck.yaml"); + let deck = fs::read_to_string(&deck_path) + .unwrap() + .replace("field.country: Finland", "field.country: Sweden"); + fs::write(&deck_path, deck).unwrap(); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--language", + "da", + "--context", + "--duplicates", + "--status", + "missing", + "--field", + "field.country", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("missing_translation notes.note.sweden.fields.field.country")); + assert!(out.contains("duplicate source group: 2 occurrence(s)")); + assert!(!out.contains("notes.note.finland.fields.field.capital")); +} + +#[test] +fn translations_summary_exports_compact_counts_by_language() { + let dir = temp_dir("translations-summary"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + assert!(json.get("reports").is_none()); + let summaries = json["summaries"].as_array().unwrap(); + assert_eq!(summaries.len(), 1); + let row = &summaries[0]; + assert_eq!(row["language"], "da"); + assert_eq!( + row["targets"], + serde_json::json!(["da-release", "da-standard"]) + ); + assert_eq!(row["overlay"], "overlay.translation.da"); + assert_eq!(row["file"], "da.yaml"); + assert_eq!(row["direct_translation"], 1); + assert_eq!(row["contextual_translation"], 1); + assert_eq!(row["no_change"], 0); + assert_eq!(row["target_adaptation"], 1); + assert_eq!(row["variable_translation"], 0); + assert_eq!(row["adapter_id_translation"], 0); + assert_eq!(row["untranslated_fallback"], 2); + assert_eq!(row["missing_text_translation"], 2); + assert_eq!(row["hidden_untranslated_fallback"], 0); + assert_eq!(row["stale_invalid"], 1); +} + +#[test] +fn translations_summary_human_output_uses_aligned_space_columns() { + let dir = temp_dir("translations-summary-human"); + write_translation_workspace(&dir); + + let output = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!( + !out.contains('\t'), + "summary should not rely on terminal tab stops:\n{out}" + ); + assert!( + out.contains("lang tgt direct"), + "header should use compact padded columns:\n{out}" + ); + assert!( + !out.contains("overlay.translation.da"), + "default summary should keep wide overlay/file columns out of the table:\n{out}" + ); + for line in out.lines().skip(1) { + assert!( + line.chars().count() <= 90, + "default summary line should stay compact, got {} chars:\n{line}\n\n{out}", + line.chars().count() + ); + } + let full = run([ + "translations", + "--manifest", + dir.join("brainbrew.yaml").to_str().unwrap(), + "--all-targets", + "--summary", + "--full", + ]); + assert!(full.status.success(), "stderr: {}", stderr(&full)); + assert!(stdout(&full).contains("overlay.translation.da")); + assert!(stdout(&full).contains("da.yaml")); +} + +#[test] +fn translations_default_report_focuses_on_translatable_note_text() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--target", + "da-standard", + "--overlay", + "overlay.translation.da", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let out = stdout(&output); + assert!(out.contains("missing text translations: 43")); + assert!(out.contains("intentionally unchanged text: 329")); + assert!(out.contains("hidden structural/media/tag fallbacks:")); + assert!(out.contains("hint: use --full")); + assert!(!out.contains("deck.description source=")); + assert!(!out.contains("notes.note.abkhazia.fields.field.flag")); + assert!(!out.contains("notes.note.abkhazia.fields.field.capital")); + assert!(out.contains("notes.note.andorra.fields.field.flag-similarity.message.format")); +} + +#[test] +fn ultimate_geography_language_overlays_have_no_actionable_missing_text() { + let manifest = workspace_root().join("fixtures/ultimate-geography/brainbrew.yaml"); + let output = run([ + "translations", + "--manifest", + manifest.to_str().unwrap(), + "--all-targets", + "--json", + ]); + + assert!(output.status.success(), "stderr: {}", stderr(&output)); + let json: serde_json::Value = serde_json::from_str(&stdout(&output)).unwrap(); + let mut missing = Vec::new(); + for report in json["reports"].as_array().unwrap() { + let file = report["file"].as_str().unwrap(); + if !file.starts_with("overlays/languages/") { + continue; + } + let target = report["target"].as_str().unwrap(); + let overlay = report["overlay"].as_str().unwrap(); + for entry in report["entries"].as_array().unwrap() { + if entry["category"] != "untranslated_fallback" { + continue; + } + let path = entry["path"].as_str().unwrap(); + let source = entry["source"].as_str().unwrap(); + let actionable_note_field = path.starts_with("notes.") + && path.contains(".fields.") + && !path.ends_with(".fields.field.flag") + && !path.ends_with(".fields.field.map") + && !path.ends_with(".message.format") + && !source.trim_start().starts_with("