Skip to content
Open
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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.0] - 2026-03-17
### Added
* **Sidebar UI for translation**. Select cells, set options, and click
**Translate** — results are written as static values and never
re-translated when the sheet is reopened.
* Translation options: source/target language, formality
(Default / Formal / Informal), context hint, and glossary ID. All
options are saved and restored automatically between sessions.
* API key management in the sidebar: enter, validate, and clear the key
without opening Apps Script settings.
* Usage bar showing character consumption for the current billing period,
updated after each translation. Billed characters for the current
translation are shown after translation is complete.

### Changed
* The `DeepLTranslate()` and `DeepLUsage()` functions are maintained for
backwards compatibility, but disabled by default, see
`FORMULA_FUNCTIONS.md` for instructions.

## [0.2.0] - 2025-09-01
### Changed
* Renamed `freeze` variable to `disableTranslations`.
Expand All @@ -20,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Initial release.


[Unreleased]: https://github.com/DeepLcom/google-sheets-example/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/DeepLcom/google-sheets-example/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/DeepLcom/google-sheets-example/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/DeepLcom/google-sheets-example/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/DeepLcom/google-sheets-example/releases/tag/v0.1.0
236 changes: 229 additions & 7 deletions DeepL.gs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

/* Set to true to enable the DeepLTranslate() and DeepLUsage() formula functions.
Read FORMULA_FUNCTIONS.md for usage details and cost implications before enabling. */
const enableFormulaFunctions = false;

/* Change the line below to disable all translations. */
const disableTranslations = false; // Set to true to stop translations.

Expand All @@ -30,10 +34,211 @@ const activateAutoDetect = false; // Set to true to enable auto-detection of re-

/* You shouldn't need to modify the lines below here */

const deeplApiKey = PropertiesService.getScriptProperties().getProperty('DEEPL_API_KEY');

/* Version of this script from https://github.com/DeepLcom/google-sheet-example, included in logs. */
const scriptVersion = "0.2.0";
const scriptVersion = "0.3.0";

/**
* Creates the DeepL menu when the spreadsheet is opened.
*/
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('DeepL')
.addItem('Open sidebar', 'showSidebar')
.addSeparator()
.addItem('About (v' + scriptVersion + ')', 'showAbout_')
.addToUi();
}

/**
* Shows a dialog with the current script version and a link to the changelog.
*/
function showAbout_() {
const ui = SpreadsheetApp.getUi();
ui.alert(
'DeepL for Google Sheets',
'Version ' + scriptVersion + '\n\n' +
'To check for updates, visit:\n' +
'https://github.com/DeepLcom/google-sheets-example/blob/main/CHANGELOG.md',
ui.ButtonSet.OK
);
}

/**
* Returns the current script version. Called from the sidebar on load.
* @return {string}
*/
function getScriptVersion() {
return scriptVersion;
}

/**
* Opens the DeepL translation sidebar.
*/
function showSidebar() {
const html = HtmlService.createHtmlOutputFromFile('DeepLSidebar')
.setTitle('DeepL Translate')
.setWidth(300);
SpreadsheetApp.getUi().showSidebar(html);
}

/**
* Translates the currently selected cells and writes results back as static values.
* Called from the sidebar via google.script.run.
*
* @param {string|null} sourceLang Source language code, or null for auto-detect.
* @param {string} targetLang Target language code.
* @param {string|null} glossaryId Glossary ID, or null to omit.
* @return {{translated: number, skipped: number, failed: number, error: string, billedCharacters: number}}
*/
function translateSelectionFromSidebar(sourceLang, targetLang, glossaryId, formality, context) {
const range = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getActiveRange();
if (!range) throw new Error('No cells selected.');

const flatCells = [];
for (let r = 0; r < range.getNumRows(); r++) {
for (let c = 0; c < range.getNumColumns(); c++) {
flatCells.push(range.getCell(r + 1, c + 1));
}
}

PropertiesService.getScriptProperties().setProperties({
'DEEPL_LAST_SOURCE_LANG': sourceLang || '',
'DEEPL_LAST_TARGET_LANG': targetLang,
'DEEPL_LAST_FORMALITY': formality || '',
'DEEPL_LAST_CONTEXT': context || '',
'DEEPL_LAST_GLOSSARY_ID': glossaryId || '',
});

const cellsToTranslate = flatCells.filter(cell => {
const text = cell.getDisplayValue();
return text && text.trim() !== '';
});
const skipped = flatCells.length - cellsToTranslate.length;

if (cellsToTranslate.length === 0) {
return { translated: 0, skipped, failed: 0, error: '', billedCharacters: 0 };
}

try {
const texts = cellsToTranslate.map(cell => cell.getDisplayValue());
const results = callDeeplTranslateApi_(texts, sourceLang, targetLang, glossaryId, formality, context);
const billedCharacters = results.reduce((sum, r) => sum + r.billedCharacters, 0);
for (let i = 0; i < cellsToTranslate.length; i++) {
cellsToTranslate[i].setValue(results[i].text);
}
return { translated: cellsToTranslate.length, skipped, failed: 0, error: '', billedCharacters };
} catch (e) {
const lastError = e.message || String(e);
Logger.log(`DeepLcom/google-sheets-example/${scriptVersion}: translateSelectionFromSidebar error: ${lastError}`);
return { translated: 0, skipped, failed: cellsToTranslate.length, error: lastError, billedCharacters: 0 };
}
}

/**
* Returns API usage as a structured object for display in the sidebar.
* Called from the sidebar via google.script.run.
* @return {{charCount: number, charLimit: number}}
*/
function getUsageForSidebar() {
const response = httpRequestWithRetries_('get', '/v2/usage');
checkResponse_(response);
const obj = JSON.parse(response.getContentText());
if (obj.character_count === undefined || obj.character_limit === undefined)
throw new Error('Character usage not found in API response.');
return { charCount: obj.character_count, charLimit: obj.character_limit };
}

