diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48dd36166..7abf799fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,6 +286,20 @@ jobs: # quinn endpoint. We only use reqwest as an HTTP/1.1+2 client and never # accept inbound QUIC connections, so the vulnerable reassembly path is # unreachable. Remove once reqwest pulls quinn-proto >= 0.11.15. + # + # quick-xml < 0.41 (two transitive copies: 0.37.5 via object_store, + # 0.38.4 via octofhir-ucum). Both RUSTSEC-2026-0194 (O(N^2) duplicate- + # attribute check) and RUSTSEC-2026-0195 (unbounded namespace-decl + # allocation) are DoS-via-malicious-XML fixed in 0.41.0. Neither copy + # parses attacker-controlled input: + # - octofhir-ucum uses quick-xml as a build-dependency only (parsing + # the bundled UCUM essence XML at build time); it is never linked + # into the shipped binary. + # - object_store parses XML responses from configured S3/Azure/GCS + # endpoints (trusted cloud infrastructure), not FHIR client input. + # helios-serde's own quick-xml (client FHIR XML) is already on 0.41 and + # sits behind the non-default `xml` feature. Remove these once + # object_store and octofhir-ucum ship builds on quick-xml >= 0.41. run: >- cargo audit --ignore RUSTSEC-2026-0118 @@ -296,6 +310,8 @@ jobs: --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 --ignore RUSTSEC-2026-0185 + --ignore RUSTSEC-2026-0194 + --ignore RUSTSEC-2026-0195 coverage: name: Code Coverage diff --git a/Cargo.lock b/Cargo.lock index 8922569fd..499fac454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,6 +410,50 @@ dependencies = [ "regex-syntax 0.8.10", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -1160,6 +1204,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -3311,7 +3364,7 @@ dependencies = [ "helios-fhir", "helios-fhir-macro", "helios-serde-support", - "quick-xml 0.38.4", + "quick-xml 0.41.0", "reqwest", "rust_decimal", "rust_decimal_macros", @@ -3396,6 +3449,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "helios-web" +version = "0.2.1" +dependencies = [ + "askama", + "axum", + "serde", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -3547,6 +3613,12 @@ dependencies = [ "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" @@ -3559,6 +3631,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.3.0" @@ -4126,7 +4207,7 @@ dependencies = [ "httpdate", "idna", "mime", - "nom", + "nom 8.0.0", "percent-encoding", "quoted_printable", "rustls 0.23.38", @@ -4396,6 +4477,22 @@ 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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4568,6 +4665,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -4744,7 +4851,7 @@ dependencies = [ "fuzzy-matcher", "lazy_static", "memchr", - "nom", + "nom 8.0.0", "once_cell", "phf 0.11.3", "quick-xml 0.38.4", @@ -5504,6 +5611,15 @@ name = "quick-xml" version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", @@ -7235,7 +7351,12 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -7421,6 +7542,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index cbbe49393..3c520a7f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ default-members = [ "crates/hfs", "crates/persistence", "crates/rest", + "crates/web", "crates/sof", "crates/cds-hooks", "crates/hts", diff --git a/crates/hts/src/backends/postgres/code_system.rs b/crates/hts/src/backends/postgres/code_system.rs index 996280ff3..41dd81d7f 100644 --- a/crates/hts/src/backends/postgres/code_system.rs +++ b/crates/hts/src/backends/postgres/code_system.rs @@ -564,16 +564,16 @@ impl CodeSystemOperations for PostgresTerminologyBackend { } // Inactive concept: emit the canonical INACTIVE_CONCEPT_FOUND warning. - // The operations layer also appends a specific-status companion (e.g. - // "...status of retired...") via `lookup_concept_status`. + // When the concept's status is more specific than `inactive` (e.g. + // `retired`), the operations layer rewrites this issue's text in place + // to the merged form ("...status of retired and inactive...") via + // `lookup_concept_status`. // // No `location` field — the IG `validation/validate-contained-good` // fixture (inline-VS path) pins this warning WITHOUT a location. - // The operations layer then clones the template for the - // specific-status companion, so the location-less template flows - // through both issues. URL-based VS-validate-code paths (e.g. - // `inactive-2a-validate`) emit their own INACTIVE_CONCEPT_FOUND - // WITH location via finish_validate_code_response, separately. + // URL-based VS-validate-code paths (e.g. `inactive-2a-validate`) emit + // their own INACTIVE_CONCEPT_FOUND WITH location via + // finish_validate_code_response, separately. // Mirrors SQLite's CS-side behaviour (which doesn't emit at all). if is_inactive { issues.push(crate::types::ValidationIssue { diff --git a/crates/hts/src/backends/postgres/schema.rs b/crates/hts/src/backends/postgres/schema.rs index c1e51cfbf..8635d8898 100644 --- a/crates/hts/src/backends/postgres/schema.rs +++ b/crates/hts/src/backends/postgres/schema.rs @@ -284,8 +284,29 @@ pub async fn build_concept_closure_pg( ) -> Result<(), tokio_postgres::Error> { use std::collections::{HashMap, VecDeque}; - // Load all concept codes for this system. - let concepts: Vec = client + // Do the whole build in ONE transaction that first pins the parent + // `code_systems` row with `FOR SHARE`. A concurrent `DELETE FROM + // code_systems` (e.g. `delete_normalized` removing a CodeSystem, which + // cascades to concepts/hierarchy/closure) then blocks until we commit and + // can't drop the row mid-build — which would otherwise make the closure + // INSERTs below violate `concept_closure_system_id_fkey`. If the row is + // already gone, another writer deleted the system first, so there is + // nothing to build. + let tx = client.transaction().await?; + if tx + .query_opt( + "SELECT 1 FROM code_systems WHERE id = $1 FOR SHARE", + &[&system_id], + ) + .await? + .is_none() + { + tx.commit().await?; + return Ok(()); + } + + // Load all concept codes for this system (within the locked snapshot). + let concepts: Vec = tx .query( "SELECT code FROM concepts WHERE system_id = $1", &[&system_id], @@ -296,12 +317,12 @@ pub async fn build_concept_closure_pg( .collect(); if concepts.is_empty() { - client - .execute( - "DELETE FROM concept_closure WHERE system_id = $1", - &[&system_id], - ) - .await?; + tx.execute( + "DELETE FROM concept_closure WHERE system_id = $1", + &[&system_id], + ) + .await?; + tx.commit().await?; return Ok(()); } @@ -315,7 +336,7 @@ pub async fn build_concept_closure_pg( // Build per-node children lists (index-based). let mut children: Vec> = vec![Vec::new(); concepts.len()]; - let rows = client + let rows = tx .query( "SELECT parent_code, child_code FROM concept_hierarchy WHERE system_id = $1", &[&system_id], @@ -336,7 +357,6 @@ pub async fn build_concept_closure_pg( let mut anc_batch: Vec<&str> = Vec::with_capacity(BATCH); let mut des_batch: Vec<&str> = Vec::with_capacity(BATCH); - let tx = client.transaction().await?; tx.execute( "DELETE FROM concept_closure WHERE system_id = $1", &[&system_id], diff --git a/crates/hts/src/operations/expand.rs b/crates/hts/src/operations/expand.rs index 4c47f83e8..55036f9e5 100644 --- a/crates/hts/src/operations/expand.rs +++ b/crates/hts/src/operations/expand.rs @@ -472,6 +472,17 @@ fn populate_properties<'a, B: TerminologyBackend>( for c in contains.iter_mut() { if let Some(list) = map.remove(&(c.system.clone(), c.code.clone())) { let cs_types = prop_types_by_system.get(&c.system); + // Dedupe identical (property, value) pairs. `concept_property_values` + // joins by CodeSystem URL only, so when the same URL is stored + // in more than one version (e.g. v3-ActReason from UTG plus the + // IG's own cs-act-reason.json fixture), a concept's `status` + // property is returned once per version. The IG + // `tho/expand-vs-act-exclusion` fixture expects a single + // `status` entry — an exact-pair dedupe collapses the versions + // while still allowing genuinely-distinct same-code properties + // (e.g. multiple `parent` values) through. + let mut seen: std::collections::HashSet<(String, String)> = + std::collections::HashSet::new(); c.properties = list .into_iter() .filter(|(code, value)| { @@ -485,6 +496,7 @@ fn populate_properties<'a, B: TerminologyBackend>( // Only emit when the status is non-active. !(code == "status" && value == "active") }) + .filter(|pair| seen.insert(pair.clone())) .map(|(code, value)| { // Pick the FHIR `value[x]` shape from the property // code: @@ -628,6 +640,50 @@ fn pure_full_system_includes(value_set: Option<&Value>) -> Option) -> Option)>> { + let compose = value_set?.get("compose")?; + if compose + .get("exclude") + .and_then(|e| e.as_array()) + .is_some_and(|a| !a.is_empty()) + { + return None; + } + let includes = compose.get("include")?.as_array()?; + let mut out = Vec::new(); + for inc in includes { + let Some(system) = inc.get("system").and_then(|v| v.as_str()) else { + continue; + }; + let Some(filters) = inc.get("filter").and_then(|f| f.as_array()) else { + continue; + }; + let is_subsumption = filters.iter().any(|f| { + let op = f.get("op").and_then(|v| v.as_str()).unwrap_or(""); + let prop = f.get("property").and_then(|v| v.as_str()).unwrap_or(""); + matches!(op, "is-a" | "descendent-of") && matches!(prop, "concept" | "code") + }); + if is_subsumption { + let version = inc + .get("version") + .and_then(|v| v.as_str()) + .map(str::to_string); + out.push((system.to_string(), version)); + } + } + if out.is_empty() { None } else { Some(out) } +} + /// The HL7 `hl7TermMaintInfra` system + code identifying a designation as /// the "preferred for language" entry. Used when the displayLanguage swap /// rotates the CodeSystem's original-language display into the designation @@ -1559,6 +1615,42 @@ async fn process_expand_inner( hierarchical = Some(true); } } + // A compose that designates a hierarchy subtree via an is-a / + // descendent-of filter also defaults to tree mode — and unlike the + // pure-full-system case this applies to URL-resolved ValueSets too + // (the IG `search/search-filter-yes` fixture is URL-resolved). Inspect + // the inline compose when present, else fetch the referenced VS. + if hierarchical.is_none() { + let ctx = TenantContext::system(); + let resolved_vs: Option = if value_set.is_some() { + value_set.clone() + } else if let Some(u) = url.as_ref() { + ValueSetOperations::search( + state.backend(), + &ctx, + crate::types::ResourceSearchQuery { + url: Some(u.clone()), + version: pipe_version.clone(), + count: Some(1), + ..Default::default() + }, + ) + .await + .ok() + .and_then(|mut v| v.pop()) + } else { + None + }; + // No `code_system_is_hierarchical` gate here: an is-a / + // descendent-of filter already implies a hierarchy intent, and over + // a flat CS it resolves to just the filter's own code, so nesting is + // a harmless no-op. Gating on hierarchy detection would instead + // silently drop nesting when that probe under-reports (as it does + // for the `search` CS). + if subsumption_filter_includes(resolved_vs.as_ref()).is_some() { + hierarchical = Some(true); + } + } } // ── Resolve supplements (request `useSupplement` params) ──────────────── diff --git a/crates/hts/src/operations/validate_code.rs b/crates/hts/src/operations/validate_code.rs index 4c09af7bd..1bebf14e4 100644 --- a/crates/hts/src/operations/validate_code.rs +++ b/crates/hts/src/operations/validate_code.rs @@ -1169,12 +1169,14 @@ async fn build_validate_response_async( ) -> Value { // For inactive concepts whose underlying status is more specific than // "inactive" (e.g. `retired`, `deprecated`, `withdrawn`), the IG - // `inactive/validate-inactive-3*` fixtures expect TWO warning issues: - // one with text "...has a status of inactive..." (the canonical wording - // already emitted by the backend) AND a second with text using the - // specific status code (e.g. "...has a status of retired..."). Detect - // that case here by looking up the concept's `status` property and - // appending a second issue when needed. + // `inactive/validate-inactive-3*` fixtures expect a SINGLE merged warning + // that names both the specific status and the generic inactive flag: + // "...has a status of retired and inactive and its use should be reviewed". + // (Upstream HL7/fhir-tx-ecosystem-ig@030182b7 replaced the earlier + // two-issue form — a generic "...status of inactive..." plus a separate + // "...status of retired..." — with this merged wording.) Detect the case + // here by looking up the concept's `status` property and rewriting the + // backend's generic "...status of inactive..." issue in place. if resp.inactive == Some(true) { let inferred_system = resp.system.clone(); let lookup_system: Option<&str> = system.or(inferred_system.as_deref()); @@ -1187,30 +1189,18 @@ async fn build_validate_response_async( if resp.concept_status.is_none() { resp.concept_status = Some(specific_status.clone()); } - let already_has_specific = resp.issues.iter().any(|i| { + let merged_text = format!( + "The concept '{cd}' has a status of {specific_status} and inactive and its use should be reviewed" + ); + // Rewrite the generic INACTIVE_CONCEPT_FOUND issue's text to the + // merged form. Guard on the generic wording so re-entry (the + // merged text no longer contains "has a status of inactive") is + // idempotent and we don't rewrite an already-merged issue. + if let Some(issue) = resp.issues.iter_mut().find(|i| { i.message_id.as_deref() == Some("INACTIVE_CONCEPT_FOUND") - && i.text - .contains(&format!("has a status of {specific_status} and")) - }); - if !already_has_specific { - let inactive_issue = resp.issues.iter().find(|i| { - i.message_id.as_deref() == Some("INACTIVE_CONCEPT_FOUND") - && i.text.contains("has a status of inactive") - }); - if let Some(template) = inactive_issue.cloned() { - let new_text = format!( - "The concept '{cd}' has a status of {specific_status} and its use should be reviewed" - ); - resp.issues.push(ValidationIssue { - severity: template.severity, - fhir_code: template.fhir_code, - tx_code: template.tx_code, - text: new_text, - expression: template.expression, - location: template.location, - message_id: template.message_id, - }); - } + && i.text.contains("has a status of inactive") + }) { + issue.text = merged_text; } } } @@ -2429,16 +2419,17 @@ async fn process_validate_code_inner( .find(|p| p.get("name").and_then(|v| v.as_str()) == Some("codeableConcept")) .and_then(|p| p.get("valueCodeableConcept")) .cloned(); - // The IG fixtures expect the LAST matching coding to win (when several + // The IG fixtures expect the FIRST matching coding to win (when several // codings in a CodeableConcept all validate, the response echoes the - // last one). Iterate in reverse so the earliest "yes" we find is the - // last entry in the input. + // first one in input order). Iterate forward and return on the first + // "yes". (Upstream HL7/fhir-tx-ecosystem-ig@b5658de8 switched this from + // last-wins to first-wins.) let cc_req_version = find_str_param(¶ms, "version"); let cs_lenient = params .iter() .find(|p| p.get("name").and_then(|v| v.as_str()) == Some("lenient-display-validation")) .and_then(|p| p.get("valueBoolean").and_then(|v| v.as_bool())); - for (system, code) in codings.into_iter().rev() { + for (system, code) in codings.into_iter() { let req = ValidateCodeRequest { url: None, value_set_version: None, @@ -4993,6 +4984,12 @@ async fn process_vs_validate_code_inner( .as_ref() .and_then(|cc| cc.get("coding").and_then(|v| v.as_array())) .map(|arr| { + // Keep the FIRST occurrence per (system, code) so the display + // matches the first-wins coding selection below. Two codings + // can share a (system, code) with different displays (e.g. the + // IG `overload/validate-good2a` fixture: code2 with "Display #2" + // then "Display 2") — last-wins `.collect()` would echo the + // wrong coding's display and resolve the wrong CS version. arr.iter() .filter_map(|c| { let s = c.get("system").and_then(|v| v.as_str())?.to_string(); @@ -5000,13 +4997,17 @@ async fn process_vs_validate_code_inner( let d = c.get("display").and_then(|v| v.as_str())?.to_string(); Some(((s, cd), d)) }) - .collect() + .fold(std::collections::HashMap::new(), |mut m, (k, v)| { + m.entry(k).or_insert(v); + m + }) }) .unwrap_or_default(); let coding_versions: std::collections::HashMap<(String, String), String> = cc_value .as_ref() .and_then(|cc| cc.get("coding").and_then(|v| v.as_array())) .map(|arr| { + // First-occurrence-wins, as for `coding_displays` above. arr.iter() .filter_map(|c| { let s = c.get("system").and_then(|v| v.as_str())?.to_string(); @@ -5014,13 +5015,17 @@ async fn process_vs_validate_code_inner( let v = c.get("version").and_then(|v| v.as_str())?.to_string(); Some(((s, cd), v)) }) - .collect() + .fold(std::collections::HashMap::new(), |mut m, (k, v)| { + m.entry(k).or_insert(v); + m + }) }) .unwrap_or_default(); - // The IG fixtures expect the LAST matching coding to win (when several + // The IG fixtures expect the FIRST matching coding to win (when several // codings in a CodeableConcept all validate, the response echoes the - // last one). Iterate in reverse so the earliest "yes" we find is the - // last entry in the input. + // first one in input order). Iterate forward and return on the first + // "yes". (Upstream HL7/fhir-tx-ecosystem-ig@b5658de8 switched this from + // last-wins to first-wins.) // // Also track per-coding `unknown-code` failures (codes that don't // exist in their CS) so we can surface them in the response even when @@ -5031,15 +5036,17 @@ async fn process_vs_validate_code_inner( // bad coding's `Unknown_Code_in_Version` error + // `None_of_the_provided_codes_are_in_the_value_set_one` info. let cc_req_version = find_str_param(¶ms, "version").or(system_version.clone()); - // Map (system, code) → original CC index (preserved through reverse - // iteration) so per-coding failure issues reference - // `CodeableConcept.coding[N]` with the input order's N. + // Map (system, code) → original CC index so per-coding failure issues + // reference `CodeableConcept.coding[N]` with the input order's N. + // First-occurrence-wins to match the first-wins coding selection. let coding_index: std::collections::HashMap<(String, String), usize> = codings .iter() .enumerate() - .map(|(i, (s, c))| ((s.clone(), c.clone()), i)) - .collect(); - for (system, code) in codings.clone().into_iter().rev() { + .fold(std::collections::HashMap::new(), |mut m, (i, (s, c))| { + m.entry((s.clone(), c.clone())).or_insert(i); + m + }); + for (system, code) in codings.clone().into_iter() { // Prefer the per-coding version (embedded in the CC) over the // top-level `version` parameter so that version-mismatch detection // fires correctly for each coding. @@ -5236,12 +5243,11 @@ async fn process_vs_validate_code_inner( let coding_display = coding_displays .get(&(system.clone(), code.clone())) .cloned(); - // ── Walk remaining codings (those we haven't reached yet in - // reverse iteration, i.e. earlier in input order) and check - // for hard `unknown-code` failures. If any exist, the IG - // `permutations/simple-bad-cc2-*` fixtures expect us to echo - // THIS coding's metadata but mark result=false and surface - // the bad coding's issues. + // ── Walk the other codings (every entry except this winning + // one) and check for hard `unknown-code` failures. If any + // exist, the IG `permutations/simple-bad-cc2-*` fixtures expect + // us to echo THIS coding's metadata but mark result=false and + // surface the bad coding's issues. let success_idx = coding_index .get(&(system.clone(), code.clone())) .copied() @@ -5423,7 +5429,7 @@ async fn process_vs_validate_code_inner( // `codeableconcept-vnn-vs1wb` family expects the // `UNKNOWN_CODESYSTEM_VERSION` issue + `x-caused-by-unknown-system` // parameter. Limited to single-coding to avoid short-circuiting - // the reverse loop before a later (good) coding gets visited + // the loop before a later (good) coding gets visited // in multi-coding CCs. let has_unknown_cs_version = codings.len() == 1 && resp diff --git a/crates/hts/tests/ecl_expand.rs b/crates/hts/tests/ecl_expand.rs index 63b9e2209..aae8ab3a3 100644 --- a/crates/hts/tests/ecl_expand.rs +++ b/crates/hts/tests/ecl_expand.rs @@ -21,16 +21,26 @@ use common::{TestApp, bundles}; // ── helpers ──────────────────────────────────────────────────────────────────── /// POST `$expand` for `url`, returning the sorted list of codes in the expansion. +/// +/// Collects codes recursively through nested `contains[]`: these tests assert +/// which concepts an ECL / is-a filter selects (the concept *set*), not the +/// tree shape. An `is-a`/`descendent-of` filter over a hierarchical CS now +/// expands hierarchically (children nested under their in-result parent), so a +/// top-level-only walk would miss the descendants. async fn expand_codes(app: &TestApp, url: &str) -> Vec { let req = TestApp::params(&[("url", "valueUri", url)]); let (status, body) = app.post_fhir("/ValueSet/$expand", req).await; assert_eq!(status, StatusCode::OK, "expand failed for {url}: {body}"); - let mut codes: Vec = body["expansion"]["contains"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|e| e["code"].as_str().map(String::from)) - .collect(); + fn collect(items: Option<&Vec>, out: &mut Vec) { + for e in items.into_iter().flatten() { + if let Some(code) = e["code"].as_str() { + out.push(code.to_string()); + } + collect(e["contains"].as_array(), out); + } + } + let mut codes: Vec = Vec::new(); + collect(body["expansion"]["contains"].as_array(), &mut codes); codes.sort(); codes } diff --git a/crates/serde/Cargo.toml b/crates/serde/Cargo.toml index d293edb45..a43b81da7 100644 --- a/crates/serde/Cargo.toml +++ b/crates/serde/Cargo.toml @@ -23,7 +23,7 @@ serde = { workspace = true } serde_json = { workspace = true } helios-fhir = { path = "../fhir", version = "0.2.1", default-features = false } helios-serde-support = { path = "../serde-support", version = "0.2.1" } -quick-xml = { version = "0.38", features = ["serialize"], optional = true } +quick-xml = { version = "0.41", features = ["serialize"], optional = true } rust_decimal = { version = "1.0", features = ["serde-with-arbitrary-precision"] } [package.metadata.docs.rs] diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 000000000..1ee2b0bfb --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "helios-web" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage = "https://github.com/HeliosSoftware/hfs/tree/main/crates/web" +rust-version.workspace = true +description = "Server-rendered, HTMX-first web UI for the Helios FHIR Server" +keywords = ["helios-software", "fhir", "htmx", "web", "ui"] +categories = ["web-programming"] + +[dependencies] +# Web framework — same major versions as helios-rest so the router can be +# mounted as a sub-router of the `hfs` binary without version skew. +axum = { version = "0.8", features = ["query"] } +tower-http = { version = "0.6", features = ["fs", "trace"] } + +# Compile-time, type-checked templates (Jinja2-like). Auto-escapes HTML by +# default — see README for the templating trade-off rationale. +askama = "0.12" + +# Query-string deserialization for the `Query` extractor. +serde = { workspace = true } + +# Logging +tracing = "0.1" + +[dev-dependencies] +# The `serve` example needs a runtime + a listener. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/web/README.md b/crates/web/README.md new file mode 100644 index 000000000..32fde0d2d --- /dev/null +++ b/crates/web/README.md @@ -0,0 +1,130 @@ +# helios-web — HTMX-first web UI (foundation) + +> Status: **proof of concept / foundation.** This crate establishes *where UI +> code goes and why*. It is not a finished application. +> +> Tracking issue: [#186](https://github.com/HeliosSoftware/hfs/issues/186) + +This document is the "rules of the road" for building browser UI in HFS. Read it +before adding a screen. + +--- + +## 1. Approach — and why + +HFS is a Rust/Axum server that already owns all the FHIR logic (persistence, +terminology, SQL-on-FHIR, auth). We render UI **on the server** and use +[HTMX](https://htmx.org/) for partial page updates, instead of building a +separate single-page application (SPA). + +**Why server-rendered hypermedia over an SPA:** + +- **One source of truth.** An SPA duplicates view logic and state on the client + and forces us to expose a second, browser-shaped JSON API alongside the FHIR + API. Hypermedia keeps rendering and state in Rust, next to the data. +- **Less JavaScript to own.** HTMX adds `hx-*` attributes to plain HTML; there is + no build step, bundler, or client framework to maintain. +- **Fits a healthcare deployment.** Assets are vendored and served locally — no + runtime CDN dependency, which matters for air-gapped / regulated installs. + +The canonical references for this style: + +- **HTMX docs** — +- **"Hypermedia Systems"** (Gross, Stepinski, Akşimşek), the book-length rationale + — +- **HATEOAS** — +- **Locality of Behaviour (LoB)** — +- **HTMX examples** (active search, click-to-edit, inline validation, infinite + scroll — the fragment patterns we standardize on) — + +## 2. Templating engine + +The POC uses **[Askama](https://docs.rs/askama)**: Jinja2-like templates checked +**at compile time**, so a bad field reference or missing template is a build +error, not a production 500. Askama **auto-escapes HTML by default**, which is +our first line of defense against XSS (see +[OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)). + +Alternatives considered (revisit if the POC assumptions break down): + +| Engine | Model | Trade-off | +|--------|-------|-----------| +| **Askama** (chosen) | Compile-time, typed | Safest; templates are part of the build; least runtime flexibility | +| [Maud](https://maud.lambda.xyz/) | Macro, Rust-in-HTML | Type-safe, no template files; markup lives in `.rs`, which some find harder to scan | +| [Minijinja](https://docs.rs/minijinja) | Runtime | Hot-reload, dynamic templates; errors surface at runtime | + +**This is the one decision most worth revisiting with the team** before the UI +grows large. It is called out in the issue for that reason. + +## 3. File placement — rules of the road + +### Where things go + +``` +crates/web/ +├── src/ Axum handlers + router. THIN: parse → call HFS crates → render. +├── templates/ +│ ├── layouts/ Shared page shells (base.html). +│ ├── pages/ Full HTML documents (one per route/navigation target). +│ └── partials/ HTMX-swappable fragments (NOT full documents). +├── assets/ Vendored, version-pinned static files (htmx.min.js, app.css). +└── examples/ Standalone runner(s) for local development. +``` + +- Handlers branch on the **`HX-Request`** header: return a **fragment** to HTMX, + the **full page** on a hard navigation / bookmarked URL. This keeps the UI + working (degraded) without JavaScript — progressive enhancement. +- Data comes from the existing crates (`helios-rest`, `helios-persistence`, + `helios-hts`, …). `helios-web` orchestrates and renders; it does not own data + logic. + +### Where things must NOT go + +- ❌ **No HTML in Rust source** (`format!`, string literals). Markup lives in + `templates/`. +- ❌ **No business / FHIR logic in templates.** Templates render data; they don't + compute it. Don't re-implement persistence or terminology logic here. +- ❌ **No browser-facing JSON API for the UI.** HTMX consumes HTML fragments. The + FHIR JSON API stays for API clients, not for the browser. +- ❌ **No inline ` + + +
+ Helios FHIR Server + web UI · proof of concept +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/crates/web/templates/pages/index.html b/crates/web/templates/pages/index.html new file mode 100644 index 000000000..17f185f2d --- /dev/null +++ b/crates/web/templates/pages/index.html @@ -0,0 +1,23 @@ +{% extends "layouts/base.html" %} + +{% block title %}FHIR resource search — HFS{% endblock %} + +{% block content %} +

