Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to using payload on POST method on calling the DeepL API for translation at /v2/translate endpoint #152

Merged
merged 4 commits into from
Feb 11, 2024
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
183 changes: 127 additions & 56 deletions src/sheetsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ const DEEPL_API_VERSION = 'v2'; // DeepL API version
const DEEPL_API_BASE_URL_FREE = `https://api-free.deepl.com/${DEEPL_API_VERSION}/`;
const DEEPL_API_BASE_URL_PRO = `https://api.deepl.com/${DEEPL_API_VERSION}/`;

// Threshold value of the length of the text to translate, in bytes. See https://developers.google.com/apps-script/guides/services/quotas#current_limitations
const THRESHOLD_BYTES = 1900;
const MAX_TEXT_NUM = 50; // Maximum number of texts to translate in a single request
// Threshold value of the length of the text to translate, in bytes.
// From the DeepL API Doc: "The total request body size must not exceed 128 KiB (128 · 1024 bytes)."
// See https://www.deepl.com/docs-api/translate-text
// The constant part of the request body is approx. 200 bytes, so we'll set the limit to 127 * 1028 bytes with a margin
const THRESHOLD_BYTES = 127 * 1028;

/**
* The JavaScript object of a DeepL-supported language.
Expand All @@ -35,6 +39,16 @@ export interface DeepLSupportedLanguages {
supports_formality: boolean;
}

/**
* The request payload to the DeepL API for POST /v2/translate.
* @see https://www.deepl.com/docs-api/translate-text/
*/
interface DeepLTranslationRequest {
text: (string | number)[];
target_lang: string;
source_lang?: string;
}