/**
* Returns all saved sidebar options, or null if none have been saved yet.
* Called from the sidebar on load.
* @return {{sourceLang: string, targetLang: string, formality: string, context: string, glossaryId: string}|null}
*/
function getSavedOptions() {
const props = PropertiesService.getScriptProperties();
const targetLang = props.getProperty('DEEPL_LAST_TARGET_LANG');
if (!targetLang) return null;
return {
sourceLang: props.getProperty('DEEPL_LAST_SOURCE_LANG') || '',
targetLang,
formality: props.getProperty('DEEPL_LAST_FORMALITY') || '',
context: props.getProperty('DEEPL_LAST_CONTEXT') || '',
glossaryId: props.getProperty('DEEPL_LAST_GLOSSARY_ID') || '',
};
}

/**
* Returns the count of cells in the active selection.
* Called from the sidebar to show how many cells will be translated.
* @return {number}
*/
function getSelectionCount() {
const range = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet().getActiveRange();
if (!range) return 0;
return range.getNumRows() * range.getNumColumns();
}

/**
* Returns the last 4 characters of the saved API key, for display in the sidebar.
* @return {string|null}
*/
function getApiKeySuffix() {
const key = getApiKey_();
return key ? key.slice(-4) : null;
}

/**
* Returns whether an API key is currently saved in Script Properties.
* Called from the sidebar on load.
* @return {boolean}
*/
function hasApiKey() {
return !!PropertiesService.getScriptProperties().getProperty('DEEPL_API_KEY');
}

/**
* Saves the given API key to Script Properties and reloads the cached constant.
* Called from the sidebar via google.script.run.
* @param {string} key
*/
function saveApiKey(key) {
if (!key || !key.trim()) throw new Error('API key must not be empty.');
PropertiesService.getScriptProperties().setProperty('DEEPL_API_KEY', key.trim());
}

/**
* Deletes the saved API key from Script Properties.
* Called from the sidebar via google.script.run.
*/
function clearApiKey() {
PropertiesService.getScriptProperties().deleteProperty('DEEPL_API_KEY');
}

/**
* Calls the DeepL translate API for a single text string.
* Shared by the formula function and the sidebar/menu actions.
*
* @param {string} text The text to translate.
* @param {string|null} sourceLang Source language code, or null/falsy for auto-detect.
* @param {string} targetLang Target language code.
* @param {string|null} glossaryId Glossary ID, or null to omit.
* @return {string} Translated text.
*/
function callDeeplTranslateApi_(texts, sourceLang, targetLang, glossaryId, formality, context) {
const body = { text: texts, target_lang: targetLang, show_billed_characters: true };
if (sourceLang) body.source_lang = sourceLang;
if (glossaryId) body.glossary_id = glossaryId;
if (formality) body.formality = formality;
if (context) body.context = context;
const totalChars = texts.reduce((sum, t) => sum + t.length, 0);
const response = httpRequestWithRetries_('post', '/v2/translate', body, totalChars, true);
checkResponse_(response);
return JSON.parse(response.getContentText()).translations
.map(t => ({ text: t.text, billedCharacters: t.billed_characters || 0 }));
}

function getApiKey_() {
return PropertiesService.getScriptProperties().getProperty('DEEPL_API_KEY');
}

/**
* Translates from one language to another using the DeepL Translation API.
Expand All @@ -56,6 +261,9 @@ function DeepLTranslate(input,
glossaryId,
options
) {
if (!enableFormulaFunctions) {
throw new Error('Formula functions are disabled. Set enableFormulaFunctions = true in DeepL.gs to enable them. See FORMULA_FUNCTIONS.md for details and cost implications.');
}
if (input === undefined) {
throw new Error("input field is undefined, please specify the text to translate.");
} else if (typeof input === "number") {
Expand Down Expand Up @@ -118,6 +326,9 @@ function DeepLTranslate(input,
* @customfunction
*/
function DeepLUsage(type) {
if (!enableFormulaFunctions) {
throw new Error('Formula functions are disabled. Set enableFormulaFunctions = true in DeepL.gs to enable them. See FORMULA_FUNCTIONS.md for details and cost implications.');
}
const response = httpRequestWithRetries_('get', '/v2/usage');
checkResponse_(response);
const responseObject = JSON.parse(response.getContentText());
Expand Down Expand Up @@ -201,19 +412,30 @@ function checkResponse_(response) {
/**
* Helper function to execute HTTP requests and retry failed requests.
*/
function httpRequestWithRetries_(method, relativeUrl, formData = null, charCount = 0) {
const baseUrl = deeplApiKey.endsWith(':fx')
function httpRequestWithRetries_(method, relativeUrl, formData = null, charCount = 0, useJson = false) {
const apiKey = getApiKey_();
if (!apiKey) {
throw new Error('DeepL API key not set. Use the DeepL sidebar to add your API key.');
}
const baseUrl = apiKey.endsWith(':fx')
? 'https://api-free.deepl.com'
: 'https://api.deepl.com';
const url = baseUrl + relativeUrl;
const params = {
method: method,
muteHttpExceptions: true,
headers: {
'Authorization': 'DeepL-Auth-Key ' + deeplApiKey,
'Authorization': 'DeepL-Auth-Key ' + apiKey,
},
};
if (formData) params.payload = formData;
if (formData) {
if (useJson) {
params.contentType = 'application/json';
params.payload = JSON.stringify(formData);
} else {
params.payload = formData;
}
}
let response = null;
for (let numRetries = 0; numRetries < 5; numRetries++) {
const lastRequestTime = Date.now();
Expand Down
Loading