From 958ea096f3a65b9756620d24e85491391324d326 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Tue, 11 Jul 2023 10:52:06 +0100 Subject: [PATCH 01/10] fix a node-sqlite3-ism that breaks record removal in grist-static (#566) Grist by default uses node-sqlite3 to manipulate data in an SQLite database. If a single parameter is passed to `run` and it is a list, the list is unpacked and its contents treated as the actual parameters. In grist-static, we use other SQLite interfaces that don't have that automatic unpacking. Most calls like this have been removed from Grist, but at least one was missed, and was causing symptoms such as https://github.com/gristlabs/grist-static/issues/5 This change should make no difference to regular Grist, but resolves the grist-static problems. --- app/server/lib/DocStorage.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/server/lib/DocStorage.ts b/app/server/lib/DocStorage.ts index d6a46834..f5658c2a 100644 --- a/app/server/lib/DocStorage.ts +++ b/app/server/lib/DocStorage.ts @@ -378,7 +378,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage { const colListSql = newCols.map(c => `${quoteIdent(c.colId)}=?`).join(', '); const types = newCols.map(c => c.type); const sqlParams = DocStorage._encodeColumnsToRows(types, newCols.map(c => [PENDING_VALUE])); - await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, sqlParams[0]); + await db.run(`UPDATE ${quoteIdent(tableId)} SET ${colListSql}`, ...sqlParams[0]); } }, @@ -1093,7 +1093,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage { public _process_RemoveRecord(tableId: string, rowId: string): Promise { const sql = "DELETE FROM " + quoteIdent(tableId) + " WHERE id=?"; debuglog("RemoveRecord SQL: " + sql, [rowId]); - return this.run(sql, [rowId]); + return this.run(sql, rowId); } @@ -1130,7 +1130,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage { const stmt = await this.prepare(preSql + chunkParams + postSql); for (const index of _.range(0, numChunks * chunkSize, chunkSize)) { debuglog("DocStorage.BulkRemoveRecord: chunk delete " + index + "-" + (index + chunkSize - 1)); - await stmt.run(rowIds.slice(index, index + chunkSize)); + await stmt.run(...rowIds.slice(index, index + chunkSize)); } await stmt.finalize(); } @@ -1139,7 +1139,7 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage { debuglog("DocStorage.BulkRemoveRecord: leftover delete " + (numChunks * chunkSize) + "-" + (rowIds.length - 1)); const leftoverParams = _.range(numLeftovers).map(q).join(','); await this.run(preSql + leftoverParams + postSql, - rowIds.slice(numChunks * chunkSize, rowIds.length)); + ...rowIds.slice(numChunks * chunkSize, rowIds.length)); } } From 152dc832f1e0b5b6197a49e3178310e360b2cee2 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Wed, 12 Jul 2023 15:57:02 +0200 Subject: [PATCH 02/10] Split out new importFileAsNewTable method for grist-static (#564) Also add column types to Limit entity to fix errors. --- app/gen-server/entity/Limit.ts | 10 +-- app/server/lib/ActiveDoc.ts | 15 +++- app/server/lib/ActiveDocImport.ts | 143 ++++++++++++++++-------------- 3 files changed, 96 insertions(+), 72 deletions(-) diff --git a/app/gen-server/entity/Limit.ts b/app/gen-server/entity/Limit.ts index 1011a98f..eeb40f5e 100644 --- a/app/gen-server/entity/Limit.ts +++ b/app/gen-server/entity/Limit.ts @@ -7,23 +7,23 @@ export class Limit extends BaseEntity { @PrimaryGeneratedColumn() public id: number; - @Column() + @Column({type: Number}) public limit: number; - @Column() + @Column({type: Number}) public usage: number; - @Column() + @Column({type: String}) public type: string; - @Column({name: 'billing_account_id'}) + @Column({name: 'billing_account_id', type: Number}) public billingAccountId: number; @ManyToOne(type => BillingAccount) @JoinColumn({name: 'billing_account_id'}) public billingAccount: BillingAccount; - @Column({name: 'created_at', default: () => "CURRENT_TIMESTAMP"}) + @Column({name: 'created_at', type: nativeValues.dateTimeType, default: () => "CURRENT_TIMESTAMP"}) public createdAt: Date; /** diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index bb4ce879..c37f405e 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -80,7 +80,7 @@ import {guessColInfo} from 'app/common/ValueGuesser'; import {parseUserAction} from 'app/common/ValueParser'; import {TEMPLATES_ORG_DOMAIN} from 'app/gen-server/ApiServer'; import {Document} from 'app/gen-server/entity/Document'; -import {ParseOptions} from 'app/plugin/FileParserAPI'; +import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI'; import {compileAclFormula} from 'app/server/lib/ACLFormula'; import {AssistanceSchemaPromptV1Context} from 'app/server/lib/Assistance'; @@ -113,7 +113,7 @@ import tmp from 'tmp'; import {ActionHistory} from './ActionHistory'; import {ActionHistoryImpl} from './ActionHistoryImpl'; -import {ActiveDocImport} from './ActiveDocImport'; +import {ActiveDocImport, FileImportOptions} from './ActiveDocImport'; import {DocClients} from './DocClients'; import {DocPluginManager} from './DocPluginManager'; import { @@ -773,6 +773,17 @@ export class ActiveDoc extends EventEmitter { await this._activeDocImport.oneStepImport(docSession, uploadInfo); } + /** + * Import data resulting from parsing a file into a new table. + * In normal circumstances this is only used internally. + * It's exposed publicly for use by grist-static which doesn't use the plugin system. + */ + public async importParsedFileAsNewTable( + docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions + ): Promise { + return this._activeDocImport.importParsedFileAsNewTable(docSession, optionsAndData, importOptions); + } + /** * This function saves attachments from a given upload and creates an entry for them in the database. * It returns the list of rowIds for the rows created in the _grist_Attachments table. diff --git a/app/server/lib/ActiveDocImport.ts b/app/server/lib/ActiveDocImport.ts index d257c0b6..3e86fcd5 100644 --- a/app/server/lib/ActiveDocImport.ts +++ b/app/server/lib/ActiveDocImport.ts @@ -44,7 +44,7 @@ interface ReferenceDescription { refTableId: string; } -interface FileImportOptions { +export interface FileImportOptions { // Suggested name of the import file. It is sometimes used as a suggested table name, e.g. for csv imports. originalFilename: string; // Containing parseOptions as serialized JSON to pass to the import plugin. @@ -227,71 +227,14 @@ export class ActiveDocImport { } /** - * Imports all files as new tables, using the given transform rules and import options. - * The isHidden flag indicates whether to create temporary hidden tables, or final ones. + * Import data resulting from parsing a file into a new table. + * In normal circumstances this is only used internally. + * It's exposed publicly for use by grist-static which doesn't use the plugin system. */ - private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[], - {parseOptions = {}, mergeOptionMaps = []}: ImportOptions, - isHidden: boolean): Promise { - - // Check that upload size is within the configured limits. - const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity; - const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0); - if (totalSize > limit) { - throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413); - } - - // The upload must be within the plugin-accessible directory. Once moved, subsequent calls to - // moveUpload() will return without having to do anything. - if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); } - await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir()); - - const importResult: ImportResult = {options: parseOptions, tables: []}; - for (const [index, file] of upload.files.entries()) { - // If we have a better guess for the file's extension, replace it in origName, to ensure - // that DocPluginManager has access to it to guess the best parser type. - let origName: string = file.origName; - if (file.ext) { - origName = path.basename(origName, path.extname(origName)) + file.ext; - } - const res = await this._importFileAsNewTable(docSession, file.absPath, { - parseOptions, - mergeOptionsMap: mergeOptionMaps[index] || {}, - isHidden, - originalFilename: origName, - uploadFileIndex: index, - transformRuleMap: transforms[index] || {} - }); - if (index === 0) { - // Returned parse options from the first file should be used for all files in one upload. - importResult.options = parseOptions = res.options; - } - importResult.tables.push(...res.tables); - } - return importResult; - } - - /** - * Imports the data stored at tmpPath. - * - * Currently it starts a python parser as a child process - * outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may - * result in the import of multiple tables, in case of e.g. Excel formats. - * @param {OptDocSession} docSession: Session instance to use for importing. - * @param {String} tmpPath: The path from of the original file. - * @param {FileImportOptions} importOptions: File import options. - * @returns {Promise} with `options` property containing parseOptions as serialized JSON as adjusted - * or guessed by the plugin, and `tables`, which is which is a list of objects with information about - * tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`. - */ - private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string, - importOptions: FileImportOptions): Promise { - const {originalFilename, parseOptions, mergeOptionsMap, isHidden, uploadFileIndex, - transformRuleMap} = importOptions; - log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename); - if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); } - const optionsAndData: ParseFileResult = - await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions); + public async importParsedFileAsNewTable( + docSession: OptDocSession, optionsAndData: ParseFileResult, importOptions: FileImportOptions + ): Promise { + const {originalFilename, mergeOptionsMap, isHidden, uploadFileIndex, transformRuleMap} = importOptions; const options = optionsAndData.parseOptions; const parsedTables = optionsAndData.tables; @@ -374,6 +317,76 @@ export class ActiveDocImport { return ({options, tables}); } + /** + * Imports all files as new tables, using the given transform rules and import options. + * The isHidden flag indicates whether to create temporary hidden tables, or final ones. + */ + private async _importFiles(docSession: OptDocSession, upload: UploadInfo, transforms: TransformRuleMap[], + {parseOptions = {}, mergeOptionMaps = []}: ImportOptions, + isHidden: boolean): Promise { + + // Check that upload size is within the configured limits. + const limit = (Number(process.env.GRIST_MAX_UPLOAD_IMPORT_MB) * 1024 * 1024) || Infinity; + const totalSize = upload.files.reduce((acc, f) => acc + f.size, 0); + if (totalSize > limit) { + throw new ApiError(`Imported files must not exceed ${gutil.byteString(limit)}`, 413); + } + + // The upload must be within the plugin-accessible directory. Once moved, subsequent calls to + // moveUpload() will return without having to do anything. + if (!this._activeDoc.docPluginManager) { throw new Error('no plugin manager available'); } + await moveUpload(upload, this._activeDoc.docPluginManager.tmpDir()); + + const importResult: ImportResult = {options: parseOptions, tables: []}; + for (const [index, file] of upload.files.entries()) { + // If we have a better guess for the file's extension, replace it in origName, to ensure + // that DocPluginManager has access to it to guess the best parser type. + let origName: string = file.origName; + if (file.ext) { + origName = path.basename(origName, path.extname(origName)) + file.ext; + } + const res = await this._importFileAsNewTable(docSession, file.absPath, { + parseOptions, + mergeOptionsMap: mergeOptionMaps[index] || {}, + isHidden, + originalFilename: origName, + uploadFileIndex: index, + transformRuleMap: transforms[index] || {} + }); + if (index === 0) { + // Returned parse options from the first file should be used for all files in one upload. + importResult.options = parseOptions = res.options; + } + importResult.tables.push(...res.tables); + } + return importResult; + } + + /** + * Imports the data stored at tmpPath. + * + * Currently it starts a python parser as a child process + * outside the sandbox, and supports xlsx, csv, and perhaps some other formats. It may + * result in the import of multiple tables, in case of e.g. Excel formats. + * @param {OptDocSession} docSession: Session instance to use for importing. + * @param {String} tmpPath: The path from of the original file. + * @param {FileImportOptions} importOptions: File import options. + * @returns {Promise} with `options` property containing parseOptions as serialized JSON as adjusted + * or guessed by the plugin, and `tables`, which is which is a list of objects with information about + * tables, such as `hiddenTableId`, `uploadFileIndex`, `origTableName`, `transformSectionRef`, `destTableId`. + */ + private async _importFileAsNewTable(docSession: OptDocSession, tmpPath: string, + importOptions: FileImportOptions): Promise { + const {originalFilename, parseOptions} = importOptions; + log.info("ActiveDoc._importFileAsNewTable(%s, %s)", tmpPath, originalFilename); + if (!this._activeDoc.docPluginManager) { + throw new Error('no plugin manager available'); + } + const optionsAndData: ParseFileResult = + await this._activeDoc.docPluginManager.parseFile(tmpPath, originalFilename, parseOptions); + return this.importParsedFileAsNewTable(docSession, optionsAndData, importOptions); + } + /** * Imports records from `hiddenTableId` into `destTableId`, transforming the column * values from `hiddenTableId` according to the `transformRule`. Finalizes import when done. From b0f76a152fce33430fe407713b97d5456a673e09 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Wed, 12 Jul 2023 22:31:00 +0100 Subject: [PATCH 03/10] add more docker label choices (#570) --- .github/workflows/docker.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6a808cd5..ef1f5d7b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v2 - name: Docker meta id: meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v4 with: images: | ${{ github.repository_owner }}/grist @@ -23,6 +23,9 @@ jobs: type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + stable - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx From 7694588a4262cee794d651b89531c0df5d755071 Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 13 Jul 2023 08:44:46 +0200 Subject: [PATCH 04/10] External storage: split checkBackend and configure (follow-up #545) (#567) This fixes an issue with external storage in saas environment. See https://github.com/gristlabs/grist-core/pull/546/files#r1259340397 Co-authored-by: Florent FAYOLLE --- app/server/lib/FlexServer.ts | 5 +---- app/server/lib/ICreate.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 72a55f13..1aa73ea2 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1132,9 +1132,6 @@ export class FlexServer implements GristServer { await this.loadConfig(); this.addComm(); - // Temporary duplication of external storage configuration. - // This may break https://github.com/gristlabs/grist-core/pull/546, - // but will revive other uses of external storage. TODO: reconcile. await this.create.configure?.(); if (!isSingleUserMode()) { @@ -1147,7 +1144,7 @@ export class FlexServer implements GristServer { this._disableExternalStorage = true; externalStorage.flag('active').set(false); } - await this.create.configure?.(); + await this.create.checkBackend?.(); const workers = this._docWorkerMap; const docWorkerId = await this._addSelfAsWorker(workers); diff --git a/app/server/lib/ICreate.ts b/app/server/lib/ICreate.ts index f876fa9c..73b76e5c 100644 --- a/app/server/lib/ICreate.ts +++ b/app/server/lib/ICreate.ts @@ -32,6 +32,8 @@ export interface ICreate { sessionSecret(): string; // Check configuration of the app early enough to show on startup. configure?(): Promise; + // Optionally perform sanity checks on the configured storage, throwing a fatal error if it is not functional + checkBackend?(): Promise; // Return a string containing 1 or more HTML tags to insert into the head element of every // static page. getExtraHeadHtml?(): string; @@ -119,6 +121,13 @@ export function makeSimpleCreator(opts: { return secret; }, async configure() { + for (const s of storage || []) { + if (s.check()) { + break; + } + } + }, + async checkBackend() { for (const s of storage || []) { if (s.check()) { await s.checkBackend?.(); From 3bff145d489e5edd05eab135d0c5be662d2ab6a3 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 12 Jul 2023 16:39:19 +0000 Subject: [PATCH 05/10] Translated using Weblate (Portuguese) Currently translated at 100.0% (823 of 823 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt/ --- static/locales/pt.client.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/locales/pt.client.json b/static/locales/pt.client.json index e6af54f2..a50ec89e 100644 --- a/static/locales/pt.client.json +++ b/static/locales/pt.client.json @@ -129,7 +129,8 @@ }, "WelcomeSitePicker": { "You can always switch sites using the account menu.": "Sempre pode alternar entre sites através do menu da conta.", - "Welcome back": "Bem-vindo de volta" + "Welcome back": "Bem-vindo de volta", + "You have access to the following Grist sites.": "Tens acesso aos seguintes sítios Grist." }, "MakeCopyMenu": { "Cancel": "Cancelar", From 5f540828294ca8fc6106da2b511007a3d8af1cbe Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 13 Jul 2023 23:22:02 +0000 Subject: [PATCH 06/10] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (854 of 854 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 3bdc4aee..48f26cb2 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -71,7 +71,11 @@ "Sign Out": "Sair", "Sign in": "Entrar", "Switch Accounts": "Alternar Contas", - "Toggle Mobile Mode": "Alternar Modo Móvel" + "Toggle Mobile Mode": "Alternar Modo Móvel", + "Activation": "Ativação", + "Billing Account": "Conta de faturamento", + "Support Grist": "Suporte Grist", + "Upgrade Plan": "Atualizar o Plano" }, "ActionLog": { "Action Log failed to load": "Falha ao carregar o Log de Ações", @@ -511,7 +515,8 @@ "Notifications": "Notificações", "Renew": "Renovar", "Report a problem": "Reportar um problema", - "Upgrade Plan": "Atualizar o Plano" + "Upgrade Plan": "Atualizar o Plano", + "Manage billing": "Gerenciar faturamento" }, "OnBoardingPopups": { "Finish": "Terminar", @@ -1099,5 +1104,37 @@ "You have access to the following Grist sites.": "Você tem acesso aos seguintes sites do Grist.", "Welcome back": "Bem-vindo de volta", "You can always switch sites using the account menu.": "Você sempre pode alternar entre sites usando o menu da conta." + }, + "SupportGristNudge": { + "Close": "Fechar", + "Opt in to Telemetry": "Aceitar a Telemetria", + "Help Center": "Centro de Ajuda", + "Support Grist": "Suporte Grist", + "Contribute": "Contribuir", + "Opted In": "Optou por participar", + "Support Grist page": "Página de Suporte Grist" + }, + "SupportGristPage": { + "GitHub": "GitHub", + "GitHub Sponsors page": "Página de patrocinadores do GitHub", + "Help Center": "Centro de Ajuda", + "Home": "Início", + "Manage Sponsorship": "Gerenciar patrocínio", + "Opt in to Telemetry": "Aceitar a Telemetria", + "Opt out of Telemetry": "Desativar a Telemetria", + "Sponsor Grist Labs on GitHub": "Patrocine Grist Labs no GitHub", + "Support Grist": "Suporte Grist", + "Telemetry": "Telemetria", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Esta instância está incluída na telemetria. Somente o administrador do site tem permissão para alterar isso.", + "You can opt out of telemetry at any time from this page.": "Você pode desativar a telemetria a qualquer momento nesta página.", + "You have opted out of telemetry.": "Você decidiu em não participar da telemetria.", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Coletamos apenas estatísticas de uso, conforme detalhado em nosso {{link}}, nunca o conteúdo dos documentos.", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Esta instância foi desativada da telemetria. Somente o administrador do site tem permissão para alterar isso.", + "You have opted in to telemetry. Thank you!": "Você optou pela telemetria. Obrigado!" + }, + "buildViewSectionDom": { + "No data": "Sem dados", + "No row selected in {{title}}": "Nenhuma linha selecionada em {{title}}", + "Not all data is shown": "Nem todos os dados são mostrados" } } From fa891bf29c4f1418c0188357e8b95b321e3cbb33 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Fri, 14 Jul 2023 01:34:56 +0000 Subject: [PATCH 07/10] Translated using Weblate (Spanish) Currently translated at 100.0% (854 of 854 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 0c29dbd4..6e7d51a7 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -66,7 +66,11 @@ "Sign Out": "Cerrar Sesión", "Sign in": "Iniciar Sesión", "Switch Accounts": "Cambiar de Cuenta", - "Toggle Mobile Mode": "Alternar Modo Móvil" + "Toggle Mobile Mode": "Alternar Modo Móvil", + "Activation": "Activación", + "Billing Account": "Cuenta de facturación", + "Support Grist": "Soporte Grist", + "Upgrade Plan": "Actualizar el Plan" }, "AddNewButton": { "Add New": "Agregar Nuevo" @@ -430,7 +434,8 @@ "Notifications": "Notificaciones", "Renew": "Renovar", "Report a problem": "Reportar un problema", - "Upgrade Plan": "Actualizar el Plan" + "Upgrade Plan": "Actualizar el Plan", + "Manage billing": "Administrar la facturación" }, "OnBoardingPopups": { "Finish": "Finalizar", @@ -1089,5 +1094,37 @@ "Welcome back": "Bienvenido de nuevo", "You can always switch sites using the account menu.": "Siempre puedes cambiar de sitio utilizando el menú de la cuenta.", "You have access to the following Grist sites.": "Usted tiene acceso a los siguientes sitios de Grist." + }, + "SupportGristNudge": { + "Help Center": "Centro de ayuda", + "Opted In": "Optado por participar", + "Support Grist": "Soporte Grist", + "Opt in to Telemetry": "Participar en Telemetría", + "Support Grist page": "Página de soporte de Grist", + "Close": "Cerrar", + "Contribute": "Contribuir" + }, + "SupportGristPage": { + "GitHub": "GitHub", + "GitHub Sponsors page": "Página de patrocinadores de GitHub", + "Help Center": "Centro de ayuda", + "Manage Sponsorship": "Gestionar el patrocinio", + "Opt in to Telemetry": "Optar por la telemetría", + "Opt out of Telemetry": "Darse de baja de la telemetría", + "Telemetry": "Telemetría", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Esta instancia está habilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.", + "Sponsor Grist Labs on GitHub": "Patrocinar Grist Labs en GitHub", + "Support Grist": "Soporte Grist", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Esta instancia está inhabilitada para la telemetría. Solo el administrador del sitio tiene permiso para cambiar esto.", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Solo recopilamos estadísticas de uso, como se detalla en nuestro {{link}}, nunca el contenido de los documentos.", + "You can opt out of telemetry at any time from this page.": "Puede cancelar la telemetría en cualquier momento desde esta página.", + "You have opted in to telemetry. Thank you!": "Ha optado por la telemetría. ¡Gracias!", + "You have opted out of telemetry.": "Ha optado por no participar en la telemetría.", + "Home": "Inicio" + }, + "buildViewSectionDom": { + "No data": "Sin datos", + "No row selected in {{title}}": "Ninguna fila seleccionada en {{title}}", + "Not all data is shown": "No se muestran todos los datos" } } From 1117886e15b4ecced666713f95e35a9460e887d5 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 13 Jul 2023 23:29:41 +0000 Subject: [PATCH 08/10] Translated using Weblate (German) Currently translated at 100.0% (854 of 854 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index df78bbab..44e6d682 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -71,7 +71,11 @@ "Sign Out": "Abmelden", "Sign in": "Anmelden", "Switch Accounts": "Konten wechseln", - "Toggle Mobile Mode": "Mobilmodus umschalten" + "Toggle Mobile Mode": "Mobilmodus umschalten", + "Activation": "Aktivierung", + "Billing Account": "Abrechnungskonto", + "Support Grist": "Grist Support", + "Upgrade Plan": "Upgrade-Plan" }, "ActionLog": { "Action Log failed to load": "Aktionsprotokoll konnte nicht geladen werden", @@ -511,7 +515,8 @@ "Notifications": "Benachrichtigungen", "Renew": "Erneuern", "Report a problem": "Ein Problem melden", - "Upgrade Plan": "Upgrade-Plan" + "Upgrade Plan": "Upgrade-Plan", + "Manage billing": "Abrechnung verwalten" }, "OnBoardingPopups": { "Finish": "Beenden", @@ -1099,5 +1104,37 @@ "Welcome back": "Willkommen zurück", "You can always switch sites using the account menu.": "Sie können jederzeit über das Kontomenü zwischen den Websites wechseln.", "You have access to the following Grist sites.": "Sie haben Zugriff auf die folgenden Grist-Seiten." + }, + "SupportGristNudge": { + "Support Grist": "Grist Support", + "Close": "Schließen", + "Contribute": "Beitragen", + "Help Center": "Hilfe-Center", + "Opt in to Telemetry": "Melden Sie sich für Telemetrie an", + "Opted In": "Angemeldet", + "Support Grist page": "Support Grist-Seite" + }, + "SupportGristPage": { + "GitHub Sponsors page": "GitHub-Sponsorenseite", + "Help Center": "Hilfe-Center", + "Manage Sponsorship": "Sponsoring verwalten", + "Opt in to Telemetry": "Melden Sie sich für Telemetrie an", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Diese Instanz ist für Telemetrie aktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Diese Instanz ist von der Telemetrie deaktiviert. Nur der Site-Administrator hat die Berechtigung, dies zu ändern.", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Wir erfassen nur Nutzungsstatistiken, wie in unserem {{link}} beschrieben, jedoch niemals Inhalte der Dokumenten.", + "You can opt out of telemetry at any time from this page.": "Sie können die Telemetrie jederzeit auf dieser Seite deaktivieren.", + "GitHub": "GitHub", + "Home": "Home", + "Opt out of Telemetry": "Deaktivieren Sie die Telemetrie", + "Sponsor Grist Labs on GitHub": "Sponsern Sie Grist Labs auf GitHub", + "Support Grist": "Grist Support", + "Telemetry": "Telemetrie", + "You have opted in to telemetry. Thank you!": "Sie haben sich für die Telemetrie entschieden. Vielen Dank!", + "You have opted out of telemetry.": "Sie haben sich von der Telemetrie abgemeldet." + }, + "buildViewSectionDom": { + "No row selected in {{title}}": "Keine Zeile in {{title}} ausgewählt", + "Not all data is shown": "Es werden nicht alle Daten angezeigt", + "No data": "Keine Daten" } } From b4b0c805fff3b3064e40b374d7541e2aef60e22e Mon Sep 17 00:00:00 2001 From: Riccardo Polignieri Date: Fri, 14 Jul 2023 19:27:29 +0000 Subject: [PATCH 09/10] Translated using Weblate (Italian) Currently translated at 100.0% (854 of 854 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/it/ --- static/locales/it.client.json | 41 +++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/static/locales/it.client.json b/static/locales/it.client.json index adc66fdc..43f8f251 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -78,7 +78,8 @@ "No notifications": "Nessuna notifica", "Renew": "Rinnova", "Report a problem": "Segnala un problema", - "Upgrade Plan": "Aggiorna il tuo piano" + "Upgrade Plan": "Aggiorna il tuo piano", + "Manage billing": "Gestisci modalità di addebito" }, "Pages": { "Delete data and this page.": "Elimina i dati e questa pagina.", @@ -345,7 +346,11 @@ "Pricing": "Prezzi", "Profile Settings": "Impostazioni utente", "Sign in": "Accedi", - "Switch Accounts": "Cambia account" + "Switch Accounts": "Cambia account", + "Activation": "Attivazione", + "Upgrade Plan": "Cambia il tuo piano", + "Billing Account": "Conto di addebito", + "Support Grist": "Sostieni Grist" }, "ActionLog": { "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "La colonna {{colId}} è stata successivamente rimossa nell'azione #{{action.actionNum}}", @@ -1035,5 +1040,37 @@ "Welcome back": "Bentornato", "You can always switch sites using the account menu.": "Puoi sempre cambiare sito usando il menu del tuo profilo.", "You have access to the following Grist sites.": "Hai accesso a questi siti di Grist." + }, + "SupportGristNudge": { + "Support Grist page": "Pagina Sostieni Grist", + "Close": "Chiudi", + "Contribute": "Contribuisci", + "Help Center": "Centro Aiuto", + "Opt in to Telemetry": "Accetta la telemetria", + "Opted In": "Accettato", + "Support Grist": "Sostieni Grist" + }, + "SupportGristPage": { + "GitHub": "GitHub", + "Help Center": "Centro Aiuto", + "Home": "Pagina iniziale", + "Manage Sponsorship": "Gestisci sponsorizzazione", + "Opt out of Telemetry": "Disattiva la telemetria", + "Sponsor Grist Labs on GitHub": "Sostieni Grist Labs su GitHub", + "Support Grist": "Sostieni Grist", + "Telemetry": "Telemetria", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Questa istanza accetta la telemetria. Solo un amministratore può cambiare questa impostazione.", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Questa istanza ha disattivato la telemetria. Solo un amministratore può cambiare questa opzione.", + "You can opt out of telemetry at any time from this page.": "Puoi disattivare la telemetria in qualsiasi momento da questa pagina.", + "You have opted in to telemetry. Thank you!": "Hai accettato la telemetria. Grazie!", + "You have opted out of telemetry.": "Hai disattivato la telemetria.", + "GitHub Sponsors page": "Pagina Sponsor GitHub", + "Opt in to Telemetry": "Accetta la telemetria", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Raccogliamo solo statistiche di utilizzo, mai contenuti dei documenti, come spiegato in {{link}}." + }, + "buildViewSectionDom": { + "No row selected in {{title}}": "Nessuna riga selezionata in {{title}}", + "Not all data is shown": "Non tutti i dati sono mostrati", + "No data": "Nessun dato" } } From 61bd064f73464f762a0343b34780b2ba82b5f900 Mon Sep 17 00:00:00 2001 From: CamilleLegeron Date: Sun, 16 Jul 2023 18:52:13 +0200 Subject: [PATCH 10/10] i18n: userManager translation + some forgotten translations (#557) * translation: add userManager translation + some forgotten translations * use '\' caracter for multiple-line strings --- app/client/aclui/AccessRules.ts | 6 +- app/client/components/ChartView.ts | 5 +- app/client/models/DocPageModel.ts | 7 +- app/client/models/SearchModel.ts | 9 ++- app/client/ui/AccountPage.ts | 5 +- app/client/ui/ApiKey.ts | 8 +-- app/client/ui/DocTour.ts | 4 +- app/client/ui/ExampleInfo.ts | 4 +- app/client/ui/GristTooltips.ts | 28 ++++---- app/client/ui/MakeCopyMenu.ts | 4 +- app/client/ui/ShareMenu.ts | 6 +- app/client/ui/UserManager.ts | 105 ++++++++++++++++------------- app/client/ui/WelcomeTour.ts | 4 +- app/client/ui/errorPages.ts | 4 +- app/client/ui/searchDropdown.ts | 5 +- app/client/ui2018/search.ts | 2 +- static/locales/en.client.json | 69 +++++++++++++++++-- 17 files changed, 174 insertions(+), 101 deletions(-) diff --git a/app/client/aclui/AccessRules.ts b/app/client/aclui/AccessRules.ts index d2912703..e205bce1 100644 --- a/app/client/aclui/AccessRules.ts +++ b/app/client/aclui/AccessRules.ts @@ -1178,8 +1178,8 @@ Useful for examples and templates, but not for sensitive data.`), }, SchemaEdit: { name: t("Permission to edit document structure"), - description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, " + - "layouts), and to write formulas, which give access to all data regardless of read restrictions."), + description: t("Allow editors to edit structure (e.g. modify and delete tables, columns, \ +layouts), and to write formulas, which give access to all data regardless of read restrictions."), availableBits: ['schemaEdit'], ...schemaEditRules.denyEditors, }, @@ -1323,7 +1323,7 @@ class SpecialSchemaObsRuleSet extends SpecialObsRuleSet { return dom.maybe( (use) => use(this._body).every(rule => rule.isBuiltInOrEmpty(use)), () => cssConditionError({style: 'margin-left: 56px; margin-bottom: 8px;'}, - "This default should be changed if editors' access is to be limited. ", + t("This default should be changed if editors' access is to be limited. "), dom('a', {style: 'color: inherit; text-decoration: underline'}, 'Dismiss', dom.on('click', () => this._allowEditors('confirm'))), testId('rule-schema-edit-warning'), diff --git a/app/client/components/ChartView.ts b/app/client/components/ChartView.ts index 8fd99763..7b33169b 100644 --- a/app/client/components/ChartView.ts +++ b/app/client/components/ChartView.ts @@ -660,8 +660,9 @@ export class ChartConfig extends GrainJSDisposable { ), dom.domComputed(this._optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) => value === 'symmetric' ? cssRowHelp(t("Each Y series is followed by a series for the length of error bars.")) : - value === 'separate' ? cssRowHelp(t("Each Y series is followed by two series, for " + - "top and bottom error bars.")) + value === 'separate' ? cssRowHelp( + t("Each Y series is followed by two series, for top and bottom error bars.") + ) : null ), ]), diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 84aeb208..3c711e2d 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -265,10 +265,9 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { { explanation: ( isDocOwner - ? t("You can try reloading the document, or using recovery mode. " + - "Recovery mode opens the document to be fully accessible to " + - "owners, and inaccessible to others. It also disables " + - "formulas. [{{error}}]", {error: err.message}) + ? t("You can try reloading the document, or using recovery mode. \ +Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. \ +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}) diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index f3525c67..2a791e04 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -12,6 +12,9 @@ import {TableData} from 'app/common/TableData'; import {BaseFormatter} from 'app/common/ValueFormatter'; import {Computed, Disposable, Observable} from 'grainjs'; import debounce = require('lodash/debounce'); +import { makeT } from 'app/client/lib/localization'; + +const t = makeT('SearchModel'); /** * SearchModel used to maintain the state of the search UI. @@ -201,7 +204,7 @@ class FinderImpl implements IFinder { // sort in order that is the same as on the raw data list page, .sort((a, b) => nativeCompare(a.tableNameDef.peek(), b.tableNameDef.peek())) // get rawViewSection, - .map(t => t.rawViewSection.peek()) + .map(table => table.rawViewSection.peek()) // and test if it isn't an empty record. .filter(s => Boolean(s.id.peek())); // Pretend that those are pages. @@ -218,7 +221,7 @@ class FinderImpl implements IFinder { // Else read all visible pages. const pages = this._gristDoc.docModel.visibleDocPages.peek(); this._pageStepper.array = pages.map(p => new PageRecWrapper(p, this._openDocPageCB)); - this._pageStepper.index = pages.findIndex(t => t.viewRef.peek() === this._gristDoc.activeViewId.get()); + this._pageStepper.index = pages.findIndex(page => page.viewRef.peek() === this._gristDoc.activeViewId.get()); if (this._pageStepper.index < 0) { return false; } } @@ -468,7 +471,7 @@ export class SearchModelImpl extends Disposable implements SearchModel { this.autoDispose(this.multiPage.addListener(v => { if (v) { this.noMatch.set(false); } })); this.allLabel = Computed.create(this, use => use(this._gristDoc.activeViewId) === 'data' ? - 'Search all tables' : 'Search all pages'); + t('Search all tables') : t('Search all pages')); // Schedule a search restart when user changes pages (otherwise search would resume from the // previous page that is not shown anymore). Also revert noMatch flag when in single page mode. diff --git a/app/client/ui/AccountPage.ts b/app/client/ui/AccountPage.ts index 88d95ad8..452c6284 100644 --- a/app/client/ui/AccountPage.ts +++ b/app/client/ui/AccountPage.ts @@ -131,9 +131,8 @@ export class AccountPage extends Disposable { ), css.subHeader(t("Two-factor authentication")), css.description( - t("Two-factor authentication is an extra layer of security for your Grist account " + - "designed to ensure that you're the only person who can access your account, " + - "even if someone knows your password.") + t("Two-factor authentication is an extra layer of security for your Grist account \ +designed to ensure that you're the only person who can access your account, even if someone knows your password.") ), dom.create(MFAConfig, user), ), diff --git a/app/client/ui/ApiKey.ts b/app/client/ui/ApiKey.ts index 3d6f313d..aa269bbc 100644 --- a/app/client/ui/ApiKey.ts +++ b/app/client/ui/ApiKey.ts @@ -78,8 +78,8 @@ export class ApiKey extends Disposable { dom.maybe((use) => !(use(this._apiKey) || this._anonymous), () => [ basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'), dom.boolAttr('disabled', this._loading)), - description(t("By generating an API key, you will be able to " + - "make API calls for your own account."), testId('description')), + description(t("By generating an API key, you will be able to \ +make API calls for your own account."), testId('description')), ]), ); } @@ -117,8 +117,8 @@ export class ApiKey extends Disposable { () => this._onDelete(), { explanation: t( - "You're about to delete an API key. This will cause all future requests " + - "using this API key to be rejected. Do you still want to delete?" + "You're about to delete an API key. This will cause all future requests \ +using this API key to be rejected. Do you still want to delete?" ), } ); diff --git a/app/client/ui/DocTour.ts b/app/client/ui/DocTour.ts index 86b6bb10..d1c2385f 100644 --- a/app/client/ui/DocTour.ts +++ b/app/client/ui/DocTour.ts @@ -21,8 +21,8 @@ export async function startDocTour(docData: DocData, docComm: DocComm, onFinishC const invalidDocTour: IOnBoardingMsg[] = [{ title: t("No valid document tour"), - body: t("Cannot construct a document tour from the data in this document. " + - "Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."), + body: t("Cannot construct a document tour from the data in this document. \ +Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location."), selector: 'document', showHasModal: true, }]; diff --git a/app/client/ui/ExampleInfo.ts b/app/client/ui/ExampleInfo.ts index 471b2e34..1781f628 100644 --- a/app/client/ui/ExampleInfo.ts +++ b/app/client/ui/ExampleInfo.ts @@ -36,8 +36,8 @@ export const buildExamples = (): IExampleInfo[] => [{ tutorialUrl: 'https://support.getgrist.com/investment-research/', welcomeCard: { title: t("Welcome to the Investment Research template"), - text: t("Check out our related tutorial to learn how to create " + - "summary tables and charts, and to link charts dynamically."), + text: t("Check out our related tutorial to learn how to create \ +summary tables and charts, and to link charts dynamically."), tutorialName: t("Tutorial: Analyze & Visualize"), }, }, { diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 04264845..6c997f95 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -50,8 +50,7 @@ export const GristTooltips: Record = { t('Formulas that trigger in certain cases, and store the calculated value as data.') ), dom('div', - t('Useful for storing the timestamp or author of a new record, data cleaning, and ' - + 'more.') + t('Useful for storing the timestamp or author of a new record, data cleaning, and more.') ), dom('div', cssLink({href: commonUrls.helpTriggerFormulas, target: '_blank'}, t('Learn more.')), @@ -76,8 +75,8 @@ export const GristTooltips: Record = { ), openAccessRules: (...args: DomElementArg[]) => cssTooltipContent( dom('div', - t('Access rules give you the power to create nuanced rules to determine who can ' - + 'see or edit which parts of your document.') + t('Access rules give you the power to create nuanced rules to determine who can \ +see or edit which parts of your document.') ), dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.')), @@ -126,8 +125,8 @@ export const GristBehavioralPrompts: Record t('Reference Columns'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', t('Select the table to link to.')), - dom('div', t('Cells in a reference column always identify an {{entire}} ' + - 'record in that table, but you may select which column from that record to show.', { + dom('div', t('Cells in a reference column always identify an {{entire}} \ +record in that table, but you may select which column from that record to show.', { entire: cssItalicizedText(t('entire')) })), dom('div', @@ -140,8 +139,8 @@ export const GristBehavioralPrompts: Record t('Raw Data page'), content: (...args: DomElementArg[]) => cssTooltipContent( - dom('div', t('The Raw Data page lists all data tables in your document, ' - + 'including summary tables and tables not included in page layouts.')), + dom('div', t('The Raw Data page lists all data tables in your document, \ +including summary tables and tables not included in page layouts.')), dom('div', cssLink({href: commonUrls.helpRawData, target: '_blank'}, t('Learn more.'))), ...args, ), @@ -150,8 +149,8 @@ export const GristBehavioralPrompts: Record t('Access Rules'), content: (...args: DomElementArg[]) => cssTooltipContent( - dom('div', t('Access rules give you the power to create nuanced rules ' - + 'to determine who can see or edit which parts of your document.')), + dom('div', t('Access rules give you the power to create nuanced rules \ +to determine who can see or edit which parts of your document.')), dom('div', cssLink({href: commonUrls.helpAccessRules, target: '_blank'}, t('Learn more.'))), ...args, ), @@ -209,8 +208,7 @@ export const GristBehavioralPrompts: Record t('Add New'), content: (...args: DomElementArg[]) => cssTooltipContent( - dom('div', t('Click the Add New button to create new documents or workspaces, ' - + 'or import data.')), + dom('div', t('Click the Add New button to create new documents or workspaces, or import data.')), ...args, ), deploymentTypes: ['saas'], @@ -219,8 +217,7 @@ export const GristBehavioralPrompts: Record t('Anchor Links'), content: (...args: DomElementArg[]) => cssTooltipContent( dom('div', - t('To make an anchor link that takes the user to a specific cell, click on' - + ' a row and press {{shortcut}}.', + t('To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.', { shortcut: ShortcutKey(ShortcutKeyContent(commands.allCommands.copyLink.humanKeys[0])), } @@ -235,8 +232,7 @@ export const GristBehavioralPrompts: Record cssTooltipContent( dom('div', t( - 'You can choose one of our pre-made widgets or embed your own ' + - 'by providing its full URL.' + 'You can choose one of our pre-made widgets or embed your own by providing its full URL.' ), ), dom('div', cssLink({href: commonUrls.helpCustomWidgets, target: '_blank'}, t('Learn more.'))), diff --git a/app/client/ui/MakeCopyMenu.ts b/app/client/ui/MakeCopyMenu.ts index 229fcedc..6c757b00 100644 --- a/app/client/ui/MakeCopyMenu.ts +++ b/app/client/ui/MakeCopyMenu.ts @@ -41,8 +41,8 @@ export async function replaceTrunkWithFork(user: FullUser|null, doc: Document, a if (cmp.summary === 'left' || cmp.summary === 'both') { titleText = t("Original Has Modifications"); buttonText = t("Overwrite"); - warningText = `${warningText} ${t("Be careful, the original has changes " + - "not in this document. Those changes will be overwritten.")}`; + warningText = `${warningText} ${t("Be careful, the original has changes \ +not in this document. Those changes will be overwritten.")}`; } else if (cmp.summary === 'unrelated') { titleText = t("Original Looks Unrelated"); buttonText = t("Overwrite"); diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index a9b988ef..fe3fa365 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -102,7 +102,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc, return cssHoverCircle({ style: `margin: 5px;` }, cssTopBarBtn('Share', dom.cls('tour-share-icon')), menu(menuCreateFunc, {placement: 'bottom-end'}), - hoverTooltip('Share', {key: 'topBarBtnTooltip'}), + hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}), testId('tb-share'), ); } else if (options.buttonAction) { @@ -115,7 +115,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc, cssShareCircle( cssShareIcon('Share'), menu(menuCreateFunc, {placement: 'bottom-end'}), - hoverTooltip('Share', {key: 'topBarBtnTooltip'}), + hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}), testId('tb-share'), ), ); @@ -128,7 +128,7 @@ function shareButton(buttonText: string|null, menuCreateFunc: MenuCreateFunc, cssShareIcon('Share') ), menu(menuCreateFunc, {placement: 'bottom-end'}), - hoverTooltip('Share', {key: 'topBarBtnTooltip'}), + hoverTooltip(t('Share'), {key: 'topBarBtnTooltip'}), testId('tb-share'), ); } diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index f4f55a54..2f2f55a4 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -5,6 +5,7 @@ * * It can be instantiated by calling showUserManagerModal with the UserAPI and IUserManagerOptions. */ +import { makeT } from 'app/client/lib/localization'; import {commonUrls} from 'app/common/gristUrls'; import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil'; import {FullUser} from 'app/common/LoginSessionAPI'; @@ -42,6 +43,8 @@ import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal, cssAnimatedModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals'; +const t = makeT('UserManager'); + export interface IUserManagerOptions { permissionData: Promise; activeUser: FullUser|null; @@ -101,15 +104,15 @@ export function showUserManagerModal(userApi: UserAPI, options: IUserManagerOpti } }; if (model.isSelfRemoved.get()) { - const name = resourceName(model.resourceType); + const resourceType = resourceName(model.resourceType); confirmModal( - `You are about to remove your own access to this ${name}`, - 'Remove my access', tryToSaveChanges, + t(`You are about to remove your own access to this {{resourceType}}`, { resourceType }), + t('Remove my access'), tryToSaveChanges, { explanation: ( - 'Once you have removed your own access, ' + - 'you will not be able to get it back without assistance ' + - `from someone else with sufficient access to the ${name}.` + t(`Once you have removed your own access, \ +you will not be able to get it back without assistance \ +from someone else with sufficient access to the {{resourceType}}.`, { resourceType }) ), } ); @@ -162,22 +165,22 @@ function buildUserManagerModal( cssModalButtons( { style: 'margin: 32px 64px; display: flex;' }, (model.isPublicMember ? null : - bigPrimaryButton('Confirm', + bigPrimaryButton(t('Confirm'), dom.boolAttr('disabled', (use) => !use(model.isAnythingChanged)), dom.on('click', () => onConfirm(ctl)), testId('um-confirm') ) ), bigBasicButton( - model.isPublicMember ? 'Close' : 'Cancel', + model.isPublicMember ? t('Close') : t('Cancel'), dom.on('click', () => ctl.close()), testId('um-cancel') ), (model.resourceType === 'document' && model.gristDoc && !model.isPersonal ? withInfoTooltip( cssLink({href: urlState().makeUrl({docPage: 'acl'})}, - dom.text(use => use(model.isAnythingChanged) ? 'Save & ' : ''), - 'Open Access Rules', + dom.text(use => use(model.isAnythingChanged) ? t('Save & ') : ''), + t('Open Access Rules'), dom.on('click', (ev) => { ev.preventDefault(); return onConfirm(ctl).then(() => urlState().pushUrl({docPage: 'acl'})); @@ -268,7 +271,7 @@ export class UserManager extends Disposable { return dom('div', cssOptionRowMultiple( icon('AddUser'), - cssLabel('Invite multiple'), + cssLabel(t('Invite multiple')), dom.on('click', (_ev) => buildMultiUserManagerModal( this, this._model, @@ -286,30 +289,31 @@ export class UserManager extends Disposable { ), publicMember ? dom('span', { style: `float: right;` }, cssSmallPublicMemberIcon('PublicFilled'), - dom('span', 'Public access: '), + dom('span', t('Public access: ')), cssOptionBtn( menu(() => { tooltipControl?.close(); return [ - menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)), - menuItem(() => publicMember.access.set(null), 'Off', + menuItem(() => publicMember.access.set(roles.VIEWER), t('On'), testId(`um-public-option`)), + menuItem(() => publicMember.access.set(null), t('Off'), // Disable null access if anonymous access is inherited. dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null), testId(`um-public-option`) ), // If the 'Off' setting is disabled, show an explanation. dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText( - `Public access inherited from ${getResourceParent(this._model.resourceType)}. ` + - `To remove, set 'Inherit access' option to 'None'.`)) + t(`Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.`, + { parent: getResourceParent(this._model.resourceType) } + ))) ]; }), - dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'), + dom.text((use) => use(publicMember.effectiveAccess) ? t('On') : t('Off')), cssCollapseIcon('Collapse'), testId('um-public-access') ), hoverTooltip((ctl) => { tooltipControl = ctl; - return 'Allow anyone with the link to open.'; + return t('Allow anyone with the link to open.'); }), ) : null, ), @@ -373,19 +377,23 @@ export class UserManager extends Disposable { const annotation = annotations.users.get(member.email); if (!annotation) { return null; } if (annotation.isSupport) { - return cssMemberType('Grist support'); + return cssMemberType(t('Grist support')); } if (annotation.isMember && annotations.hasTeam) { - return cssMemberType('Team member'); + return cssMemberType(t('Team member')); } - const collaborator = annotations.hasTeam ? 'guest' : 'free collaborator'; + const collaborator = annotations.hasTeam ? t('guest') : t('free collaborator'); const limit = annotation.collaboratorLimit; if (!limit || !limit.top) { return null; } const elements: HTMLSpanElement[] = []; if (limit.at <= limit.top) { - elements.push(cssMemberType(`${limit.at} of ${limit.top} ${collaborator}s`)); + elements.push(cssMemberType( + t(`{{limitAt}} of {{limitTop}} {{collaborator}}s`, { limitAt: limit.at, limitTop: limit.top, collaborator })) + ); } else { - elements.push(cssMemberTypeProblem(`${capitalizeFirstWord(collaborator)} limit exceeded`)); + elements.push(cssMemberTypeProblem( + t(`{{collaborator}} limit exceeded`, { collaborator: capitalizeFirstWord(collaborator) })) + ); } if (annotations.hasTeam) { // Add a link for adding a member. For a doc, streamline this so user can make @@ -401,10 +409,10 @@ export class UserManager extends Disposable { { email: member.email }).catch(reportError); } }), - `Add ${member.name || 'member'} to your team`)); + t(`Add {{member}} to your team`, { member: member.name || t('member') }))); } else if (limit.at >= limit.top) { elements.push(cssLink({href: commonUrls.plans, target: '_blank'}, - 'Create a team to share with more people')); + t('Create a team to share with more people'))); } return elements; }); @@ -418,13 +426,13 @@ export class UserManager extends Disposable { let memberType: string; if (annotation.isSupport) { - memberType = 'Grist support'; + memberType = t('Grist support'); } else if (annotation.isMember && annotations.hasTeam) { - memberType = 'Team member'; + memberType = t('Team member'); } else if (annotations.hasTeam) { - memberType = 'Outside collaborator'; + memberType = t('Outside collaborator'); } else { - memberType = 'Collaborator'; + memberType = t('Collaborator'); } return cssMemberType(memberType, testId('um-member-annotation')); @@ -439,8 +447,8 @@ export class UserManager extends Disposable { cssMemberListItem( cssPublicMemberIcon('PublicFilled'), cssMemberText( - cssMemberPrimary('Public Access'), - cssMemberSecondary('Anyone with link ', makeCopyBtn(this._options.linkToCopy)), + cssMemberPrimary(t('Public Access')), + cssMemberSecondary(t('Anyone with link '), makeCopyBtn(this._options.linkToCopy)), ), this._memberRoleSelector(publicMember.effectiveAccess, publicMember.inheritedAccess, false, this._model.publicUserSelectOptions @@ -472,12 +480,12 @@ export class UserManager extends Disposable { cssMemberPrimary(name, testId('um-member-name')), activeUser?.email ? cssMemberSecondary(activeUser.email) : null, cssMemberPublicAccess( - dom('span', 'Public access', testId('um-member-annotation')), + dom('span', t('Public access'), testId('um-member-annotation')), cssPublicAccessIcon('PublicFilled'), ), ), cssRoleBtn( - accessLabel ?? 'Guest', + accessLabel ?? t('Guest'), cssCollapseIcon('Collapse'), dom.cls('disabled'), testId('um-member-role'), @@ -522,23 +530,24 @@ export class UserManager extends Disposable { ) ), // If the user's access is inherited, give an explanation on how to change it. - isActiveUser ? menuText(`User may not modify their own access.`) : null, + isActiveUser ? menuText(t(`User may not modify their own access.`)) : null, // If the user's access is inherited, give an explanation on how to change it. dom.maybe((use) => use(inherited) && !isActiveUser, () => menuText( - `User inherits permissions from ${getResourceParent(this._model.resourceType)}. To remove, ` + - `set 'Inherit access' option to 'None'.`)), + t(`User inherits permissions from {{parent}}. To remove, \ +set 'Inherit access' option to 'None'.`, { parent: getResourceParent(this._model.resourceType) }))), // If the user is a guest, give a description of the guest permission. dom.maybe((use) => !this._model.isOrg && use(role) === roles.GUEST, () => menuText( - `User has view access to ${this._model.resourceType} resulting from manually-set access ` + - `to resources inside. If removed here, this user will lose access to resources inside.`)), - this._model.isOrg ? menuText(`No default access allows access to be ` + - `granted to individual documents or workspaces, rather than the full team site.`) : null + t(`User has view access to {{resource}} resulting from manually-set access \ +to resources inside. If removed here, this user will lose access to resources inside.`, + { resource: this._model.resourceType }))), + this._model.isOrg ? menuText(t(`No default access allows access to be \ +granted to individual documents or workspaces, rather than the full team site.`)) : null ]), dom.text((use) => { // Get the label of the active role. Note that the 'Guest' role is assigned when the role // is not found because it is not included as a selection. const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(role) === _role.value); - return activeRole ? activeRole.label : "Guest"; + return activeRole ? activeRole.label : t("Guest"); }), cssCollapseIcon('Collapse'), this._model.isPersonal ? dom.cls('disabled') : null, @@ -634,7 +643,7 @@ function getFullUser(member: IEditableMember): FullUser { // Create a "Copy Link" button. function makeCopyBtn(linkToCopy: string|undefined, ...domArgs: DomElementArg[]) { - return linkToCopy && cssCopyBtn(cssCopyIcon('Copy'), 'Copy Link', + return linkToCopy && cssCopyBtn(cssCopyIcon('Copy'), t('Copy Link'), dom.on('click', (ev, elem) => copyLink(elem, linkToCopy)), testId('um-copy-link'), ...domArgs, @@ -646,7 +655,7 @@ function makeCopyBtn(linkToCopy: string|undefined, ...domArgs: DomElementArg[]) async function copyLink(elem: HTMLElement, link: string) { await copyToClipboard(link); setTestState({clipboard: link}); - showTransientTooltip(elem, 'Link copied to clipboard', {key: 'copy-doc-link'}); + showTransientTooltip(elem, t('Link copied to clipboard'), { key: 'copy-doc-link' }); } async function manageTeam(appModel: AppModel, @@ -808,9 +817,9 @@ const cssMemberPublicAccess = styled(cssMemberSecondary, ` function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: boolean) { switch (resourceType) { case 'organization': { - if (personal) { return 'Your role for this team site'; } + if (personal) { return t('Your role for this team site'); } return [ - 'Manage members of team site', + t('Manage members of team site'), !resource ? null : cssOrgName( `${(resource as Organization).name} (`, cssOrgDomain(`${(resource as Organization).domain}.getgrist.com`), @@ -819,12 +828,14 @@ function renderTitle(resourceType: ResourceType, resource?: Resource, personal?: ]; } default: { - return personal ? `Your role for this ${resourceType}` : `Invite people to ${resourceType}`; + return personal ? + t(`Your role for this {{resourceType}}`, { resourceType }) : + t(`Invite people to {{resourceType}}`, { resourceType }); } } } // Rename organization to team site. function resourceName(resourceType: ResourceType): string { - return resourceType === 'organization' ? 'team site' : resourceType; + return resourceType === 'organization' ? t('team site') : resourceType; } diff --git a/app/client/ui/WelcomeTour.ts b/app/client/ui/WelcomeTour.ts index e2128642..6cafb14d 100644 --- a/app/client/ui/WelcomeTour.ts +++ b/app/client/ui/WelcomeTour.ts @@ -10,7 +10,7 @@ import { dom, styled } from "grainjs"; const t = makeT('WelcomeTour'); -export const welcomeTour: IOnBoardingMsg[] = [ +export const WelcomeTour: IOnBoardingMsg[] = [ { title: t('Editing Data'), body: () => [ @@ -97,7 +97,7 @@ export const welcomeTour: IOnBoardingMsg[] = [ export function startWelcomeTour(onFinishCB: () => void) { commands.allCommands.fieldTabOpen.run(); - startOnBoarding(welcomeTour, onFinishCB); + startOnBoarding(WelcomeTour, onFinishCB); } const TopBarButtonIcon = styled(icon, ` diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index a6eea10d..0f989c88 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -35,8 +35,8 @@ export function createForbiddenPage(appModel: AppModel, message?: string) { return pagePanelsError(appModel, t("Access denied{{suffix}}", {suffix: ''}), [ dom.domComputed(appModel.currentValidUser, user => user ? [ cssErrorText(message || t("You do not have access to this organization's documents.")), - cssErrorText(t("You are signed in as {{email}}. You can sign in with a different " + - "account, or ask an administrator for access.", {email: dom('b', user.email)})), + cssErrorText(t("You are signed in as {{email}}. You can sign in with a different \ +account, or ask an administrator for access.", {email: dom('b', user.email)})), ] : [ // This page is not normally shown because a logged out user with no access will get // redirected to log in. But it may be seen if a user logs out and returns to a cached diff --git a/app/client/ui/searchDropdown.ts b/app/client/ui/searchDropdown.ts index 8597778c..4884ec7b 100644 --- a/app/client/ui/searchDropdown.ts +++ b/app/client/ui/searchDropdown.ts @@ -10,6 +10,9 @@ import { icon } from "app/client/ui2018/icons"; import { cssMenuItem, defaultMenuOptions, IOpenController, IPopupOptions, setPopupToFunc } from "popweasel"; import { mergeWith } from "lodash"; import { getOptionFull, SimpleList } from "../lib/simpleList"; +import { makeT } from 'app/client/lib/localization'; + +const t = makeT('searchDropdown'); const testId = makeTestId('test-sd-'); @@ -92,7 +95,7 @@ class DropdownWithSearch extends Disposable { cssMenuHeader( cssSearchIcon('Search'), this._inputElem = cssSearch( - {placeholder: this._options.placeholder || 'Search'}, + {placeholder: this._options.placeholder || t('Search')}, dom.on('input', () => { this._update(); }), dom.on('blur', () => setTimeout(() => this._inputElem.focus(), 0)), ), diff --git a/app/client/ui2018/search.ts b/app/client/ui2018/search.ts index acb72723..37a53340 100644 --- a/app/client/ui2018/search.ts +++ b/app/client/ui2018/search.ts @@ -177,7 +177,7 @@ export function searchBar(model: SearchModel, testId: TestId = noTestId) { cssTopBarBtn('Search', testId('icon'), dom.on('click', focusAndSelect), - hoverTooltip('Search', {key: 'topBarBtnTooltip'}), + hoverTooltip(t('Search'), {key: 'topBarBtnTooltip'}), ) ), expandedSearch( diff --git a/static/locales/en.client.json b/static/locales/en.client.json index a1504192..766dee29 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -39,7 +39,9 @@ "View As": "View As", "Seed rules": "Seed rules", "When adding table rules, automatically add a rule to grant OWNER full access.": "When adding table rules, automatically add a rule to grant OWNER full access.", - "Permission to edit document structure": "Permission to edit document structure" + "Permission to edit document structure": "Permission to edit document structure", + "This default should be changed if editors' access is to be limited. ": "This default should be changed if editors' access is to be limited. ", + "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions." }, "AccountPage": { "API": "API", @@ -589,7 +591,8 @@ "Send to Google Drive": "Send to Google Drive", "Show in folder": "Show in folder", "Unsaved": "Unsaved", - "Work on a Copy": "Work on a Copy" + "Work on a Copy": "Work on a Copy", + "Share": "Share" }, "SiteSwitcher": { "Create new team site": "Create new team site", @@ -801,7 +804,8 @@ "Find Next ": "Find Next ", "Find Previous ": "Find Previous ", "No results": "No results", - "Search in document": "Search in document" + "Search in document": "Search in document", + "Search": "Search" }, "sendToDrive": { "Sending file to Google Drive": "Sending file to Google Drive" @@ -979,7 +983,9 @@ "Add New": "Add New", "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.", "Anchor Links": "Anchor Links", - "Custom Widgets": "Custom Widgets" + "Custom Widgets": "Custom Widgets", + "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.", + "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "You can choose one of our pre-made widgets or embed your own by providing its full URL." }, "DescriptionConfig": { "DESCRIPTION": "DESCRIPTION" @@ -1041,6 +1047,61 @@ "You can always switch sites using the account menu.": "You can always switch sites using the account menu.", "You have access to the following Grist sites.": "You have access to the following Grist sites." }, + "DescriptionTextArea": { + "DESCRIPTION": "DESCRIPTION" + }, + "UserManager": { + "Add {{member}} to your team": "Add {{member}} to your team", + "Allow anyone with the link to open.": "Allow anyone with the link to open.", + "Anyone with link ": "Anyone with link ", + "Cancel": "Cancel", + "Close": "Close", + "Collaborator": "Collaborator", + "Confirm": "Confirm", + "Copy Link": "Copy Link", + "Create a team to share with more people": "Create a team to share with more people", + "Grist support": "Grist support", + "Guest": "Guest", + "Invite multiple": "Invite multiple", + "Invite people to {{resourceType}}": "Invite people to {{resourceType}}", + "Link copied to clipboard": "Link copied to clipboard", + "Manage members of team site": "Manage members of team site", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.", + "Off": "Off", + "On": "On", + "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.": "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.", + "Open Access Rules": "Open Access Rules", + "Outside collaborator": "Outside collaborator", + "Public Access": "Public Access", + "Public access": "Public access", + "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.", + "Public access: ": "Public access: ", + "Remove my access": "Remove my access", + "Save & ": "Save & ", + "Team member": "Team member", + "User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.", + "User may not modify their own access.": "User may not modify their own access.", + "Your role for this team site": "Your role for this team site", + "Your role for this {{resourceType}}": "Your role for this {{resourceType}}", + "free collaborator": "free collaborator", + "guest": "guest", + "member": "member", + "team site": "team site", + "{{collaborator}} limit exceeded": "{{collaborator}} limit exceeded", + "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} of {{limitTop}} {{collaborator}}s", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.", + "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.", + "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.", + "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.", + "You are about to remove your own access to this {{resourceType}}": "You are about to remove your own access to this {{resourceType}}" + }, + "SearchModel": { + "Search all pages": "Search all pages", + "Search all tables": "Search all tables" + }, + "searchDropdown": { + "Search": "Search" + }, "SupportGristNudge": { "Close": "Close", "Contribute": "Contribute",