From 158a9f64daac699d094dc7550c81be9059aefa0a Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Thu, 5 Jun 2025 01:50:52 +0200 Subject: Add grit module to common Parses grit files. I tried using serde-xml-rs but it can't handle text mixed with elements so xml-rs and event stream it is. --- server/Cargo.lock | 8 + server/Cargo.toml | 3 +- server/common/Cargo.toml | 2 + server/common/src/grit.rs | 1086 ++++++++++++++++++++++++++++ server/common/src/lib.rs | 1 + server/common/src/testdata/grit/base.grd | 48 ++ server/common/src/testdata/grit/extra.grdp | 6 + server/common/src/tests.rs | 230 ++++++ 8 files changed, 1383 insertions(+), 1 deletion(-) create mode 100644 server/common/src/grit.rs create mode 100644 server/common/src/testdata/grit/base.grd create mode 100644 server/common/src/testdata/grit/extra.grdp (limited to 'server') diff --git a/server/Cargo.lock b/server/Cargo.lock index 7349bb6..97a5f2d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -611,12 +611,14 @@ dependencies = [ name = "eyeballs-common" version = "0.1.0" dependencies = [ + "anyhow", "futures", "log", "pathdiff", "serde", "testdir", "tokio", + "xml-rs", ] [[package]] @@ -3680,6 +3682,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xml-rs" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" + [[package]] name = "yansi" version = "1.0.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index f8bab3c..f62a2db 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,6 +10,7 @@ default-members = [".", "api", "common", "hook"] resolver = "2" [workspace.dependencies] +anyhow = "1.0" futures = "0.3.31" log = { version = "0.4.25", features = ["release_max_level_warn"] } rmp-serde = "1.3" @@ -19,7 +20,7 @@ tokio = { version = "1" } utoipa = { version = "5" } [dependencies] -anyhow = "1.0" +anyhow.workspace = true eyeballs-api = { path = "api" } eyeballs-common = { path = "common" } futures.workspace = true diff --git a/server/common/Cargo.toml b/server/common/Cargo.toml index fdabffa..25d3d39 100644 --- a/server/common/Cargo.toml +++ b/server/common/Cargo.toml @@ -4,11 +4,13 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow.workspace = true futures.workspace = true log.workspace = true pathdiff = "0.2.3" serde.workspace = true tokio = { workspace = true, features = ["fs", "macros", "process", "rt", "sync"] } +xml-rs = "0.8.26" [dev-dependencies] testdir.workspace = true diff --git a/server/common/src/grit.rs b/server/common/src/grit.rs new file mode 100644 index 0000000..273da8a --- /dev/null +++ b/server/common/src/grit.rs @@ -0,0 +1,1086 @@ +#![allow(dead_code)] + +use anyhow::Error; +use std::fs; +use std::io::{BufReader, Read}; +use std::path::Path; +use tokio::task::spawn_blocking; +use xml::attribute::OwnedAttribute; +use xml::reader::{EventReader, ParserConfig, XmlEvent}; + +#[derive(Debug, PartialEq)] +pub struct Grit { + pub current_release: u32, + pub latest_public_release: u32, + + pub outputs: Outputs, + pub translations: Translations, + pub release: Release, +} + +#[derive(Debug, PartialEq)] +pub struct Outputs { + pub output: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct Output { + pub filename: String, + pub output_type: String, + pub lang: String, +} + +#[derive(Debug, PartialEq)] +pub enum IfOutput { + Output(Output), + + If { expr: String, output: Vec }, +} + +#[derive(Debug, PartialEq)] +pub struct Translations { + pub file: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct File { + pub path: String, + pub lang: String, +} + +#[derive(Debug, PartialEq)] +pub enum IfFile { + File(File), + + If { expr: String, file: Vec }, +} + +#[derive(Debug, PartialEq)] +pub struct Release { + pub allow_pseudo: bool, + pub seq: u32, + + pub messages: Messages, +} + +#[derive(Debug, PartialEq)] +pub struct Messages { + pub fallback_to_english: bool, + + pub messages: Vec, +} + +#[derive(Debug, PartialEq)] +pub enum IfMessagePart { + Message(Message), + + Part(PartRef), + + If { + expr: String, + + message: Vec, + }, +} + +#[derive(Debug, PartialEq)] +pub enum MessagePart { + Message(Message), + + Part(PartRef), +} + +#[derive(Debug, PartialEq)] +pub struct PartRef { + pub file: String, +} + +#[derive(Debug, PartialEq)] +pub struct Message { + pub desc: String, + pub name: String, + pub internal_comment: Option, + pub meaning: Option, + + pub content: Vec, +} + +#[derive(Debug, PartialEq)] +pub enum TextPlaceholder { + Text(String), + Placeholder { + name: String, + content: String, + example: Option, + }, +} + +#[derive(Debug, PartialEq)] +pub enum IfMessage { + Message(Message), + + If { expr: String, message: Vec }, +} + +#[derive(Debug, PartialEq)] +pub struct GritPart { + pub messages: Vec, +} + +fn get_opt_attribute<'a>(attributes: &'a Vec, name: &str) -> Option<&'a str> { + for attribute in attributes { + if attribute.name.local_name == name { + return Some(attribute.value.as_str()); + } + } + None +} + +fn get_attribute<'a>(attributes: &'a Vec, name: &str) -> anyhow::Result<&'a str> { + get_opt_attribute(attributes, name).ok_or(Error::msg(format!("Expected attribute {name}"))) +} + +fn parse_grit_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let current_release = get_attribute(attributes, "current_release")?.parse::()?; + let latest_public_release = + get_attribute(attributes, "latest_public_release")?.parse::()?; + + let mut outputs: Option = None; + let mut translations: Option = None; + let mut release: Option = None; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "outputs" => { + outputs = Some(parse_outputs_element(&attributes, reader)?); + } + "translations" => { + translations = Some(parse_translations_element(&attributes, reader)?); + } + "release" => { + release = Some(parse_release_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in grit", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "grit"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Grit { + current_release, + latest_public_release, + outputs: outputs.ok_or(Error::msg("Expected outputs in grit"))?, + translations: translations.ok_or(Error::msg("Expected outputs in grit"))?, + release: release.ok_or(Error::msg("Expected outputs in grit"))?, + }) +} + +fn parse_outputs_element( + _attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let mut output = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "if" => { + output.push(parse_if_output_element(&attributes, reader)?); + } + "output" => { + output.push(IfOutput::Output(parse_output_element(&attributes, reader)?)); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in outputs", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "outputs"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Outputs { output }) +} + +fn parse_output_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let filename = get_attribute(attributes, "filename")?; + let output_type = get_attribute(attributes, "type")?; + let lang = get_attribute(attributes, "lang")?; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes: _, + namespace: _, + } => { + return Err(Error::msg(format!( + "Unexpected {0} in output", + name.local_name + ))); + } + XmlEvent::EndElement { name } => { + assert!(name.local_name == "output"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Output { + filename: filename.to_string(), + output_type: output_type.to_string(), + lang: lang.to_string(), + }) +} + +fn parse_if_output_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let expr = get_attribute(attributes, "expr")?; + let mut output = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "output" => { + output.push(parse_output_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in outputs>if", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "if"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(IfOutput::If { + expr: expr.to_string(), + output, + }) +} + +fn parse_translations_element( + _attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let mut file = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "if" => { + file.push(parse_if_file_element(&attributes, reader)?); + } + "file" => { + file.push(IfFile::File(parse_file_element(&attributes, reader)?)); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in translations", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "translations"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Translations { file }) +} + +fn parse_file_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let path = get_attribute(attributes, "path")?; + let lang = get_attribute(attributes, "lang")?; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes: _, + namespace: _, + } => { + return Err(Error::msg(format!( + "Unexpected {0} in file", + name.local_name + ))); + } + XmlEvent::EndElement { name } => { + assert!(name.local_name == "file"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(File { + path: path.to_string(), + lang: lang.to_string(), + }) +} + +fn parse_if_file_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let expr = get_attribute(attributes, "expr")?; + let mut file = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "file" => { + file.push(parse_file_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in outputs>if", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "if"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(IfFile::If { + expr: expr.to_string(), + file, + }) +} + +fn parse_release_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let allow_pseudo = get_attribute(attributes, "allow_pseudo")?.parse::()?; + let seq = get_attribute(attributes, "seq")?.parse::()?; + + let mut messages: Option = None; + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "messages" => { + messages = Some(parse_messages_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in release", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "release"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Release { + allow_pseudo, + seq, + messages: messages.ok_or(Error::msg("No messages in release"))?, + }) +} + +fn parse_messages_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let fallback_to_english = get_attribute(attributes, "fallback_to_english")?.parse::()?; + + let mut messages = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "message" => { + messages.push(IfMessagePart::Message(parse_message_element( + &attributes, + reader, + )?)); + } + "part" => { + messages.push(IfMessagePart::Part(parse_part_element( + &attributes, + reader, + )?)); + } + "if" => { + messages.push(parse_if_message_part_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in messages", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "messages"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(Messages { + fallback_to_english, + messages, + }) +} + +fn parse_message_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let desc = get_attribute(attributes, "desc")?; + let name = get_attribute(attributes, "name")?; + let internal_comment = get_opt_attribute(attributes, "internal_comment").map(|s| s.to_string()); + let meaning = get_opt_attribute(attributes, "meaning").map(|s| s.to_string()); + + let mut content = Vec::::new(); + let mut first = true; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "ph" => { + first = false; + content.push(parse_placeholder_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in file", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "message"); + break; + } + XmlEvent::Characters(data) => content.push(TextPlaceholder::Text(if first { + first = false; + data.trim_start().to_string() + } else { + data + })), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + if !first { + match content.last_mut().unwrap() { + TextPlaceholder::Text(data) => { + data.truncate(data.trim_end().len()); + if data.is_empty() { + content.pop(); + } + } + TextPlaceholder::Placeholder { + name: _, + content: _, + example: _, + } => {} + } + } + + Ok(Message { + desc: desc.to_string(), + name: name.to_string(), + internal_comment, + meaning, + content, + }) +} + +fn parse_if_message_part_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let expr = get_attribute(attributes, "expr")?; + + let mut message = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "message" => { + message.push(MessagePart::Message(parse_message_element( + &attributes, + reader, + )?)); + } + "part" => { + message.push(MessagePart::Part(parse_part_element(&attributes, reader)?)); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in messages>if", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "if"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(IfMessagePart::If { + expr: expr.to_string(), + message, + }) +} + +fn parse_if_message_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let expr = get_attribute(attributes, "expr")?; + + let mut message = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "message" => { + message.push(parse_message_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in grit-part>if", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "if"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(IfMessage::If { + expr: expr.to_string(), + message, + }) +} + +fn parse_part_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let file = get_attribute(attributes, "file")?; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes: _, + namespace: _, + } => { + return Err(Error::msg(format!( + "Unexpected {0} in part", + name.local_name + ))); + } + XmlEvent::EndElement { name } => { + assert!(name.local_name == "part"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(PartRef { + file: file.to_string(), + }) +} + +fn parse_placeholder_element( + attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let name = get_attribute(attributes, "name")?; + + let mut content = String::new(); + let mut example: Option = None; + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "ex" => { + if example.is_some() { + return Err(Error::msg("Multiple examples in placeholder")); + } + example = Some(parse_placeholder_example_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in file", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "ph"); + break; + } + XmlEvent::Characters(data) => { + if example.is_some() { + return Err(Error::msg("Text after example in placeholder")); + } + content.push_str(data.as_str()); + } + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(TextPlaceholder::Placeholder { + name: name.to_string(), + content, + example, + }) +} + +fn parse_placeholder_example_element( + _attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let mut content = String::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes: _, + namespace: _, + } => { + return Err(Error::msg(format!("Unexpected {0} in ex", name.local_name))); + } + XmlEvent::EndElement { name } => { + assert!(name.local_name == "ex"); + break; + } + XmlEvent::Characters(data) => { + content.push_str(data.as_str()); + } + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(content) +} + +fn parse_grit_part_element( + _attributes: &Vec, + reader: &mut EventReader, +) -> anyhow::Result { + let mut messages = Vec::::new(); + + loop { + let event = reader.next()?; + match event { + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => match name.local_name.as_str() { + "message" => { + messages.push(IfMessage::Message(parse_message_element( + &attributes, + reader, + )?)); + } + "if" => { + messages.push(parse_if_message_element(&attributes, reader)?); + } + _ => { + return Err(Error::msg(format!( + "Unexpected {0} in grit-part", + name.local_name + ))); + } + }, + XmlEvent::EndElement { name } => { + assert!(name.local_name == "grit-part"); + break; + } + XmlEvent::Characters(_) => (), + + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::EndDocument => panic!("Unexpected EOD"), + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + + Ok(GritPart { messages }) +} + +pub async fn parse_grit(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref().to_path_buf(); + spawn_blocking(move || { + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + let mut ereader = ParserConfig::new() + .ignore_comments(true) + .whitespace_to_characters(true) + .cdata_to_characters(true) + .create_reader(reader); + let mut ret: Option = None; + loop { + let event = ereader.next()?; + match event { + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => { + if name.local_name == "grit" { + ret = Some(parse_grit_element(&attributes, &mut ereader)?); + } else { + return Err(Error::msg("Document root != grit")); + } + } + XmlEvent::EndDocument => break, + XmlEvent::EndElement { name: _ } => panic!("Unexpected EoE"), + XmlEvent::Characters(_) => (), + + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + Ok(ret.unwrap()) + }) + .await + .unwrap() +} + +pub async fn parse_grit_part(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref().to_path_buf(); + spawn_blocking(move || { + let file = fs::File::open(path)?; + let reader = BufReader::new(file); + let mut ereader = ParserConfig::new() + .ignore_comments(true) + .whitespace_to_characters(true) + .cdata_to_characters(true) + .create_reader(reader); + let mut ret: Option = None; + loop { + let event = ereader.next()?; + match event { + XmlEvent::StartDocument { + version: _, + encoding: _, + standalone: _, + } => (), + XmlEvent::StartElement { + name, + attributes, + namespace: _, + } => { + if name.local_name == "grit-part" { + ret = Some(parse_grit_part_element(&attributes, &mut ereader)?); + } else { + return Err(Error::msg("Document root != grit-part")); + } + } + XmlEvent::EndDocument => break, + XmlEvent::EndElement { name: _ } => panic!("Unexpected EoE"), + XmlEvent::Characters(_) => (), + + XmlEvent::ProcessingInstruction { name: _, data: _ } => (), + XmlEvent::CData(_) => (), + XmlEvent::Comment(_) => (), + XmlEvent::Whitespace(_) => (), + } + } + Ok(ret.unwrap()) + }) + .await + .unwrap() +} diff --git a/server/common/src/lib.rs b/server/common/src/lib.rs index abea52e..b844371 100644 --- a/server/common/src/lib.rs +++ b/server/common/src/lib.rs @@ -1,6 +1,7 @@ pub mod fs_utils; pub mod git; pub mod git_socket; +pub mod grit; #[cfg(test)] mod tests; diff --git a/server/common/src/testdata/grit/base.grd b/server/common/src/testdata/grit/base.grd new file mode 100644 index 0000000..2ddc74a --- /dev/null +++ b/server/common/src/testdata/grit/base.grd @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Bookmarks + + + Welcome to %1$sOpera + + + By using this application you are agreeing to Opera's <tos>Terms of Service</tos>. Also, you can learn how Opera handles and protects your data in our <privacy>Privacy Statement</privacy>. + + + {BOOKMARKS, plural, + one {%1$d1 folder deleted} + few {%1$d15 folders deleted} + many {%1$d100 folders deleted} + other {%1$d42 folders deleted}} + + + + diff --git a/server/common/src/testdata/grit/extra.grdp b/server/common/src/testdata/grit/extra.grdp new file mode 100644 index 0000000..b4382a3 --- /dev/null +++ b/server/common/src/testdata/grit/extra.grdp @@ -0,0 +1,6 @@ + + + + Extra title + + diff --git a/server/common/src/tests.rs b/server/common/src/tests.rs index 5a02119..fc4d7aa 100644 --- a/server/common/src/tests.rs +++ b/server/common/src/tests.rs @@ -5,6 +5,7 @@ use tokio::sync::OnceCell; use crate::fs_utils; use crate::git; +use crate::grit; #[tokio::test] async fn test_fs_utils_create_dir_allow_existing() { @@ -285,3 +286,232 @@ async fn test_git_bare_delete_branch() { assert!(repo.delete_branch("other").await.is_ok()); assert!(repo.delete_branch("does-not-exist").await.is_err()); } + +#[tokio::test] +async fn test_grit_parse_base() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/testdata/grit/base.grd"); + let grit = grit::parse_grit(path).await.unwrap(); + assert_eq!( + grit, + grit::Grit { + current_release: 1, + latest_public_release: 0, + outputs: grit::Outputs { + output: vec![ + grit::IfOutput::Output( + grit::Output { + filename: "values/strings.xml".to_string(), + output_type: "android".to_string(), + lang: "en".to_string(), + }, + ), + grit::IfOutput::Output( + grit::Output { + filename: "values-en-rGB/strings.xml".to_string(), + output_type: "android".to_string(), + lang: "en-GB".to_string(), + }, + ), + grit::IfOutput::Output( + grit::Output { + filename: "values-my/strings.xml".to_string(), + output_type: "android".to_string(), + lang: "my".to_string(), + }, + ), + grit::IfOutput::If { + expr: "not pp_if('zawgyi_encoding')".to_string(), + output: vec![ + grit::Output { + filename: "values-my-rZG/strings.xml".to_string(), + output_type: "android".to_string(), + lang: "my-ZG".to_string(), + }, + ], + }, + grit::IfOutput::Output( + grit::Output { + filename: "values-sv/strings.xml".to_string(), + output_type: "android".to_string(), + lang: "sv".to_string(), + }, + ), + ], + }, + translations: grit::Translations { + file: vec![ + grit::IfFile::File( + grit::File { + path: "translations/base_en_gb.xlf".to_string(), + lang: "en-GB".to_string(), + }, + ), + grit::IfFile::If { + expr: "pp_if('zawgyi_encoding')".to_string(), + file: vec![ + grit::File { + path: "translations/base_my-Zawgyi.xlf".to_string(), + lang: "my".to_string(), + }, + ], + }, + grit::IfFile::If { + expr: "not pp_if('zawgyi_encoding')".to_string(), + file: vec![ + grit::File { + path: "translations/base_my.xlf".to_string(), + lang: "my".to_string(), + }, + grit::File { + path: "translations/base_my-Zawgyi.xlf".to_string(), + lang: "my-ZG".to_string(), + }, + ], + }, + grit::IfFile::File( + grit::File { + path: "translations/base_sv.xlf".to_string(), + lang: "sv".to_string(), + }, + ), + ], + }, + release: grit::Release { + allow_pseudo: false, + seq: 1, + messages: grit::Messages { + fallback_to_english: true, + messages: vec![ + grit::IfMessagePart::If { + expr: "pp_ifdef('include_extra')".to_string(), + message: vec![ + grit::MessagePart::Part( + grit::PartRef { + file: "extra.grdp".to_string(), + }, + ), + ], + }, + grit::IfMessagePart::Message( + grit::Message { + desc: "Title which is shown on the main bookmarks view.".to_string(), + name: "IDS_BOOKMARKS_FRAGMENT_TITLE".to_string(), + internal_comment: Some("section(bookmarks)".to_string()), + meaning: None, + content: vec![ + grit::TextPlaceholder::Text("Bookmarks".to_string()), + ], + }, + ), + grit::IfMessagePart::Message( + grit::Message { + desc: "Generic welcome string.".to_string(), + name: "IDS_GENERIC_WELCOME".to_string(), + internal_comment: Some("section(eula)".to_string()), + meaning: None, + content: vec![ + grit::TextPlaceholder::Text("Welcome to ".to_string()), + grit::TextPlaceholder::Placeholder { + name: "STRING".to_string(), + content: "%1$s".to_string(), + example: Some("Opera".to_string()), + }, + ], + }, + ), + grit::IfMessagePart::Message( + grit::Message { + desc: "First startup information about the license and privacy terms.".to_string(), + name: "IDS_START_TERMS".to_string(), + internal_comment: None, + meaning: None, + content: vec![ + grit::TextPlaceholder::Text( + "By using this application you are agreeing to Opera's ".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "TOS_BEGIN".to_string(), + content: "".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + "Terms of Service".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "TOS_END".to_string(), + content: "".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + ". Also, you can learn how Opera handles and protects your data in our ".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "PRIVACY_BEGIN".to_string(), + content: "".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + "Privacy Statement".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "PRIVACY_END".to_string(), + content: "".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + ".".to_string(), + ), + ], + }, + ), + grit::IfMessagePart::Message( + grit::Message { + desc: "Message which is shown when one or more folders have been deleted from the bookmark list.".to_string(), + name: "IDS_BOOKMARKS_FOLDERS_DELETED".to_string(), + internal_comment: None, + meaning: None, + content: vec![ + grit::TextPlaceholder::Text( + "{BOOKMARKS, plural,\n one {".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "COUNT".to_string(), + content: "%1$d".to_string(), + example: Some("1".to_string()), + }, + grit::TextPlaceholder::Text( + " folder deleted}\n few {".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "COUNT".to_string(), + content: "%1$d".to_string(), + example: Some("15".to_string()), + }, + grit::TextPlaceholder::Text( + " folders deleted}\n many {".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "COUNT".to_string(), + content: "%1$d".to_string(), + example: Some("100".to_string()), + }, + grit::TextPlaceholder::Text( + " folders deleted}\n other {".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "COUNT".to_string(), + content: "%1$d".to_string(), + example: Some("42".to_string()), + }, + grit::TextPlaceholder::Text( + " folders deleted}}".to_string(), + ), + ], + }, + ), + ], + }, + }, + }, + ) +} -- cgit v1.2.3-70-g09d2