FHIR resource search

+

+ Type to search. Results load without a full-page reload — the input issues an + HX-Request and the server returns just the results fragment. +

+ + + +
+ {% include "partials/search_results.html" %} +
+{% endblock %} diff --git a/crates/web/templates/partials/search_results.html b/crates/web/templates/partials/search_results.html new file mode 100644 index 000000000..ae808b5d5 --- /dev/null +++ b/crates/web/templates/partials/search_results.html @@ -0,0 +1,16 @@ +{# HTMX-swappable fragment. Must NOT be a full HTML document — it is swapped + into #results on the page. All values are auto-escaped by Askama. #} +{% if hits.is_empty() %} + {% if query.is_empty() %} +

Start typing to search.

+ {% else %} +

No matches for “{{ query }}”.

+ {% endif %} +{% else %} +

{{ hits.len() }} match(es) for “{{ query }}”.

+
    + {% for hit in hits %} +
  • {{ hit.name }}
  • + {% endfor %} +
+{% endif %} diff --git a/docs/multi-language.md b/docs/multi-language.md new file mode 100644 index 000000000..ed8770980 --- /dev/null +++ b/docs/multi-language.md @@ -0,0 +1,310 @@ +# Multi-language support — approach, guidelines, and rules of the road + +**Status:** Foundation / discussion (living document) +**Branch:** `feat/i18n-foundation` (off `main`) +**Owner:** Angela +**Relates to:** #186 (HTMX-first web UI foundation), `feat/user-ui-settings`, +`feat/smart-ui-auth` + +This document describes **how Helios FHIR Server (HFS) supports multiple +languages, from the front end to the back end.** The initial target languages +are **English (source), Spanish, and German**; the architecture is designed so +that adding a fourth (French, Portuguese, …) is a translation task, not an +engineering project. + +It is deliberately opinionated: the point of a "rules of the road" document is +to make the *first* localization PR set the *right* precedent rather than an +accidental one. Where a concrete tool is named, it is a recommendation to be +ratified — the reasoning matters more than the pick. + +--- + +## 1. What "multi-language" means here + +Localization in HFS is **not one thing**. A user request touches several +independently-translated layers, and conflating them is the most common way i18n +efforts go wrong. We separate them explicitly: + +| # | Layer | Example | Where it lives | Status | +|---|-------|---------|----------------|--------| +| 1 | **UI chrome / static strings** | "Dashboard", "Search", "Sign out" | `locales/*.ftl` message catalogs | **new (this issue)** | +| 2 | **Locale negotiation** | pick `de` for this request | middleware: header + user setting + override | **new (this issue)** | +| 3 | **API / error messages** | `OperationOutcome.text` / `.issue.details` | `helios-rest` responses, catalog-backed | scaffolded here, wired incrementally | +| 4 | **FHIR *content* localization** | `Resource.text` narrative, extensions | resource data itself | out of scope to translate; must be *passed through* correctly | +| 5 | **Terminology display** | SNOMED "diabetes mellitus" → German term | HTS designations + `displayLanguage` | **already implemented** (see §4) | +| 6 | **Formatting** | dates, numbers, units | locale-aware formatting at render time | guidelines here, applied in UI crate | + +The critical mental model: **layers 1–3 are *our* strings** (we author and +translate them), **layers 4–5 are *the data's* strings** (we select and render +the right one but do not invent translations), and **layer 6 is presentation**. + +--- + +## 2. Front-to-back request flow + +A localized request flows through the stack like this: + +``` +Browser ──Accept-Language: de-DE, de;q=0.9, en;q=0.7──▶ hfs (Axum) + │ │ + │ (or ?lang=de override, or user-saved preference) ▼ + │ ┌──────────────────────┐ + │ │ locale-negotiation │ Layer 2 + │ │ middleware │ + │ │ → RequestLocale │ + │ └──────────┬───────────┘ + │ │ + │ ┌───────────────────────────────┼───────────────┐ + │ ▼ ▼ ▼ + │ helios-web (UI) helios-rest (API) helios-hts + │ renders Askama template OperationOutcome $expand/$lookup + │ + Fluent catalog (Layer 1) text (Layer 3) displayLanguage + │ (Layer 5) + ▼ +HTML fragment / page ◀── all three consult the SAME negotiated RequestLocale ── +``` + +**One negotiated locale per request, computed once, threaded everywhere.** The UI +chrome, the error text, and the terminology `displayLanguage` must all agree. +A page that says "Terminología" in the nav but shows English SNOMED terms is a +bug, and it is only avoidable if every layer reads the same `RequestLocale`. + +### Locale negotiation precedence (highest wins) + +1. **Explicit override** — `?lang=` query param or a `hfs_lang` cookie set by the + language switcher. Lets a user read in a language other than their browser's. +2. **Saved user preference** — from `feat/user-ui-settings` (a per-user setting), + when authenticated. +3. **`Accept-Language`** — RFC 4647 lookup against our supported set. +4. **Server default** — `en`. + +The negotiated result is a single value carried in request extensions (e.g. +`RequestLocale(LanguageIdentifier)`), available to every handler. The same value +is passed to HTS as `displayLanguage` / forwarded as `Accept-Language` so +terminology display matches the UI. + +--- + +## 3. UI strings — the chosen approach (Layers 1–2) + +### 3.1 Format: Project Fluent + +We store UI strings as **[Project Fluent](https://projectfluent.org/) (`.ftl`)** +catalogs under `locales//main.ftl` (seeded in this branch for `en`, +`es`, `de`). + +**Why Fluent over flat key/value (JSON/`gettext`):** + +- **Grammar-correct plurals and selectors** via CLDR categories — German and + Spanish plural rules differ from English, and Fluent selects the right branch + (`[one] … *[other] …`) instead of forcing `"1 result(s)"`. +- **Asymmetric translation:** a translation may need a plural/gender branch the + English source does not. Fluent allows a locale to add branches without + changing the source — flat maps cannot express this. +- **Placeables and terms** (`{ -app-name }`, `{ $count }`) keep interpolation and + reusable brand strings in the catalog, not in Rust. +- **First-class Rust support** via [`fluent`](https://docs.rs/fluent) / + [`fluent-templates`](https://docs.rs/fluent-templates), which integrates with + **Askama** — the templating engine proposed for the UI in #186. This keeps the + toolchains aligned. + +**Trade-off:** Fluent is a richer format than a JSON map, so translators need a +one-page primer. That cost is paid once and is far smaller than the cost of +retrofitting plural/gender handling onto a flat catalog later. + +*Rejected alternatives:* a hand-rolled `HashMap<&str,&str>` (no plurals, no +fallback semantics, invites string-concatenation bugs); `gettext`/`.po` (mature, +but weaker Rust ergonomics and a clumsier plural model than Fluent). + +### 3.2 Loading and rendering + +- Catalogs are **embedded at build time** (via `fluent-templates`' + `static_loader!` / `rust-embed`) so the server is a single binary with no + runtime file dependency — consistent with the asset-embedding stance in #186 + (no runtime CDN, air-gap-friendly for a healthcare deployment). +- Templates look up strings by key through a small helper (e.g. an Askama filter + or a `t("nav-dashboard")`-style function bound to the request's + `RequestLocale`). **Templates never hold English text** — they hold keys. +- Missing-key and missing-locale behavior is a **fallback chain**, never a crash + and never a blank: `negotiated locale → its base language → en`. A key present + in `en` but absent in `de` renders the English string (logged in debug), so a + half-translated locale is always usable. + +### 3.3 Where things go — rules of the road + +**Where UI localization lives** +- `locales//main.ftl` — all translatable UI text. Split into multiple + `.ftl` files per feature area only when `main.ftl` gets unwieldy; keep the same + split across all locales. +- Locale-negotiation middleware — in the UI/rest layer, producing one + `RequestLocale` per request. +- A thin template helper that resolves keys against the request locale. + +**Where it must NOT go** +- **No hardcoded human-readable strings in Rust or templates.** If a user can + read it, it comes from a catalog. (Log messages and developer-facing errors are + exempt — those stay English.) +- **No string concatenation to build sentences** ("You have " + n + " results"). + Sentence structure varies by language; use one keyed message with placeables + and a plural selector. +- **No per-locale branching in handler logic** (`if lang == "de"`). Differences + live in the catalog, not in Rust control flow. +- **No new browser-facing JSON API for translations** — the UI renders localized + HTML server-side (consistent with #186's hypermedia stance). +- **Do not localize identifiers, codes, URLs, or FHIR element names** — only + human-facing prose. + +--- + +## 4. Terminology localization — already implemented (Layer 5) + +HFS's terminology server (HTS) **already supports multi-language SNOMED CT** (and +LOINC linguistic variants), and the UI must build on this rather than reinvent +it. This is the most mature localization layer in the product. + +- **Multi-language import.** The SNOMED RF2 importer + (`crates/hts/src/import/snomed_rf2.rs`) imports *all* active descriptions in + *every language present in the archive*, including the per-language description + files shipped by national editions, as FHIR `concept.designation` entries + tagged with the RF2 `languageCode`. Language reference sets are consulted to + emit `preferredForLanguage` designations for the refset-preferred synonym in + each language. +- **Import can be scoped by language** via `LanguageFilter` + (`HTS_IMPORT_LANGUAGES` / `--languages`) so a deployment can import only the + languages it serves; excluded per-language files are skipped without being + parsed. +- **Runtime language selection.** `$expand` / `$lookup` / `$validate-code` honor + `displayLanguage` (and the `Accept-Language` header) to return the right term. + The matching logic lives in `crates/hts/src/language.rs`, which implements + **RFC 4647 §3.4 Lookup** with progressive tag truncation — deliberately built + to reconcile the *heterogeneous* language tags terminologies ship (SNOMED RF2 + bare tags like `de`; LOINC region-qualified tags like `de-DE`) with whatever a + browser sends (`de-DE` via `Accept-Language`). It ranks candidates so `es-ES` + beats `esES` beats `es`/`es-MX`, and a `de` request accepts a stored `de-CH`. + +**Rule of the road:** the UI's terminology screens pass the request's negotiated +locale straight through to HTS as `displayLanguage`. **Do not** add a second, +UI-local translation table for clinical terms — the authoritative multilingual +terms already exist as designations, and duplicating them would drift from the +source terminology. UI *chrome* around a term ("Preferred term", "Synonyms") is +Layer 1; the term itself is Layer 5. + +--- + +## 5. API and error messages (Layer 3) + +`OperationOutcome` is a FHIR resource returned to *both* machines and humans. Its +diagnostic prose (`issue.details.text`, `issue.diagnostics`, the human `text` +narrative) should be localizable to the request locale, while the machine-facing +parts stay stable: + +- **Localize:** `OperationOutcome.text` (the human narrative) and + `issue.details.text`. +- **Do NOT localize:** `issue.code`, `issue.details.coding` (the codes machines + branch on), resource identifiers, or field paths in `issue.expression`. + +Error strings live in the same Fluent catalogs (`error-*` keys, seeded here) so +there is one translation workflow, not two. This layer is **scaffolded** in this +branch and wired into `helios-rest` responses incrementally — negotiation and the +catalog exist; individual message sites are migrated off hardcoded English as +they are touched, to avoid one giant risky sweep. + +--- + +## 6. FHIR content localization (Layer 4) — pass-through, don't invent + +FHIR resources carry their own language metadata, and HFS's job is to **preserve +and surface it correctly, not to translate clinical content**: + +- `Resource.language` (the language of the resource) and `Resource.text` (the + narrative) are authored data. Round-trip them faithfully across all supported + FHIR versions (R4/R4B/R5/R6) — do not strip, rewrite, or machine-translate. +- Translatable content extensions (e.g. the R5+ translation extension on + string elements) are data: render the variant matching the request locale when + present, fall back to the base value otherwise. + +**Rule of the road:** translating patient/clinical content is a clinical-safety +concern and is **out of scope** for HFS's UI i18n. We select among translations +that already exist in the data; we never generate them. + +--- + +## 7. Formatting (Layer 6) + +Dates, times, numbers, and quantities are **presentation** and must be formatted +for the render locale, not hardcoded to `en-US`: + +- Format at the edge (in the UI/template layer), from locale-neutral values + (ISO 8601 instants, numeric quantities). Never store a pre-formatted localized + string. +- Prefer a maintained locale-data library (e.g. ICU4X) over ad-hoc `strftime` + patterns; a German user expects `2. Juli 2026`, a US user `July 2, 2026`. +- **Never localize FHIR wire formats** — `date`/`dateTime`/`instant` on the FHIR + API are always ISO 8601 regardless of UI locale. Formatting is a display-only + transform applied after the data leaves the FHIR layer. +- UCUM units are codes, not prose — do not translate them. + +--- + +## 8. Security & correctness notes + +- **Auto-escaping still applies to translated text.** Treat catalog values as + untrusted for output-encoding purposes; the template engine's auto-escaping + (Askama, per #186) must not be bypassed for "trusted" translations. Interpolate + values as placeables so they are escaped, not spliced into raw markup. +- **No user-controlled format strings.** Fluent placeables are named and typed; + never build a catalog key or a message body from user input. +- **Locale is not authorization.** Negotiated locale changes *presentation only*; + it must never widen data access or alter tenant/scope decisions. + +--- + +## 9. Language roadmap + +| Phase | Languages | Notes | +|-------|-----------|-------| +| Foundation (this issue) | `en` (source), `es`, `de` | catalogs, negotiation, docs, POC hook | +| Next | fill error/API messages (§5) | migrate hardcoded strings incrementally | +| Later | `fr`, `pt`, … | pure translation once §1–§7 hold | + +Adding a language = copy `locales/en/`, translate values, register the locale, +add it to the switcher. If that ever requires touching Rust control flow, the +rules of the road in §3.3 have been violated. + +--- + +## 10. Deliverables in this branch + +- `locales/{en,es,de}/main.ftl` — seeded UI message catalogs (source + two). +- `locales/README.md` — catalog conventions. +- `docs/multi-language.md` — this document. + +Structure that lands with the UI crate (#186), not here: the negotiation +middleware, the Askama/Fluent template helper, and the `Cargo.toml` wiring — this +branch establishes the **shape and the rules**; the runtime wiring rides on the +`helios-web` foundation so the two don't conflict. + +--- + +## 11. References + +- **Project Fluent** — https://projectfluent.org/ (syntax guide, plural/selector + model) and the Fluent **Rust** crates: https://docs.rs/fluent and + https://docs.rs/fluent-templates +- **Unicode CLDR plural rules** — https://cldr.unicode.org/index/cldr-spec/plural-rules +- **BCP 47** (language tags) — https://www.rfc-editor.org/info/bcp47 and + **RFC 4647** (language-tag matching / Lookup) — + https://www.rfc-editor.org/rfc/rfc4647 (basis for `crates/hts/src/language.rs`) +- **HTTP `Accept-Language`** — https://developer.mozilla.org/docs/Web/HTTP/Headers/Accept-Language +- **ICU4X** (locale-aware date/number formatting in Rust) — https://github.com/unicode-org/icu4x +- **FHIR — resource language & narrative** — https://hl7.org/fhir/resource.html#language + and https://hl7.org/fhir/narrative.html +- **FHIR terminology — designations & `displayLanguage`** — + https://hl7.org/fhir/valueset-operation-expand.html (see `displayLanguage`) +- **SNOMED CT — language reference sets** — + https://confluence.ihtsdotools.org/display/DOCGLOSS/language+reference+set +- **W3C Internationalization** (best practices) — https://www.w3.org/International/ +- **OWASP — output encoding / XSS** (applies to server-rendered translated + strings) — https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html +- Related HFS work: #186 (HTMX-first web UI foundation), `feat/user-ui-settings` + (per-user preferences, incl. language), `feat/smart-ui-auth`. diff --git a/locales/README.md b/locales/README.md new file mode 100644 index 000000000..3924c1ad3 --- /dev/null +++ b/locales/README.md @@ -0,0 +1,37 @@ +# `locales/` — UI message catalogs + +This directory holds the **translatable strings for the HFS user interface**, +one subdirectory per locale, in [Project Fluent](https://projectfluent.org/) +(`.ftl`) format. + +``` +locales/ +├── en/main.ftl ← SOURCE locale (canonical key set) — edit here first +├── es/main.ftl ← Spanish +└── de/main.ftl ← German +``` + +## Rules of the road + +- **English (`en`) is the source of truth.** Add or rename a key in + `en/main.ftl` first; every other locale must define the same key set. +- **Missing keys fall back** through the negotiated chain to `en` (see the + full policy in [`docs/multi-language.md`](../docs/multi-language.md)). A + missing translation degrades to English, never to a raw key or a blank. +- **Translations are data, not code.** No HTML, no logic, no string + concatenation in templates — put the whole sentence in the catalog and + interpolate values with Fluent placeables (`{ $var }`). +- **Pluralization uses CLDR categories** via Fluent selectors (`[one]`, + `*[other]`, …). Do not build `"1 result(s)"` by hand. +- **Keep keys stable and semantic** (`nav-dashboard`, not `label_17`). + +## Adding a language + +1. Copy `en/` to a new locale directory (e.g. `fr/`). +2. Translate every value; leave keys and placeables untouched. +3. Register the locale in the UI's supported-locale list and add it to the + language switcher (`language-*` keys). + +See [`docs/multi-language.md`](../docs/multi-language.md) for how these +catalogs are loaded, how locale negotiation works end to end, and how UI +localization relates to FHIR content and terminology (SNOMED) localization. diff --git a/locales/de/main.ftl b/locales/de/main.ftl new file mode 100644 index 000000000..022bbbc95 --- /dev/null +++ b/locales/de/main.ftl @@ -0,0 +1,65 @@ +# Helios FHIR-Server — UI-Nachrichtenkatalog +# Gebietsschema: Deutsch (de) +# +# Verwenden Sie dieselben Schlüssel wie in `en/main.ftl` (Quell-Gebietsschema). +# Fehlende Schlüssel greifen gemäß der in docs/multi-language.md beschriebenen +# Fallback-Kette auf Englisch zurück. + +## Marke / gemeinsame Begriffe + +-app-name = Helios FHIR-Server +-org-name = Helios Software + +## Seitenstruktur + +app-title = { -app-name } +app-tagline = Ein schneller, versionsübergreifender FHIR-Server + +nav-dashboard = Übersicht +nav-terminology = Terminologie +nav-resources = Ressourcen +nav-settings = Einstellungen +nav-signout = Abmelden + +## Sprachauswahl + +language-label = Sprache +language-en = Englisch +language-es = Spanisch +language-de = Deutsch + +## Übersicht / Status + +dashboard-heading = Server-Übersicht +health-status-ok = Alle Systeme betriebsbereit +health-status-degraded = Einige Systeme sind beeinträchtigt +health-uptime = Betriebszeit: { $duration } + +resource-count = { $count -> + [one] { $count } Ressource + *[other] { $count } Ressourcen +} + +## Terminologie durchsuchen + +terminology-search-label = CodeSystems und ValueSets durchsuchen +terminology-search-placeholder = z. B. 73211009, „Diabetes“, http://snomed.info/sct +terminology-display-language = Anzeigesprache +terminology-no-results = Keine passenden Konzepte gefunden. + +## Allgemeine Aktionen + +action-search = Suchen +action-save = Speichern +action-cancel = Abbrechen +action-retry = Erneut versuchen + +## Fehler (spiegelt den OperationOutcome-Text wider; siehe docs/multi-language.md §5) + +error-not-found = Die angeforderte Ressource wurde nicht gefunden. +error-unauthorized = Sie sind nicht berechtigt, diese Aktion auszuführen. +error-generic = Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut. + +## Fußzeile + +footer-copyright = © { $year } { -org-name } diff --git a/locales/en/main.ftl b/locales/en/main.ftl new file mode 100644 index 000000000..725a548ce --- /dev/null +++ b/locales/en/main.ftl @@ -0,0 +1,69 @@ +# Helios FHIR Server — UI message catalog +# Locale: English (en) — SOURCE LOCALE. Every key defined here is the +# canonical set; other locales are expected to provide the same keys. +# +# Syntax: Project Fluent (https://projectfluent.org/). Terms (prefixed with +# `-`) are reusable snippets; messages (bare identifiers) are what the UI +# looks up. Placeables `{ $var }` are interpolated by the caller. Do NOT put +# markup or logic here — translations are data, the template renders them. + +## Brand / shared terms + +-app-name = Helios FHIR Server +-org-name = Helios Software + +## Page chrome + +app-title = { -app-name } +app-tagline = A fast, multi-version FHIR server + +nav-dashboard = Dashboard +nav-terminology = Terminology +nav-resources = Resources +nav-settings = Settings +nav-signout = Sign out + +## Language switcher + +language-label = Language +language-en = English +language-es = Spanish +language-de = German + +## Dashboard / health + +dashboard-heading = Server dashboard +health-status-ok = All systems operational +health-status-degraded = Some systems are degraded +health-uptime = Uptime: { $duration } + +# Pluralized count — every locale must supply the plural categories its +# grammar requires (CLDR rules; Fluent selects the branch automatically). +resource-count = { $count -> + [one] { $count } resource + *[other] { $count } resources +} + +## Terminology browsing + +terminology-search-label = Search CodeSystems and ValueSets +terminology-search-placeholder = e.g. 73211009, "diabetes", http://snomed.info/sct +terminology-display-language = Display language +terminology-no-results = No matching concepts found. + +## Common actions + +action-search = Search +action-save = Save +action-cancel = Cancel +action-retry = Retry + +## Errors (mirrors OperationOutcome text; see docs/multi-language.md §5) + +error-not-found = The requested resource was not found. +error-unauthorized = You are not authorized to perform this action. +error-generic = Something went wrong. Please try again. + +## Footer + +footer-copyright = © { $year } { -org-name } diff --git a/locales/es/main.ftl b/locales/es/main.ftl new file mode 100644 index 000000000..0e7bb4139 --- /dev/null +++ b/locales/es/main.ftl @@ -0,0 +1,65 @@ +# Servidor FHIR Helios — catálogo de mensajes de la interfaz +# Configuración regional: Español (es) +# +# Mantenga las mismas claves que en `en/main.ftl` (la configuración regional +# de origen). Las claves que falten recurren a inglés según la cadena de +# reserva descrita en docs/multi-language.md. + +## Marca / términos compartidos + +-app-name = Servidor FHIR Helios +-org-name = Helios Software + +## Estructura de página + +app-title = { -app-name } +app-tagline = Un servidor FHIR rápido y multiversión + +nav-dashboard = Panel +nav-terminology = Terminología +nav-resources = Recursos +nav-settings = Configuración +nav-signout = Cerrar sesión + +## Selector de idioma + +language-label = Idioma +language-en = Inglés +language-es = Español +language-de = Alemán + +## Panel / estado + +dashboard-heading = Panel del servidor +health-status-ok = Todos los sistemas operativos +health-status-degraded = Algunos sistemas están degradados +health-uptime = Tiempo activo: { $duration } + +resource-count = { $count -> + [one] { $count } recurso + *[other] { $count } recursos +} + +## Exploración de terminología + +terminology-search-label = Buscar CodeSystems y ValueSets +terminology-search-placeholder = p. ej. 73211009, «diabetes», http://snomed.info/sct +terminology-display-language = Idioma de visualización +terminology-no-results = No se encontraron conceptos coincidentes. + +## Acciones comunes + +action-search = Buscar +action-save = Guardar +action-cancel = Cancelar +action-retry = Reintentar + +## Errores (refleja el texto de OperationOutcome; véase docs/multi-language.md §5) + +error-not-found = No se encontró el recurso solicitado. +error-unauthorized = No está autorizado para realizar esta acción. +error-generic = Algo salió mal. Vuelva a intentarlo. + +## Pie de página + +footer-copyright = © { $year } { -org-name }