diff options
Diffstat (limited to 'server')
| -rw-r--r-- | server/Cargo.lock | 8 | ||||
| -rw-r--r-- | server/Cargo.toml | 3 | ||||
| -rw-r--r-- | server/common/Cargo.toml | 2 | ||||
| -rw-r--r-- | server/common/src/grit.rs | 1086 | ||||
| -rw-r--r-- | server/common/src/lib.rs | 1 | ||||
| -rw-r--r-- | server/common/src/testdata/grit/base.grd | 48 | ||||
| -rw-r--r-- | server/common/src/testdata/grit/extra.grdp | 6 | ||||
| -rw-r--r-- | server/common/src/tests.rs | 230 |
8 files changed, 1383 insertions, 1 deletions
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]] @@ -3681,6 +3683,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" 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<IfOutput>, +} + +#[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<Output> }, +} + +#[derive(Debug, PartialEq)] +pub struct Translations { + pub file: Vec<IfFile>, +} + +#[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<File> }, +} + +#[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<IfMessagePart>, +} + +#[derive(Debug, PartialEq)] +pub enum IfMessagePart { + Message(Message), + + Part(PartRef), + + If { + expr: String, + + message: Vec<MessagePart>, + }, +} + +#[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<String>, + pub meaning: Option<String>, + + pub content: Vec<TextPlaceholder>, +} + +#[derive(Debug, PartialEq)] +pub enum TextPlaceholder { + Text(String), + Placeholder { + name: String, + content: String, + example: Option<String>, + }, +} + +#[derive(Debug, PartialEq)] +pub enum IfMessage { + Message(Message), + + If { expr: String, message: Vec<Message> }, +} + +#[derive(Debug, PartialEq)] +pub struct GritPart { + pub messages: Vec<IfMessage>, +} + +fn get_opt_attribute<'a>(attributes: &'a Vec<OwnedAttribute>, 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<OwnedAttribute>, name: &str) -> anyhow::Result<&'a str> { + get_opt_attribute(attributes, name).ok_or(Error::msg(format!("Expected attribute {name}"))) +} + +fn parse_grit_element<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Grit> { + let current_release = get_attribute(attributes, "current_release")?.parse::<u32>()?; + let latest_public_release = + get_attribute(attributes, "latest_public_release")?.parse::<u32>()?; + + let mut outputs: Option<Outputs> = None; + let mut translations: Option<Translations> = None; + let mut release: Option<Release> = 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<R: Read>( + _attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Outputs> { + let mut output = Vec::<IfOutput>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Output> { + 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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<IfOutput> { + let expr = get_attribute(attributes, "expr")?; + let mut output = Vec::<Output>::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<R: Read>( + _attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Translations> { + let mut file = Vec::<IfFile>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<File> { + 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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<IfFile> { + let expr = get_attribute(attributes, "expr")?; + let mut file = Vec::<File>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Release> { + let allow_pseudo = get_attribute(attributes, "allow_pseudo")?.parse::<bool>()?; + let seq = get_attribute(attributes, "seq")?.parse::<u32>()?; + + let mut messages: Option<Messages> = 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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Messages> { + let fallback_to_english = get_attribute(attributes, "fallback_to_english")?.parse::<bool>()?; + + let mut messages = Vec::<IfMessagePart>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<Message> { + 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::<TextPlaceholder>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<IfMessagePart> { + let expr = get_attribute(attributes, "expr")?; + + let mut message = Vec::<MessagePart>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<IfMessage> { + let expr = get_attribute(attributes, "expr")?; + + let mut message = Vec::<Message>::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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<PartRef> { + 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<R: Read>( + attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<TextPlaceholder> { + let name = get_attribute(attributes, "name")?; + + let mut content = String::new(); + let mut example: Option<String> = 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<R: Read>( + _attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<String> { + 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<R: Read>( + _attributes: &Vec<OwnedAttribute>, + reader: &mut EventReader<R>, +) -> anyhow::Result<GritPart> { + let mut messages = Vec::<IfMessage>::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<Path>) -> anyhow::Result<Grit> { + 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<Grit> = 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<Path>) -> anyhow::Result<GritPart> { + 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<GritPart> = 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<grit current_release="1" latest_public_release="0"> + <outputs> + <output filename="values/strings.xml" type="android" lang="en" /> + <output filename="values-en-rGB/strings.xml" type="android" lang="en-GB" /> + <output filename="values-my/strings.xml" type="android" lang="my" /> + <if expr="not pp_if('zawgyi_encoding')"> + <output filename="values-my-rZG/strings.xml" type="android" lang="my-ZG" /> + </if> + <output filename="values-sv/strings.xml" type="android" lang="sv" /> + </outputs> + + <translations> + <file path="translations/base_en_gb.xlf" lang="en-GB" /> + <if expr="pp_if('zawgyi_encoding')"> + <file path="translations/base_my-Zawgyi.xlf" lang="my" /> + </if> + <if expr="not pp_if('zawgyi_encoding')"> + <file path="translations/base_my.xlf" lang="my" /> + <file path="translations/base_my-Zawgyi.xlf" lang="my-ZG" /> + </if> + <file path="translations/base_sv.xlf" lang="sv" /> + </translations> + + <release allow_pseudo="false" seq="1"> + <messages fallback_to_english="true"> + <if expr="pp_ifdef('include_extra')"> + <part file="extra.grdp" /> + </if> + <message desc="Title which is shown on the main bookmarks view." name="IDS_BOOKMARKS_FRAGMENT_TITLE" internal_comment="section(bookmarks)"> + Bookmarks + </message> + <message desc="Generic welcome string." name="IDS_GENERIC_WELCOME" internal_comment="section(eula)"> + Welcome to <ph name="STRING">%1$s<ex>Opera</ex></ph> + </message> + <message desc="First startup information about the license and privacy terms." name="IDS_START_TERMS"> + By using this application you are agreeing to Opera's <ph name="TOS_BEGIN"><tos></ph>Terms of Service<ph name="TOS_END"></tos></ph>. Also, you can learn how Opera handles and protects your data in our <ph name="PRIVACY_BEGIN"><privacy></ph>Privacy Statement<ph name="PRIVACY_END"></privacy></ph>. + </message> + <message desc="Message which is shown when one or more folders have been deleted from the bookmark list." name="IDS_BOOKMARKS_FOLDERS_DELETED"> + {BOOKMARKS, plural, + one {<ph name="COUNT">%1$d<ex>1</ex></ph> folder deleted} + few {<ph name="COUNT">%1$d<ex>15</ex></ph> folders deleted} + many {<ph name="COUNT">%1$d<ex>100</ex></ph> folders deleted} + other {<ph name="COUNT">%1$d<ex>42</ex></ph> folders deleted}} + </message> + </messages> + </release> +</grit> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<grit-part> + <message name="IDS_EXTRA" desc="Extra title"> + Extra title + </message> +</grit-part> 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: "<tos>".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + "Terms of Service".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "TOS_END".to_string(), + content: "</tos>".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: "<privacy>".to_string(), + example: None, + }, + grit::TextPlaceholder::Text( + "Privacy Statement".to_string(), + ), + grit::TextPlaceholder::Placeholder { + name: "PRIVACY_END".to_string(), + content: "</privacy>".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(), + ), + ], + }, + ), + ], + }, + }, + }, + ) +} |
