Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions langcodec-cli/src/annotate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,16 +267,17 @@ fn run_annotate_with_backend(

for request in &requests {
match results.get(&request.key) {
Some(Some(annotation)) => {
Some(Some(annotation))
if apply_annotation(
&mut codec,
annotation_format,
&request.key,
&annotation.comment,
)? {
changed += 1;
}
)? =>
{
changed += 1;
}
Some(Some(_)) => {}
Some(None) => unmatched += 1,
None => {}
}
Expand Down
102 changes: 102 additions & 0 deletions langcodec-cli/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, Deserialize)]
Expand Down Expand Up @@ -82,6 +83,24 @@ pub struct TolgeePullConfig {
pub file_structure_template: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct UserConfig {
#[serde(default)]
pub tolgee: UserTolgeeConfig,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct UserTolgeeConfig {
pub api_key: Option<String>,
#[serde(default)]
pub projects: HashMap<String, UserTolgeeProjectConfig>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct UserTolgeeProjectConfig {
pub api_key: Option<String>,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct TranslateInputConfig {
pub source: Option<String>,
Expand Down Expand Up @@ -122,6 +141,11 @@ pub struct LoadedConfig {
pub data: CliConfig,
}

#[derive(Debug, Clone)]
pub struct LoadedUserConfig {
pub data: UserConfig,
}

impl LoadedConfig {
pub fn config_dir(&self) -> Option<&Path> {
self.path.parent()
Expand Down Expand Up @@ -168,6 +192,15 @@ impl TolgeeConfig {
}
}

impl UserTolgeeConfig {
pub fn api_key_for_project(&self, project_id: Option<u64>) -> Option<&str> {
project_id
.and_then(|id| self.projects.get(&id.to_string()))
.and_then(|project| project.api_key.as_deref())
.or(self.api_key.as_deref())
}
}

impl TranslateConfig {
pub fn resolved_source(&self) -> Option<&str> {
self.input.source.as_deref().or(self.source.as_deref())
Expand Down Expand Up @@ -246,6 +279,18 @@ pub fn load_config(explicit_path: Option<&str>) -> Result<Option<LoadedConfig>,
Ok(Some(LoadedConfig { path, data }))
}

pub fn load_user_config() -> Result<Option<LoadedUserConfig>, String> {
let Some(path) = discover_user_config_path() else {
return Ok(None);
};

let text = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read user config '{}': {}", path.display(), e))?;
let data: UserConfig = toml::from_str(&text)
.map_err(|e| format!("Failed to parse user config '{}': {}", path.display(), e))?;
Ok(Some(LoadedUserConfig { data }))
}

fn discover_config_path() -> Result<Option<PathBuf>, String> {
let mut current = std::env::current_dir()
.map_err(|e| format!("Failed to determine current directory: {}", e))?;
Expand All @@ -262,6 +307,20 @@ fn discover_config_path() -> Result<Option<PathBuf>, String> {
}
}

fn discover_user_config_path() -> Option<PathBuf> {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(|home| PathBuf::from(home).join(".config"))
})?;

let candidate = config_home.join("langcodec").join("config.toml");
candidate.is_file().then_some(candidate)
}

pub fn resolve_config_relative_path(config_dir: Option<&Path>, path: &str) -> String {
let candidate = Path::new(path);
if candidate.is_absolute() {
Expand Down Expand Up @@ -565,4 +624,47 @@ namespace = "WebGame"

assert_eq!(config.tolgee.push.languages, Some(vec!["en".to_string()]));
}

#[test]
fn user_config_parses_tolgee_global_api_key() {
let config: UserConfig = toml::from_str(
r#"
[tolgee]
api_key = "tgpak_user_default_key"
"#,
)
.expect("parse user config");

assert_eq!(
config.tolgee.api_key.as_deref(),
Some("tgpak_user_default_key")
);
assert_eq!(
config.tolgee.api_key_for_project(Some(36)),
Some("tgpak_user_default_key")
);
}

#[test]
fn user_config_parses_tolgee_project_api_key() {
let config: UserConfig = toml::from_str(
r#"
[tolgee]
api_key = "tgpak_user_default_key"

[tolgee.projects.36]
api_key = "tgpak_project_specific_key"
"#,
)
.expect("parse user config");

assert_eq!(
config.tolgee.api_key_for_project(Some(36)),
Some("tgpak_project_specific_key")
);
assert_eq!(
config.tolgee.api_key_for_project(Some(37)),
Some("tgpak_user_default_key")
);
}
}
65 changes: 25 additions & 40 deletions langcodec-cli/src/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,51 +122,36 @@ pub fn detect_custom_format(file_path: &str, file_content: &str) -> Option<Custo
.to_lowercase();

match extension.as_str() {
"langcodec" => {
// Try to parse as JSON array of Resource objects
if serde_json::from_str::<Vec<serde_json::Value>>(file_content).is_ok() {
// Check if it looks like an array of Resource objects
if let Ok(array) = serde_json::from_str::<Vec<serde_json::Value>>(file_content)
&& !array.is_empty()
{
// Check if the first element has the expected Resource structure
if let Some(first) = array.first()
&& let Some(obj) = first.as_object()
&& obj.contains_key("metadata")
&& obj.contains_key("entries")
{
return Some(CustomFormat::LangcodecResourceArray);
}
}
}
"langcodec" if is_langcodec_resource_array(file_content) => {
Some(CustomFormat::LangcodecResourceArray)
}
"json" => {
// Try to parse as JSON object first (JSONLanguageMap)
if serde_json::from_str::<serde_json::Value>(file_content).is_ok() {
// Check if it's an object (not an array)
if let Ok(obj) = serde_json::from_str::<
std::collections::HashMap<String, serde_json::Value>,
>(file_content)
&& !obj.is_empty()
{
return Some(CustomFormat::JSONLanguageMap);
}
// Check if it's an array (JSONArrayLanguageMap)
if serde_json::from_str::<Vec<serde_json::Value>>(file_content).is_ok() {
return Some(CustomFormat::JSONArrayLanguageMap);
}
}
"json" if is_json_language_map(file_content) => Some(CustomFormat::JSONLanguageMap),
"json" if serde_json::from_str::<Vec<serde_json::Value>>(file_content).is_ok() => {
Some(CustomFormat::JSONArrayLanguageMap)
}
"yaml" | "yml" => {
// Try to parse as YAML
if serde_yaml::from_str::<serde_yaml::Value>(file_content).is_ok() {
return Some(CustomFormat::YAMLLanguageMap);
}
"yaml" | "yml" if serde_yaml::from_str::<serde_yaml::Value>(file_content).is_ok() => {
Some(CustomFormat::YAMLLanguageMap)
}
_ => {}
_ => None,
}
}

fn is_langcodec_resource_array(file_content: &str) -> bool {
let Ok(array) = serde_json::from_str::<Vec<serde_json::Value>>(file_content) else {
return false;
};
let Some(first) = array.first() else {
return false;
};

first
.as_object()
.is_some_and(|obj| obj.contains_key("metadata") && obj.contains_key("entries"))
}

None
fn is_json_language_map(file_content: &str) -> bool {
serde_json::from_str::<std::collections::HashMap<String, serde_json::Value>>(file_content)
.is_ok_and(|obj| !obj.is_empty())
}

/// Validate custom format file content
Expand Down
90 changes: 89 additions & 1 deletion langcodec-cli/src/tolgee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};

use crate::config::{LoadedConfig, TolgeeConfig, load_config, resolve_config_relative_path};
use crate::config::{
LoadedConfig, TolgeeConfig, UserConfig, load_config, load_user_config,
resolve_config_relative_path,
};

const DEFAULT_TOLGEE_CONFIG: &str = ".tolgeerc.json";
const TOLGEE_FORMAT_APPLE_XCSTRINGS: &str = "APPLE_XCSTRINGS";
Expand Down Expand Up @@ -69,6 +72,7 @@ struct TolgeeProject {
config_path: PathBuf,
project_root: PathBuf,
raw: Value,
user_api_key: Option<String>,
pull_template: String,
mappings: Vec<TolgeeMappedFile>,
}
Expand Down Expand Up @@ -432,16 +436,60 @@ fn build_tolgee_project_from_raw(
.and_then(Value::as_str)
.unwrap_or(DEFAULT_PULL_TEMPLATE)
.to_string();
let user_api_key = resolve_tolgee_user_api_key(&raw)?;

Ok(TolgeeProject {
config_path,
project_root,
raw,
user_api_key,
pull_template,
mappings,
})
}

fn resolve_tolgee_user_api_key(raw: &Value) -> Result<Option<String>, String> {
if tolgee_has_project_api_key(raw) {
return Ok(None);
}

let Some(user_config) = load_user_config()? else {
return Ok(None);
};
Ok(resolve_tolgee_user_api_key_from_config(
raw,
Some(&user_config.data),
))
}

fn resolve_tolgee_user_api_key_from_config(
raw: &Value,
user_config: Option<&UserConfig>,
) -> Option<String> {
if tolgee_has_project_api_key(raw) {
return None;
}

user_config.and_then(|config| {
config
.tolgee
.api_key_for_project(tolgee_project_id(raw))
.map(str::to_string)
})
}

fn tolgee_has_project_api_key(raw: &Value) -> bool {
raw.get("apiKey").is_some()
}

fn tolgee_project_id(raw: &Value) -> Option<u64> {
raw.get("projectId").and_then(|value| {
value
.as_u64()
.or_else(|| value.as_str().and_then(|value| value.parse().ok()))
})
}

fn normalize_tolgee_raw(raw: &mut Value) {
let Some(push) = raw.get_mut("push").and_then(Value::as_object_mut) else {
return;
Expand Down Expand Up @@ -615,6 +663,10 @@ fn invoke_tolgee(
}
};

if let Some(api_key) = &project.user_api_key {
command.env("TOLGEE_API_KEY", api_key);
}

let output = command
.arg("--config")
.arg(&config_path)
Expand Down Expand Up @@ -982,6 +1034,7 @@ mod tests {
config_path: PathBuf::from("/tmp/.tolgeerc.json"),
project_root: PathBuf::from("/tmp"),
raw: json!({}),
user_api_key: None,
pull_template: "/{namespace}/{languageTag}.{extension}".to_string(),
mappings: Vec::new(),
};
Expand Down Expand Up @@ -1072,4 +1125,39 @@ file_structure_template = "/{namespace}/Localizable.{extension}"
assert_eq!(project.raw["push"]["languages"], json!(["en"]));
assert!(project.raw["push"].get("language").is_none());
}

#[test]
fn resolves_tolgee_credentials_by_precedence() {
let user_config: UserConfig = toml::from_str(
r#"
[tolgee]
api_key = "tgpak_user_global"

[tolgee.projects.36]
api_key = "tgpak_user_project"
"#,
)
.unwrap();

let project_key = resolve_tolgee_user_api_key_from_config(
&json!({
"projectId": 36,
"apiKey": "tgpak_project"
}),
Some(&user_config),
);
assert_eq!(project_key, None);

let user_project_key = resolve_tolgee_user_api_key_from_config(
&json!({ "projectId": 36 }),
Some(&user_config),
);
assert_eq!(user_project_key.as_deref(), Some("tgpak_user_project"));

let user_global_key = resolve_tolgee_user_api_key_from_config(
&json!({ "projectId": 37 }),
Some(&user_config),
);
assert_eq!(user_global_key.as_deref(), Some("tgpak_user_global"));
}
}
Loading
Loading