rpfm_extensions/translator/
hub.rs1use getset::Getters;
18use serde_derive::{Deserialize, Serialize};
19
20use std::thread;
21use std::time::{Duration, Instant};
22
23use rpfm_lib::error::{RLibError, Result};
24use rpfm_lib::integrations::github::{GitHubClient, NewPullRequest, TreeChange};
25
26use super::{DEFAULT_SRC_LANG, PackTranslation};
27
28const FORK_TIMEOUT: Duration = Duration::from_secs(60);
30
31const FORK_POLL_INTERVAL: Duration = Duration::from_secs(2);
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Getters, Serialize, Deserialize)]
40#[getset(get = "pub")]
41pub struct TranslationStats {
42
43 total: usize,
45
46 translated: usize,
48
49 pending: usize,
51
52 auto_translated: usize,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Getters)]
58#[getset(get = "pub")]
59pub struct HubSubmission {
60
61 file_path: String,
63
64 replaced_path: Option<String>,
66
67 branch: String,
69
70 commit_message: String,
72
73 title: String,
75
76 body: String,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Getters, Serialize, Deserialize)]
82#[getset(get = "pub")]
83pub struct SubmissionResult {
84
85 url: String,
87
88 created: bool,
90}
91
92impl PackTranslation {
97
98 pub fn relative_path(&self, game_key: &str) -> String {
108 format!("{}/{}/{}", game_key, self.pack_name, self.file_name())
109 }
110
111 pub fn stats(&self) -> TranslationStats {
113 self.translations.values()
114 .filter(|tr| !tr.rem)
115 .fold(TranslationStats::default(), |mut stats, tr| {
116 stats.total += 1;
117 if tr.retr {
118 stats.pending += 1;
119 } else {
120 stats.translated += 1;
121 if tr.aut {
122 stats.auto_translated += 1;
123 }
124 }
125
126 stats
127 })
128 }
129
130 pub fn hub_submission(&self, game_key: &str) -> HubSubmission {
140 let folder = format!("{}/{}", game_key, self.pack_name);
141
142 let replaced_path = if self.src_lang.eq_ignore_ascii_case(DEFAULT_SRC_LANG) {
144 let other_file_name = if self.version == 0 {
145 format!("{}-{}.json", self.src_lang, self.language)
146 } else {
147 format!("{}.json", self.language)
148 };
149
150 Some(format!("{folder}/{other_file_name}"))
151 } else {
152 None
153 };
154
155 let branch = ["rpfm", game_key, &self.pack_name, &format!("{}-{}", self.src_lang, self.language)]
156 .iter()
157 .map(|segment| branch_segment(segment))
158 .collect::<Vec<_>>()
159 .join("/");
160
161 let languages = format!("{} -> {}", self.src_lang, self.language);
162 let stats = self.stats();
163 let authors = if self.authors.is_empty() {
164 "Not specified".to_owned()
165 } else {
166 self.authors.join(", ")
167 };
168
169 let body = format!("Translation submitted from RPFM's Translator.
170
171- Game: `{game_key}`
172- Pack: `{pack}`
173- Languages: `{languages}`
174- Format version: `{version}`
175- Authors: {authors}
176- Lines: {translated} of {total} translated, {pending} pending, {auto} of the translated ones auto-translated and not reviewed.
177",
178 pack = self.pack_name,
179 version = self.version,
180 translated = stats.translated,
181 total = stats.total,
182 pending = stats.pending,
183 auto = stats.auto_translated,
184 );
185
186 HubSubmission {
187 file_path: self.relative_path(game_key),
188 replaced_path,
189 branch,
190 commit_message: format!("Update {} translation ({languages}) [{game_key}]", self.pack_name),
191 title: format!("[{game_key}] Translation for {} ({languages})", self.pack_name),
192 body,
193 }
194 }
195
196 pub fn submit_to_hub(&self, client: &GitHubClient, hub_owner: &str, hub_name: &str, game_key: &str, content: &str) -> Result<SubmissionResult> {
218 let submission = self.hub_submission(game_key);
219 let hub = client.repository(hub_owner, hub_name)?
220 .ok_or_else(|| RLibError::GitHubRepositoryNotFound(format!("{hub_owner}/{hub_name}")))?;
221 let base_branch = hub.default_branch();
222
223 let (repo_owner, repo_name) = if *hub.can_push() {
224 (hub_owner.to_owned(), hub_name.to_owned())
225 } else {
226 let fork = client.fork(hub_owner, hub_name)?;
227 wait_for_fork(client, fork.owner(), fork.name(), fork.default_branch())?;
228
229 let _ = client.merge_upstream(fork.owner(), fork.name(), fork.default_branch());
232 (fork.owner().to_owned(), fork.name().to_owned())
233 };
234
235 let base_commit = client.branch_head(hub_owner, hub_name, base_branch)?
236 .ok_or_else(|| RLibError::GitHubRepositoryNotFound(format!("{hub_owner}/{hub_name}:{base_branch}")))?;
237 let base_tree = client.commit_tree(hub_owner, hub_name, &base_commit)?;
238
239 let blob = client.create_blob(&repo_owner, &repo_name, content)?;
240 let mut changes = vec![TreeChange { path: submission.file_path().to_owned(), blob: Some(blob) }];
241
242 if let Some(replaced_path) = submission.replaced_path() {
244 if let Some((folder, file_name)) = replaced_path.rsplit_once('/') {
245 if client.folder_entries(hub_owner, hub_name, folder, &base_commit)?.iter().any(|entry| entry == file_name) {
246 changes.push(TreeChange { path: replaced_path.to_owned(), blob: None });
247 }
248 }
249 }
250
251 let tree = client.create_tree(&repo_owner, &repo_name, &base_tree, &changes)?;
252 let commit = client.create_commit(&repo_owner, &repo_name, submission.commit_message(), &tree, &base_commit)?;
253 client.set_branch(&repo_owner, &repo_name, submission.branch(), &commit)?;
254
255 let head = format!("{repo_owner}:{}", submission.branch());
256 if let Some(pull) = client.open_pull_request(hub_owner, hub_name, &head, base_branch)? {
257 return Ok(SubmissionResult { url: pull.html_url().to_owned(), created: false });
258 }
259
260 let pull = client.create_pull_request(hub_owner, hub_name, &NewPullRequest {
261 title: submission.title().to_owned(),
262 body: submission.body().to_owned(),
263 head,
264 base: base_branch.to_owned(),
265 })?;
266
267 Ok(SubmissionResult { url: pull.html_url().to_owned(), created: true })
268 }
269}
270
271fn wait_for_fork(client: &GitHubClient, owner: &str, name: &str, branch: &str) -> Result<()> {
273 let deadline = Instant::now() + FORK_TIMEOUT;
274 while client.branch_head(owner, name, branch)?.is_none() {
275 if Instant::now() >= deadline {
276 return Err(RLibError::GitHubForkNotReady(format!("{owner}/{name}")));
277 }
278
279 thread::sleep(FORK_POLL_INTERVAL);
280 }
281
282 Ok(())
283}
284
285fn branch_segment(text: &str) -> String {
290 let mut segment = String::with_capacity(text.len());
291 for character in text.chars() {
292 let character = if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { character } else { '-' };
293 let previous = segment.chars().last();
294
295 if (character == '-' && previous == Some('-')) || (character == '.' && previous == Some('.')) {
297 continue;
298 }
299
300 segment.push(character);
301 }
302
303 let mut segment = segment.trim_matches(|character| character == '-' || character == '.').to_owned();
304 if segment.ends_with(".lock") {
305 segment.push('_');
306 }
307
308 if segment.is_empty() {
309 segment.push('_');
310 }
311
312 segment
313}