From 4bfc1b1eac45e5d1eba57b217899698b7abd9019 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 11:39:33 -0500 Subject: [PATCH 01/12] automated update to translation keys (#811) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 94e2116a..c5d5342e 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -695,7 +695,8 @@ "TOOLS": "TOOLS", "Tour of this Document": "Tour of this Document", "Validate Data": "Validate Data", - "Settings": "Settings" + "Settings": "Settings", + "API Console": "API Console" }, "TopBar": { "Manage Team": "Manage Team" From 4378ec24d8f834cb0392fb01cbb1aaa17d0f7885 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 3 Jan 2024 07:57:53 -0500 Subject: [PATCH 02/12] v1.1.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 48dd5d5b..487b547f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grist-core", - "version": "1.1.9", + "version": "1.1.10", "license": "Apache-2.0", "description": "Grist is the evolution of spreadsheets", "homepage": "https://github.com/gristlabs/grist-core", From 6722512d96f771aaf2029415a590ed6c75859855 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 3 Jan 2024 19:06:38 +0100 Subject: [PATCH 03/12] Completely ignored disabled webhooks (#800) --- app/server/lib/Triggers.ts | 6 +-- test/server/lib/DocApi.ts | 91 +++++++++++++++++++++++++++----------- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts index 313cedf4..561e4eee 100644 --- a/app/server/lib/Triggers.ts +++ b/app/server/lib/Triggers.ts @@ -174,7 +174,7 @@ export class DocTriggers { const triggersTable = docData.getMetaTable("_grist_Triggers"); const getTableId = docData.getMetaTable("_grist_Tables").getRowPropFunc("tableId"); - const triggersByTableRef = _.groupBy(triggersTable.getRecords(), "tableRef"); + const triggersByTableRef = _.groupBy(triggersTable.getRecords().filter(t => t.enabled), "tableRef"); const triggersByTableId: Array<[string, Trigger[]]> = []; // First we need a list of columns which must be included in full in the action summary @@ -545,9 +545,7 @@ export class DocTriggers { tableDelta: TableDelta, ): boolean { let readyBefore: boolean; - if (!trigger.enabled) { - return false; - } else if (!trigger.isReadyColRef) { + if (!trigger.isReadyColRef) { // User hasn't configured a column, so all records are considered ready immediately readyBefore = recordDelta.existedBefore; } else { diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 8311050c..3b242402 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -18,7 +18,7 @@ import {delayAbort} from 'app/server/lib/serverUtils'; import axios, {AxiosRequestConfig, AxiosResponse} from 'axios'; import {delay} from 'bluebird'; import {assert} from 'chai'; -import * as express from 'express'; +import express from 'express'; import FormData from 'form-data'; import * as fse from 'fs-extra'; import * as _ from 'lodash'; @@ -3307,7 +3307,6 @@ function testDocApi() { }); describe('webhooks related endpoints', async function () { - /* Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe */ @@ -3503,8 +3502,6 @@ function testDocApi() { assert.equal(webhookList.length, 3); }); - - it("POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for editors", async function () { const subscribeResponse = await subscribeWebhook(); @@ -3563,6 +3560,7 @@ function testDocApi() { assert.equal(accessResp.status, 200); await flushAuth(); }); + }); describe("Daily API Limit", () => { @@ -3836,6 +3834,7 @@ function testDocApi() { eventTypes?: string[], name?: string, memo?: string, + enabled?: boolean, }) { // Subscribe helper that returns a method to unsubscribe. const {data, status} = await axios.post( @@ -3844,7 +3843,7 @@ function testDocApi() { eventTypes: options?.eventTypes ?? ['add', 'update'], url: `${serving.url}/${endpoint}`, isReadyColumn: options?.isReadyColumn === undefined ? 'B' : options?.isReadyColumn, - ...pick(options, 'name', 'memo'), + ...pick(options, 'name', 'memo', 'enabled'), }, chimpy ); assert.equal(status, 200); @@ -3970,34 +3969,41 @@ function testDocApi() { } }); - it("delivers expected payloads from combinations of changes, with retrying and batching", - async function () { - // Create a test document. - const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; - const docId = await userApi.newDoc({name: 'testdoc'}, ws1); - const doc = userApi.getDocAPI(docId); - - // For some reason B is turned into Numeric even when given bools + async function createWebhooks({docId, tableId, eventTypesSet, isReadyColumn, enabled}: + {docId: string, tableId: string, eventTypesSet: string[][], isReadyColumn: string, enabled?: boolean} + ) { + // Ensure the isReady column is a Boolean await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [ - ['ModifyColumn', 'Table1', 'B', {type: 'Bool'}], + ['ModifyColumn', tableId, isReadyColumn, {type: 'Bool'}], ], chimpy); - // Make a webhook for every combination of event types const subscribeResponses = []; const webhookIds: Record = {}; - for (const eventTypes of [ - ["add"], - ["update"], - ["add", "update"], - ]) { - const {data, status} = await axios.post( - `${serverUrl}/api/docs/${docId}/tables/Table1/_subscribe`, - {eventTypes, url: `${serving.url}/${eventTypes}`, isReadyColumn: "B"}, chimpy - ); - assert.equal(status, 200); + + for (const eventTypes of eventTypesSet) { + const data = await subscribe(String(eventTypes), docId, {tableId, eventTypes, isReadyColumn, enabled}); subscribeResponses.push(data); webhookIds[data.webhookId] = String(eventTypes); } + return {subscribeResponses, webhookIds}; + } + + it("delivers expected payloads from combinations of changes, with retrying and batching", + async function () { + // Create a test document. + const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; + const docId = await userApi.newDoc({name: 'testdoc'}, ws1); + const doc = userApi.getDocAPI(docId); + + // Make a webhook for every combination of event types + const {subscribeResponses, webhookIds} = await createWebhooks({ + docId, tableId: 'Table1', isReadyColumn: "B", + eventTypesSet: [ + ["add"], + ["update"], + ["add", "update"], + ] + }); // Add and update some rows, trigger some events // Values of A where B is true and thus the record is ready are [1, 4, 7, 8] @@ -4101,6 +4107,41 @@ function testDocApi() { ); }); + + [{ + itMsg: "doesn't trigger webhook that has been disabled", + enabled: false, + }, { + itMsg: "does trigger webhook that has been enable", + enabled: true, + }].forEach((ctx) => { + + it(ctx.itMsg, async function () { + // Create a test document. + const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; + const docId = await userApi.newDoc({name: 'testdoc'}, ws1); + const doc = userApi.getDocAPI(docId); + + await createWebhooks({ + docId, tableId: 'Table1', isReadyColumn: "B", eventTypesSet: [ ["add"] ], enabled: ctx.enabled + }); + + await doc.addRows("Table1", { + A: [42], + B: [true] + }); + + const queueRedisCalls = redisCalls.filter(args => args[1] === "webhook-queue-" + docId); + const redisPushIndex = queueRedisCalls.findIndex(args => args[0] === "rpush"); + + if (ctx.enabled) { + assert.isAbove(redisPushIndex, 0, "Should have pushed events to the redis queue"); + } else { + assert.equal(redisPushIndex, -1, "Should not have pushed any events to the redis queue"); + } + }); + + }); }); describe("/webhooks endpoint", function () { From 837597cd558ef815035b8119b05df9eef1db0d42 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 3 Jan 2024 20:47:53 +0100 Subject: [PATCH 04/12] Fix deadlock with webhooks on document load #799 (#812) --- app/server/lib/Sharing.ts | 14 ++++++++++---- test/server/lib/DocApi.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/server/lib/Sharing.ts b/app/server/lib/Sharing.ts index 77743e63..d9936264 100644 --- a/app/server/lib/Sharing.ts +++ b/app/server/lib/Sharing.ts @@ -22,6 +22,7 @@ import {ActionHistory, asActionGroup, getActionUndoInfo} from './ActionHistory'; import {ActiveDoc} from './ActiveDoc'; import {makeExceptionalDocSession, OptDocSession} from './DocSession'; import {WorkCoordinator} from './WorkCoordinator'; +import {summarizeAction} from 'app/common/ActionSummarizer'; // Describes the request to apply a UserActionBundle. It includes a Client (so that broadcast // message can set `.fromSelf` property), and methods to resolve or reject the promise for when @@ -246,14 +247,14 @@ export class Sharing { try { - const isCalculate = (userActions.length === 1 && SYSTEM_ACTIONS.has(userActions[0][0] as string)); + const isSystemAction = (userActions.length === 1 && SYSTEM_ACTIONS.has(userActions[0][0] as string)); // `internal` is true if users shouldn't be able to undo the actions. Applies to: // - Calculate/UpdateCurrentTime because it's not considered as performed by a particular client. // - Adding attachment metadata when uploading attachments, // because then the attachment file may get hard-deleted and redo won't work properly. // - Action was rejected but it had some side effects (e.g. NOW() or UUID() formulas). const internal = - isCalculate || + isSystemAction || userActions.every(a => a[0] === "AddRecord" && a[1] === "_grist_Attachments") || !!failure; @@ -305,7 +306,7 @@ export class Sharing { // If the document has shut down in the meantime, and this was just a "Calculate" action, // return a trivial result. This is just to reduce noisy warnings in migration tests. - if (this._activeDoc.isShuttingDown && isCalculate) { + if (this._activeDoc.isShuttingDown && isSystemAction) { return { actionNum: localActionBundle.actionNum, retValues: [], @@ -336,7 +337,12 @@ export class Sharing { } await this._activeDoc.processActionBundle(ownActionBundle); - const actionSummary = await this._activeDoc.handleTriggers(localActionBundle); + // Don't trigger webhooks for single Calculate actions, this causes a deadlock on document load. + // See gh issue #799 + const isSingleCalculateAction = userActions.length === 1 && userActions[0][0] === 'Calculate'; + const actionSummary = !isSingleCalculateAction ? + await this._activeDoc.handleTriggers(localActionBundle) : + summarizeAction(localActionBundle); await this._activeDoc.updateRowCount(sandboxActionBundle.rowCount, docSession); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 3b242402..cc54f0a8 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -4599,6 +4599,37 @@ function testDocApi() { await unsubscribe1(); }); + it('should not block document load (gh issue #799)', async function () { + // Create a test document. + const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; + const docId = await userApi.newDoc({name: 'testdoc5'}, ws1); + const doc = userApi.getDocAPI(docId); + // Before #799, formula of this type would block document load because of a deadlock + // and make this test fail. + const formulaEvaluatedAtDocLoad = 'NOW()'; + + await axios.post(`${serverUrl}/api/docs/${docId}/apply`, [ + ['ModifyColumn', 'Table1', 'C', {isFormula: true, formula: formulaEvaluatedAtDocLoad}], + ], chimpy); + + const unsubscribeWebhook1 = await autoSubscribe('probe', docId); + + // Create a first row. + await doc.addRows("Table1", { + A: [1], + }); + + await doc.forceReload(); + + // Create a second row after document reload. + // This should not timeout. + await doc.addRows("Table1", { + A: [2], + }); + + await unsubscribeWebhook1(); + }); + it("should monitor failures", async () => { const webhook3 = await subscribe('probe', docId); const webhook4 = await subscribe('probe', docId); From ba14a1bea70e27ed782141c93a9b2beb56d72080 Mon Sep 17 00:00:00 2001 From: jyio Date: Wed, 3 Jan 2024 15:49:32 -0500 Subject: [PATCH 05/12] OIDC: Support overriding `end_session_endpoint` using environment variable `GRIST_OIDC_IDP_END_SESSION_ENDPOINT` (#802) Support overriding `end_session_endpoint` using environment variable `GRIST_OIDC_IDP_END_SESSION_ENDPOINT` --- app/server/lib/OIDCConfig.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/server/lib/OIDCConfig.ts b/app/server/lib/OIDCConfig.ts index 110183e8..86f78bce 100644 --- a/app/server/lib/OIDCConfig.ts +++ b/app/server/lib/OIDCConfig.ts @@ -26,6 +26,9 @@ * If omitted, the name will either be the concatenation of "given_name" + "family_name" or the "name" attribute. * env GRIST_OIDC_SP_PROFILE_EMAIL_ATTR * The key of the attribute to use for the user's email. Defaults to "email". + * env GRIST_OIDC_IDP_END_SESSION_ENDPOINT + * If set, overrides the IdP's end_session_endpoint with an alternative URL to redirect user upon logout + * (for an IdP that has a logout endpoint but does not support the OIDC RP-Initiated Logout specification). * env GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT * If set to "true", on logout, there won't be any attempt to call the IdP's end_session_endpoint * (the user will remain logged in in the IdP). @@ -63,6 +66,7 @@ export class OIDCConfig { private _redirectUrl: string; private _namePropertyKey?: string; private _emailPropertyKey: string; + private _endSessionEndpoint: string; private _skipEndSessionEndpoint: boolean; private _ignoreEmailVerified: boolean; @@ -94,6 +98,11 @@ export class OIDCConfig { defaultValue: 'email', }); + this._endSessionEndpoint = section.flag('endSessionEndpoint').readString({ + envVar: 'GRIST_OIDC_IDP_END_SESSION_ENDPOINT', + defaultValue: '', + })!; + this._skipEndSessionEndpoint = section.flag('skipEndSessionEndpoint').readBool({ envVar: 'GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT', defaultValue: false, @@ -112,9 +121,11 @@ export class OIDCConfig { redirect_uris: [ this._redirectUrl ], response_types: [ 'code' ], }); - if (this._client.issuer.metadata.end_session_endpoint === undefined && !this._skipEndSessionEndpoint) { + if (this._client.issuer.metadata.end_session_endpoint === undefined && + !this._endSessionEndpoint && !this._skipEndSessionEndpoint) { throw new Error('The Identity provider does not propose end_session_endpoint. ' + - 'If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true'); + 'If that is expected, please set GRIST_OIDC_IDP_SKIP_END_SESSION_ENDPOINT=true ' + + 'or provide an alternative logout URL in GRIST_OIDC_IDP_END_SESSION_ENDPOINT'); } log.info(`OIDCConfig: initialized with issuer ${issuerUrl}`); } @@ -187,6 +198,10 @@ export class OIDCConfig { if (this._skipEndSessionEndpoint) { return redirectUrl.href; } + // Alternatively, we could use a logout URL specified by configuration. + if (this._endSessionEndpoint) { + return this._endSessionEndpoint; + } return this._client.endSessionUrl({ post_logout_redirect_uri: redirectUrl.href }); From 97df12c34d91451e53e4cb3a8a75684b7c439fdf Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 3 Jan 2024 22:38:51 +0100 Subject: [PATCH 06/12] Change error message on documents for non-owner users (#790) The error can often be fixed by just reloading the document with no need to worry the document owners For example, when the error message is: "interrupted by reconnect" Co-authored-by: Florent FAYOLLE --- app/client/models/DocPageModel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 689b23c2..1f8a3457 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -294,7 +294,8 @@ Recovery mode opens the document to be fully accessible to owners, and inaccessi It also disables formulas. [{{error}}]", {error: err.message}) : isDenied ? t('Sorry, access to this document has been denied. [{{error}}]', {error: err.message}) - : t("Document owners can attempt to recover the document. [{{error}}]", {error: err.message}) + : t("Please reload the document and if the error persist, "+ + "contact the document owners to attempt a document recovery. [{{error}}]", {error: err.message}) ), hideCancel: true, extraButtons: !(isDocOwner && !isDenied) ? null : bigBasicButton( From b6d65dcfd059109a71c06df5bb1f14e87252dfc1 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 1 Jan 2024 12:18:03 +0000 Subject: [PATCH 07/12] Translated using Weblate (Spanish) Currently translated at 100.0% (1023 of 1023 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index fe5862c7..a6cd53db 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -540,7 +540,8 @@ "Columns_other": "Columnas", "Fields_one": "Campo", "Fields_other": "Campos", - "Add referenced columns": "Añadir columnas referenciadas" + "Add referenced columns": "Añadir columnas referenciadas", + "Reset form": "Restablecer el formulario" }, "RowContextMenu": { "Copy anchor link": "Copiar enlace de anclaje", @@ -1309,5 +1310,8 @@ "Delete card": "Borrar la tarjeta", "Copy anchor link": "Copiar enlace fijado", "Insert card": "Insertar la tarjeta" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Campos ocultos" } } From eec684760c90c9dad06c36b7e598d6b89ddc2d81 Mon Sep 17 00:00:00 2001 From: Riccardo Polignieri Date: Tue, 2 Jan 2024 21:45:10 +0000 Subject: [PATCH 08/12] Translated using Weblate (Italian) Currently translated at 100.0% (1023 of 1023 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/it/ --- static/locales/it.client.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/locales/it.client.json b/static/locales/it.client.json index 24fd3f1d..ec01fcab 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -145,7 +145,8 @@ "Series_one": "Serie", "Series_other": "Serie", "Sort & Filter": "Ordina e filtra", - "Add referenced columns": "Aggiungi colonne referenziate" + "Add referenced columns": "Aggiungi colonne referenziate", + "Reset form": "Resetta modulo" }, "RowContextMenu": { "Copy anchor link": "Copia link", @@ -1255,5 +1256,8 @@ "Delete card": "Elimina scheda", "Copy anchor link": "Copia il link", "Insert card": "Inserisci scheda" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Campi nascosti" } } From 3bfc40159b60a42590d63ae0cdc81d4d6c6b9a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Tue, 2 Jan 2024 03:06:14 +0000 Subject: [PATCH 09/12] Translated using Weblate (Slovenian) Currently translated at 100.0% (1023 of 1023 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index 658aa80a..f4c733fd 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -578,7 +578,8 @@ "TRANSFORM": "TRANSFORM", "SELECTOR FOR": "SELEKTOR ZA", "Sort & Filter": "Razvrščanje in filtriranje", - "Widget": "Pripomoček" + "Widget": "Pripomoček", + "Reset form": "Ponastavi obrazec" }, "FloatingPopup": { "Maximize": "Povečajte", @@ -1255,5 +1256,8 @@ "Delete card": "Briši kartico", "Copy anchor link": "Kopiraj sidrno povezavo", "Insert card": "Vstavi kartico" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Skrita polja" } } From 8fe3c27dd2e00a4e3dea228933379c5963cf6c02 Mon Sep 17 00:00:00 2001 From: Alexandru-Ionut Chiuta Date: Tue, 2 Jan 2024 12:25:30 +0000 Subject: [PATCH 10/12] Translated using Weblate (Romanian) Currently translated at 100.0% (1023 of 1023 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ro/ --- static/locales/ro.client.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/static/locales/ro.client.json b/static/locales/ro.client.json index 1543c35e..dcdd839d 100644 --- a/static/locales/ro.client.json +++ b/static/locales/ro.client.json @@ -445,7 +445,8 @@ "TRANSFORM": "TRANSFORMĂ", "SELECTOR FOR": "SELECTOR PENTRU", "Sort & Filter": "Sortare și filtrare", - "Widget": "Widget" + "Widget": "Widget", + "Reset form": "Resetare formular" }, "FloatingPopup": { "Maximize": "Maximizați", @@ -1255,5 +1256,8 @@ }, "sendToDrive": { "Sending file to Google Drive": "Se trimite fișierul la Google Drive" + }, + "HiddenQuestionConfig": { + "Hidden fields": "Câmpuri ascunse" } } From 37630bcb284b703cf1974fed59257554e2698eb8 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Thu, 4 Jan 2024 16:38:52 +0000 Subject: [PATCH 11/12] Translated using Weblate (Spanish) Currently translated at 100.0% (1024 of 1024 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index a6cd53db..0b133b98 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -615,7 +615,8 @@ "TOOLS": "HERRAMIENTAS", "Tour of this Document": "Recorrido por este documento", "Validate Data": "Validar datos", - "Settings": "Ajustes" + "Settings": "Ajustes", + "API Console": "Consola de la API" }, "TopBar": { "Manage Team": "Administrar equipo" From 6d0de4500df7f645d677bce87153820f96a3129b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Wed, 3 Jan 2024 22:50:08 +0000 Subject: [PATCH 12/12] Translated using Weblate (Slovenian) Currently translated at 100.0% (1024 of 1024 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index f4c733fd..4d094714 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -283,7 +283,8 @@ "Validate Data": "Potrdi podatke", "How-to Tutorial": "Vadnica kako narediti", "Tour of this Document": "Ogled tega dokumenta", - "Return to viewing as yourself": "Vrnite se k ogledu kot vi" + "Return to viewing as yourself": "Vrnite se k ogledu kot vi", + "API Console": "API Konzola" }, "pages": { "Rename": "Preimenuj",