Skip to main content

rpfm_extensions/translator/
hub.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Submission of translations to the Translation Hub.
12//!
13//! Builds, from a saved [`PackTranslation`], everything needed to submit it to the hub as a pull
14//! request: where its file goes, which outdated file it replaces, the branch it's pushed to, and the
15//! texts of the commit and the pull request. Then submits it through a [`GitHubClient`].
16
17use 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
28/// How long to wait for a newly created fork to become usable.
29const FORK_TIMEOUT: Duration = Duration::from_secs(60);
30
31/// Time between checks for a newly created fork.
32const FORK_POLL_INTERVAL: Duration = Duration::from_secs(2);
33
34//-------------------------------------------------------------------------------//
35//                              Enums & Structs
36//-------------------------------------------------------------------------------//
37
38/// Line counts of a translation, ignoring lines removed from the pack.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Getters, Serialize, Deserialize)]
40#[getset(get = "pub")]
41pub struct TranslationStats {
42
43    /// Lines in the pack.
44    total: usize,
45
46    /// Lines with an up-to-date translation.
47    translated: usize,
48
49    /// Lines that still need translating, because they're new or their source text changed.
50    pending: usize,
51
52    /// Up-to-date lines translated automatically and not reviewed yet.
53    auto_translated: usize,
54}
55
56/// Everything needed to submit a translation to the Translation Hub as a pull request.
57#[derive(Clone, Debug, PartialEq, Eq, Getters)]
58#[getset(get = "pub")]
59pub struct HubSubmission {
60
61    /// Path of the translation's file in the hub.
62    file_path: String,
63
64    /// Path of the same translation in the other format version, to delete from the hub if it's there.
65    replaced_path: Option<String>,
66
67    /// Branch the submission is pushed to. It's the same for every submission of the same translation.
68    branch: String,
69
70    /// Message of the submission's commit.
71    commit_message: String,
72
73    /// Title of the pull request.
74    title: String,
75
76    /// Description of the pull request, in Markdown.
77    body: String,
78}
79
80/// Result of submitting a translation to the Translation Hub.
81#[derive(Clone, Debug, PartialEq, Eq, Getters, Serialize, Deserialize)]
82#[getset(get = "pub")]
83pub struct SubmissionResult {
84
85    /// Web page of the pull request.
86    url: String,
87
88    /// Whether a new pull request was opened. `false` means an open one was updated.
89    created: bool,
90}
91
92//-------------------------------------------------------------------------------//
93//                             Implementations
94//-------------------------------------------------------------------------------//
95
96impl PackTranslation {
97
98    /// Path of this translation's file, relative to a translations folder or the hub's root.
99    ///
100    /// # Arguments
101    ///
102    /// * `game_key` - Key of the game the translation belongs to.
103    ///
104    /// # Returns
105    ///
106    /// `{game_key}/{pack_name}/{file_name}`, with `/` separators so it's also valid as a repository path.
107    pub fn relative_path(&self, game_key: &str) -> String {
108        format!("{}/{}/{}", game_key, self.pack_name, self.file_name())
109    }
110
111    /// Line counts of this translation, ignoring lines removed from the pack.
112    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    /// Build the submission of this translation to the Translation Hub.
131    ///
132    /// # Arguments
133    ///
134    /// * `game_key` - Key of the game the translation belongs to.
135    ///
136    /// # Returns
137    ///
138    /// The paths, branch and texts of the submission.
139    pub fn hub_submission(&self, game_key: &str) -> HubSubmission {
140        let folder = format!("{}/{}", game_key, self.pack_name);
141
142        // Mirrors `save`: only EN-sourced translations have files in both formats, and `{language}.json` is always the EN one.
143        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    /// Submit this translation to the Translation Hub as a pull request, or update its open one.
197    ///
198    /// The commit goes on top of the hub's current default branch, in the hub itself if the user can push
199    /// to it, or in the user's fork otherwise (created if needed). Each submission replaces the previous one
200    /// in the translation's branch, so an open pull request always shows a single commit.
201    ///
202    /// # Arguments
203    ///
204    /// * `client` - GitHub client, signed in as the submitting user.
205    /// * `hub_owner` - Owner of the Translation Hub repository.
206    /// * `hub_name` - Name of the Translation Hub repository.
207    /// * `game_key` - Key of the game the translation belongs to.
208    /// * `content` - Contents of the translation's file, as saved on disk.
209    ///
210    /// # Returns
211    ///
212    /// The pull request's page, and whether it was newly opened.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the hub can't be found, the fork isn't ready in time, or any GitHub request fails.
217    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            // A fork whose branch has its own changes can't be synced. That's fine: forks share
230            // objects with their upstream, so the commit can still be based on the hub's head.
231            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        // Deleting a file that isn't in the tree fails, so only delete the replaced file if the hub has it.
243        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
271/// Wait until a fork is usable. GitHub creates forks in the background, so a new one may take a moment.
272fn 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
285/// Turn a text into a valid segment of a git branch name.
286///
287/// Characters other than ASCII letters, digits, `.`, `_` and `-` become `-`, and the result avoids the
288/// sequences git forbids in reference names: `..`, a leading or trailing `.`, and a trailing `.lock`.
289fn 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        // Collapse runs of dashes, and break up `..` so it can't appear.
296        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}