deepl_rustls/endpoint/
document.rs

1use super::{Pollable, Result};
2use crate::{impl_requester, Formality, Lang};
3use serde::{Deserialize, Serialize};
4use std::{
5    future::IntoFuture,
6    path::{Path, PathBuf},
7};
8use tokio::io::AsyncWriteExt;
9use tokio_stream::StreamExt;
10
11/// Response from api/v2/document
12#[derive(Serialize, Deserialize)]
13pub struct UploadDocumentResp {
14    /// A unique ID assigned to the uploaded document and the translation process.
15    /// Must be used when referring to this particular document in subsequent API requests.
16    pub document_id: String,
17    /// A unique key that is used to encrypt the uploaded document as well as the resulting
18    /// translation on the server side. Must be provided with every subsequent API request
19    /// regarding this particular document.
20    pub document_key: String,
21}
22
23/// Response from api/v2/document/$ID
24#[derive(Deserialize, Debug)]
25pub struct DocumentStatusResp {
26    /// A unique ID assigned to the uploaded document and the requested translation process.
27    /// The same ID that was used when requesting the translation status.
28    pub document_id: String,
29    /// A short description of the state the document translation process is currently in.
30    /// See [`DocumentTranslateStatus`] for more.
31    pub status: DocumentTranslateStatus,
32    /// Estimated number of seconds until the translation is done.
33    /// This parameter is only included while status is "translating".
34    pub seconds_remaining: Option<u64>,
35    /// The number of characters billed to your account.
36    pub billed_characters: Option<u64>,
37    /// A short description of the error, if available. Note that the content is subject to change.
38    /// This parameter may be included if an error occurred during translation.
39    pub error_message: Option<String>,
40}
41
42/// Possible value of the document translate status
43#[derive(Debug, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "lowercase")]
45pub enum DocumentTranslateStatus {
46    /// The translation job is waiting in line to be processed
47    Queued,
48    /// The translation is currently ongoing
49    Translating,
50    /// The translation is done and the translated document is ready for download
51    Done,
52    /// An irrecoverable error occurred while translating the document
53    Error,
54}
55
56impl DocumentTranslateStatus {
57    pub fn is_done(&self) -> bool {
58        self == &Self::Done
59    }
60}
61
62impl_requester! {
63    UploadDocumentRequester {
64        @required{
65            file_path: PathBuf,
66            target_lang: Lang,
67        };
68        @optional{
69            source_lang: Lang,
70            filename: String,
71            formality: Formality,
72            glossary_id: String,
73        };
74    } -> Result<UploadDocumentResp, Error>;
75}
76
77impl<'a> UploadDocumentRequester<'a> {
78    fn to_multipart_form(&self) -> reqwest::multipart::Form {
79        let Self {
80            source_lang,
81            target_lang,
82            formality,
83            glossary_id,
84            ..
85        } = self;
86
87        let mut form = reqwest::multipart::Form::new();
88
89        // SET source_lang
90        if let Some(lang) = source_lang {
91            form = form.text("source_lang", lang.to_string());
92        }
93
94        // SET target_lang
95        form = form.text("target_lang", target_lang.to_string());
96
97        // SET formality
98        if let Some(formal) = formality {
99            form = form.text("formality", formal.to_string());
100        }
101
102        // SET glossary
103        if let Some(id) = glossary_id {
104            form = form.text("glossary_id", id.to_string());
105        }
106
107        form
108    }
109
110    fn send(&self) -> Pollable<'a, Result<UploadDocumentResp>> {
111        let mut form = self.to_multipart_form();
112        let client = self.client.clone();
113        let filename = self.filename.clone();
114        let file_path = self.file_path.clone();
115
116        let fut = async move {
117            // SET file && filename asynchronously
118            let file = tokio::fs::read(&file_path).await.map_err(|err| {
119                Error::ReadFileError(file_path.to_str().unwrap().to_string(), err)
120            })?;
121
122            let mut part = reqwest::multipart::Part::bytes(file);
123            if let Some(filename) = filename {
124                part = part.file_name(filename.to_string());
125                form = form.text("filename", filename);
126            } else {
127                part = part.file_name(file_path.file_name().expect(
128                    "No extension found for this file, and no filename given, cannot make request",
129                ).to_str().expect("not a valid UTF-8 filepath!").to_string());
130            }
131
132            form = form.part("file", part);
133
134            let res = client
135                .post(client.get_endpoint("document"))
136                .multipart(form)
137                .send()
138                .await
139                .map_err(|err| Error::RequestFail(format!("fail to upload file: {err}")))?;
140
141            if !res.status().is_success() {
142                return super::extract_deepl_error(res).await;
143            }
144
145            let res: UploadDocumentResp = res.json().await.map_err(|err| {
146                Error::InvalidResponse(format!("fail to decode response body: {err}"))
147            })?;
148            Ok(res)
149        };
150
151        Box::pin(fut)
152    }
153}
154
155impl<'a> IntoFuture for UploadDocumentRequester<'a> {
156    type Output = Result<UploadDocumentResp>;
157    type IntoFuture = Pollable<'a, Self::Output>;
158
159    fn into_future(self) -> Self::IntoFuture {
160        self.send()
161    }
162}
163
164impl<'a> IntoFuture for &mut UploadDocumentRequester<'a> {
165    type Output = Result<UploadDocumentResp>;
166    type IntoFuture = Pollable<'a, Self::Output>;
167
168    fn into_future(self) -> Self::IntoFuture {
169        self.send()
170    }
171}
172
173impl DeepLApi {
174    /// Upload document to DeepL API server, return [`UploadDocumentResp`] for
175    /// querying the translation status and to download the translated document once
176    /// translation is complete.
177    ///
178    /// # Example
179    ///
180    /// ```rust
181    /// use deepl::DeepLApi;
182    ///
183    /// let key = std::env::var("DEEPL_API_KEY").unwrap();
184    /// let deepl = DeepLApi::with(&key).new();
185    ///
186    /// // Upload the file to DeepL
187    /// let filepath = std::path::PathBuf::from("./hamlet.txt");
188    /// let response = deepl.upload_document(&filepath, Lang::ZH)
189    ///         .source_lang(Lang::EN)
190    ///         .filename("Hamlet.txt".to_string())
191    ///         .formality(Formality::Default)
192    ///         .glossary_id("def3a26b-3e84-45b3-84ae-0c0aaf3525f7".to_string())
193    ///         .await
194    ///         .unwrap();
195    /// ```
196    ///
197    /// Read the example `upload_document` in repository for detailed usage
198    pub fn upload_document(
199        &self,
200        fp: impl Into<std::path::PathBuf>,
201        target_lang: Lang,
202    ) -> UploadDocumentRequester {
203        UploadDocumentRequester::new(self, fp.into(), target_lang)
204    }
205
206    async fn open_file_to_write(p: &Path) -> Result<tokio::fs::File> {
207        let open_result = tokio::fs::OpenOptions::new()
208            .append(true)
209            .create_new(true)
210            .open(p)
211            .await;
212
213        if let Ok(file) = open_result {
214            return Ok(file);
215        }
216
217        let err = open_result.unwrap_err();
218        if err.kind() != std::io::ErrorKind::AlreadyExists {
219            return Err(Error::WriteFileError(format!(
220                "Fail to open file {p:?}: {err}"
221            )));
222        }
223
224        tokio::fs::remove_file(p).await.map_err(|err| {
225            Error::WriteFileError(format!(
226                "There was already a file there and it is not deletable: {err}"
227            ))
228        })?;
229        dbg!("Detect exist, removed");
230
231        let open_result = tokio::fs::OpenOptions::new()
232            .append(true)
233            .create_new(true)
234            .open(p)
235            .await;
236
237        if let Err(err) = open_result {
238            return Err(Error::WriteFileError(format!(
239                "Fail to open file for download document, even after retry: {err}"
240            )));
241        }
242
243        Ok(open_result.unwrap())
244    }
245
246    /// Check the status of document, returning [`DocumentStatusResp`] if success.
247    pub async fn check_document_status(
248        &self,
249        ident: &UploadDocumentResp,
250    ) -> Result<DocumentStatusResp> {
251        let form = [("document_key", ident.document_key.as_str())];
252        let url = self.get_endpoint(&format!("document/{}", ident.document_id));
253        let res = self
254            .post(url)
255            .form(&form)
256            .send()
257            .await
258            .map_err(|err| Error::RequestFail(err.to_string()))?;
259
260        if !res.status().is_success() {
261            return super::extract_deepl_error(res).await;
262        }
263
264        let status: DocumentStatusResp = res
265            .json()
266            .await
267            .map_err(|err| Error::InvalidResponse(format!("response is not JSON: {err}")))?;
268
269        Ok(status)
270    }
271
272    /// Download the possibly translated document. Downloaded document will store to the given
273    /// `output` path.
274    ///
275    /// Return downloaded file's path if success
276    pub async fn download_document<O: AsRef<Path>>(
277        &self,
278        ident: &UploadDocumentResp,
279        output: O,
280    ) -> Result<PathBuf> {
281        let url = self.get_endpoint(&format!("document/{}/result", ident.document_id));
282        let form = [("document_key", ident.document_key.as_str())];
283        let res = self
284            .post(url)
285            .form(&form)
286            .send()
287            .await
288            .map_err(|err| Error::RequestFail(err.to_string()))?;
289
290        if res.status() == reqwest::StatusCode::NOT_FOUND {
291            return Err(Error::NonExistDocument);
292        }
293
294        if res.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE {
295            return Err(Error::TranslationNotDone);
296        }
297
298        if !res.status().is_success() {
299            return super::extract_deepl_error(res).await;
300        }
301
302        let mut file = Self::open_file_to_write(output.as_ref()).await?;
303
304        let mut stream = res.bytes_stream();
305
306        #[inline]
307        fn mapper<E: std::error::Error>(s: &'static str) -> Box<dyn FnOnce(E) -> Error> {
308            Box::new(move |err: E| Error::WriteFileError(format!("{s}: {err}")))
309        }
310
311        while let Some(chunk) = stream.next().await {
312            let chunk = chunk.map_err(mapper("fail to download part of the document"))?;
313            file.write_all(&chunk)
314                .await
315                .map_err(mapper("fail to write downloaded part into file"))?;
316            file.sync_all()
317                .await
318                .map_err(mapper("fail to sync file content"))?;
319        }
320
321        Ok(output.as_ref().to_path_buf())
322    }
323}
324
325#[tokio::test]
326async fn test_upload_document() {
327    let key = std::env::var("DEEPL_API_KEY").unwrap();
328    let api = DeepLApi::with(&key).new();
329
330    let raw_text = "Hello World";
331
332    tokio::fs::write("./test.txt", &raw_text).await.unwrap();
333
334    let test_file = PathBuf::from("./test.txt");
335    let response = api.upload_document(&test_file, Lang::DE).await.unwrap();
336    let mut status = api.check_document_status(&response).await.unwrap();
337
338    // wait for translation
339    loop {
340        if status.status.is_done() {
341            break;
342        }
343        if let Some(msg) = status.error_message {
344            println!("{}", msg);
345            break;
346        }
347        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
348        status = api.check_document_status(&response).await.unwrap();
349        dbg!(&status);
350    }
351
352    let path = api
353        .download_document(&response, "test_translated.txt")
354        .await
355        .unwrap();
356
357    let content = tokio::fs::read_to_string(path).await.unwrap();
358    let expect = "Hallo Welt";
359    assert_eq!(content, expect);
360}
361
362#[tokio::test]
363async fn test_upload_docx() {
364    use docx_rs::{read_docx, DocumentChild, Docx, Paragraph, ParagraphChild, Run, RunChild};
365
366    let key = std::env::var("DEEPL_API_KEY").unwrap();
367    let api = DeepLApi::with(&key).new();
368
369    let test_file = PathBuf::from("./example.docx");
370    let file = std::fs::File::create(&test_file).expect("fail to create test asserts");
371    Docx::new()
372        .add_paragraph(
373            Paragraph::new()
374                .add_run(Run::new().add_text("To be, or not to be, that is the question")),
375        )
376        .build()
377        .pack(file)
378        .expect("fail to write test asserts");
379
380    let response = api.upload_document(&test_file, Lang::DE).await.unwrap();
381    let mut status = api.check_document_status(&response).await.unwrap();
382
383    // wait for translation
384    loop {
385        if status.status.is_done() {
386            break;
387        }
388        if let Some(msg) = status.error_message {
389            println!("{}", msg);
390            break;
391        }
392        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
393        status = api.check_document_status(&response).await.unwrap();
394        dbg!(&status);
395    }
396
397    let path = api
398        .download_document(&response, "translated.docx")
399        .await
400        .unwrap();
401    let get = tokio::fs::read(&path).await.unwrap();
402    let doc = read_docx(&get).expect("can not open downloaded document");
403    // collect all the text in this docx file
404    let text = doc
405        .document
406        .children
407        .iter()
408        .filter_map(|child| {
409            if let DocumentChild::Paragraph(paragraph) = child {
410                let text = paragraph
411                    .children
412                    .iter()
413                    .filter_map(|pchild| {
414                        if let ParagraphChild::Run(run) = pchild {
415                            let text = run
416                                .children
417                                .iter()
418                                .filter_map(|rchild| {
419                                    if let RunChild::Text(text) = rchild {
420                                        Some(text.text.to_string())
421                                    } else {
422                                        None
423                                    }
424                                })
425                                .collect::<String>();
426
427                            Some(text)
428                        } else {
429                            None
430                        }
431                    })
432                    .collect::<String>();
433                Some(text)
434            } else {
435                None
436            }
437        })
438        .collect::<String>();
439
440    assert_eq!(text, "Sein oder nicht sein, das ist hier die Frage");
441}