/**
* The response from the DeepL API for POST /v2/translate.
* @see https://www.deepl.com/docs-api/translate-text/
Expand Down Expand Up @@ -81,7 +95,7 @@ function onOpen(): void {
.addItem('Set Language', 'setLanguage'),
)
.addSeparator()
.addItem('Translate', 'translateRange')
.addItem('Translate', 'translateSelectedRange')
.addToUi();
}

Expand Down Expand Up @@ -255,18 +269,20 @@ export function setLanguage(): void {
* Translate the selected cell range using DeepL API
* and paste the result in the adjacent range.
*/
export function translateRange(): void {
export function translateSelectedRange(): void {
const ui = SpreadsheetApp.getUi();
const activeSheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
const selectedRange = activeSheet.getActiveRange();
const userProperties = PropertiesService.getUserProperties().getProperties();
try {
if (!userProperties[UP_KEY_TARGET_LOCALE]) {
// If the target language is not set, throw an error
throw new Error(
`[${ADDON_NAME}] Target Language Unavailable: Set the target language in Settings > Set Language of the add-on menu.`,
);
}
if (!selectedRange) {
// If no cell is selected, throw an error
throw new Error(`[${ADDON_NAME}] Select cells to translate.`);
}
// Check target range, i.e., the range where translated texts will be placed
Expand All @@ -278,6 +294,7 @@ export function translateRange(): void {
selectedRangeNumCol,
);
if (!targetRange.isBlank()) {
// If the target range is not empty, ask the user whether to proceed and overwrite the contents
const alertOverwrite = ui.alert(
'Translated text(s) will be pasted in the cell(s) to the right of the currently selected range. This target area is not empty.\nContinuing this process will overwrite the contents.\n\nAre you sure you want to continue?',
ui.ButtonSet.OK_CANCEL,
Expand All @@ -289,38 +306,15 @@ export function translateRange(): void {

// Get the source text
const sourceTextArr = selectedRange.getValues();
// console.log(`sourceTextArr: ${JSON.stringify(sourceTextArr)}`);

const translatedText = sourceTextArr.map((row) =>
row.map((cellValue: string | number | boolean) => {
if (cellValue === '') {
return '';
}
const textBytes = getBlobBytes(encodeURIComponent(cellValue));
if (textBytes > THRESHOLD_BYTES) {
const cellValueString = cellValue.toString();
const truncatedCellValue = cellValueString.slice(
0,
Math.floor((cellValueString.length * THRESHOLD_BYTES) / textBytes),
);
throw new Error(
`[${ADDON_NAME}] Cell content length exceeds Google's limits. Please consider splitting the content into multiple cells. The following is the estimated maximum length of the cell in question:\n${truncatedCellValue}\n\nPlease note that this is a rough estimate and that the actual acceptable text length might differ slightly.`,
);
} else {
Utilities.sleep(1000); // Interval to avoid concentrated access to API
// Cell-based translation
return deepLTranslate(
cellValue.toString(),
userProperties[UP_KEY_SOURCE_LOCALE],
userProperties[UP_KEY_TARGET_LOCALE],
)[0];
}
}),
);
// console.log(`translatedText: ${JSON.stringify(translatedText)}`);

// Set translated text in target range
targetRange.setValues(translatedText);
targetRange.setValues(
translateRange(
sourceTextArr as (string | number)[][],
userProperties[UP_KEY_TARGET_LOCALE],
userProperties[UP_KEY_SOURCE_LOCALE],
),
);
// Complete
ui.alert('Complete: Translation has been completed.');
} catch (error) {
Expand All @@ -329,50 +323,128 @@ export function translateRange(): void {
}
}

/**
* Translate the given 2-dimension array of texts using DeepL API
* and return the translated texts in the same format.
* @param sourceTextArr 2-dimension array of texts to translate
* @returns 2-dimension array of translated texts
* @see https://www.deepl.com/docs-api/translate-text
*/
export function translateRange(
sourceTextArr: (string | number)[][],
targetLocale: string,
sourceLocale?: string,
): string[][] {
const columnNumber = sourceTextArr[0].length;
const translatedRangeFlat = splitLongArray(
// Split the array into multiple arrays if the total length of the array exceeds the given maximum length
// or if the total length of the stringified array in bytes exceeds the given maximum bytes
sourceTextArr.flat(),
MAX_TEXT_NUM,
THRESHOLD_BYTES,
)
.map((arr) => deepLTranslate(arr, targetLocale, sourceLocale))
.flat();
return translatedRangeFlat.reduce((acc, _, i, arr) => {
if (i % columnNumber === 0) {
acc.push(arr.slice(i, i + columnNumber));
}
return acc;
}, [] as string[][]);
}

/**
* Split the given array into multiple arrays
* if the total length of the array exceeds the given maximum length
* or if the total length of the stringified array in bytes exceeds the given maximum bytes.
* Execute this function recursively until the given array is within the given limits.
* @param originalArray The original array to split
* @param maxLength The maximum length of the array
* @param maxBytes The maximum length of the stringified array in bytes
* @returns An array of arrays. If the original array is within the given limits, the array will contain the original array.
*/
export function splitLongArray<T>(
originalArray: T[],
maxLength: number,
maxBytes: number,
): T[][] {
const returnArray: T[][] = [];
if (
originalArray.length <= maxLength &&
getBlobBytes(JSON.stringify(originalArray)) <= maxBytes
) {
returnArray.push(originalArray);
} else {
const halfLength = Math.floor(originalArray.length / 2);
const firstHalf = originalArray.slice(0, halfLength);
const secondHalf = originalArray.slice(halfLength);
[firstHalf, secondHalf].forEach((arr) => {
if (arr.length === 1 && getBlobBytes(JSON.stringify(arr)) > maxBytes) {
throw new Error(
`[${ADDON_NAME}] The following cell value exceeds the maximum length of the text to translate. Please consider splitting the content into multiple cells.:\n${String(arr[0])}`,
);
}
if (
arr.length <= maxLength &&
getBlobBytes(JSON.stringify(arr)) <= maxBytes
) {
returnArray.push(arr);
} else {
returnArray.push(...splitLongArray(arr, maxLength, maxBytes));
}
});
}
return returnArray;
}

/**
* Call the DeepL API on the `translate` endpoint
* @param sourceText Array of texts to translate
* @param sourceLocale The language of the source text
* @param targetLocale The language to be translated into
* @param sourceLocale The language of the source text
* @returns Array of translated texts.
* @see https://www.deepl.com/docs-api/translate-text/
*/
export function deepLTranslate(
sourceText: string | string[] | null | undefined,
sourceLocale: string | null | undefined,
sourceText: string | number | (string | number)[] | null | undefined,
targetLocale: string,
sourceLocale?: string | null,
): string[] {
const endpoint = 'translate';
let sourceTextCasted: string;
if (!sourceText || sourceText.length === 0) {
let sourceTexts: (string | number)[];
if (
!sourceText ||
(typeof sourceText === 'string' && sourceText.length === 0)
) {
throw new Error(`[${ADDON_NAME}] Empty input.`);
}
if (Array.isArray(sourceText)) {
sourceTextCasted = sourceText
.map((text) => `text=${encodeURIComponent(text)}`)
.join('&');
} else if (Array.isArray(sourceText)) {
sourceTexts = sourceText;
} else {
sourceTextCasted = `text=${encodeURIComponent(sourceText)}`;
sourceTexts = [sourceText];
}
// console.log(`sourceTextCasted: ${sourceTextCasted}`);

// API key
const apiKey = getDeepLApiKey();
const baseUrl = getDeepLApiBaseUrl(apiKey);
// Call the DeepL API
let url =
baseUrl +
endpoint +
`?auth_key=${apiKey}&target_lang=${targetLocale}&${sourceTextCasted}`;
const url = baseUrl + endpoint;
const payload: DeepLTranslationRequest = {
text: sourceTexts,
target_lang: targetLocale,
};
if (sourceLocale) {
url += `&source_lang=${sourceLocale}`;
payload.source_lang = sourceLocale;
}
// console.log(`url: ${url}`);
const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
method: 'post',
contentType: 'application/json',
headers: { Authorization: `DeepL-Auth-Key ${apiKey}` },
payload: JSON.stringify(payload),
muteHttpExceptions: true,
};

// Call the DeepL API translate request
const response = handleDeepLErrors(
UrlFetchApp.fetch(url, { muteHttpExceptions: true }),
);
const response = handleDeepLErrors(UrlFetchApp.fetch(url, options));

const translatedTextObj = JSON.parse(
response.getContentText(),
Expand All @@ -381,7 +453,6 @@ export function deepLTranslate(
(translationsResponse: DeepLTranslationObj): string =>
translationsResponse.text,
);
// console.log(`translatedText: ${JSON.stringify(translatedText)}`);

return translatedText;
}
Expand Down
10 changes: 5 additions & 5 deletions tests/deepLTranslate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,22 @@ describe('deepLTranslate', () => {
const mockSourceObjects = [
{
note: 'with source language specified',
sourceLang: 'EN-US',
targetLang: 'DE',
sourceLang: 'EN-US',
sourceText: 'Hello, World!',
translatedText: 'Hallo, Welt!',
},
{
note: 'without source language specified',
sourceLang: null,
targetLang: 'DE',
sourceLang: null,
sourceText: 'Hello, World!',
translatedText: 'Hallo, Welt!',
},
{
note: 'in an array of strings',
sourceLang: null,
targetLang: 'DE',
sourceLang: null,
sourceText: ['Hello, World!', 'Hello, World!'],
translatedText: ['Hallo, Welt!', 'Hallo, Welt!'],
},
Expand All @@ -53,7 +53,7 @@ describe('deepLTranslate', () => {
getResponseCode: jest.fn(() => 200),
})),
} as unknown as GoogleAppsScript.URL_Fetch.UrlFetchApp;
const translated = deepLTranslate(sourceText, sourceLang, targetLang);
const translated = deepLTranslate(sourceText, targetLang, sourceLang);
expect(translated).toStrictEqual(
Array.isArray(translatedText) ? translatedText : [translatedText],
);
Expand All @@ -78,7 +78,7 @@ describe('deepLTranslate', () => {
'should throw an error $note',
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ note, sourceLang, targetLang, sourceText }) => {
expect(() => deepLTranslate(sourceText, sourceLang, targetLang)).toThrow(
expect(() => deepLTranslate(sourceText, targetLang, sourceLang)).toThrow(
new Error('[SheetsL] Empty input.'),
);
},
Expand Down
Loading