From 87228e9c7444e13dfe23df7c4d837dec6b712e71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Nov 2023 04:52:03 -0500 Subject: [PATCH 01/11] automated update to translation keys (#774) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 216958f0..95e58428 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -425,7 +425,9 @@ "Duplicate in {{- label}}": "Duplicate in {{- label}}", "No reference columns.": "No reference columns.", "Search columns": "Search columns", - "UUID": "UUID" + "UUID": "UUID", + "Add column with type": "Add column with type", + "Add formula column": "Add formula column" }, "GristDoc": { "Added new linked section to view {{viewName}}": "Added new linked section to view {{viewName}}", @@ -1063,7 +1065,8 @@ "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.", "Lookups return data from related tables.": "Lookups return data from related tables.", "Use reference columns to relate data in different tables.": "Use reference columns to relate data in different tables.", - "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL." + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.", + "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant." }, "DescriptionConfig": { "DESCRIPTION": "DESCRIPTION" From e3cc76b67d11a7619fe907c5fc09c06975bf9ce1 Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 27 Nov 2023 12:26:11 +0100 Subject: [PATCH 02/11] Force language to English while running the tests (#776) While running the tests in an environment whose default locales are not English, the tests fails. This is due to the fact that the webpages rendered in the nbrowsers tests are expected to be in English. Co-authored-by: Florent FAYOLLE --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9edc8019..0b6ba4e6 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "install:python3": "buildtools/prepare_python3.sh", "build:prod": "buildtools/build.sh", "start:prod": "sandbox/run.sh", - "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", - "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", + "test": "GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} --slow 8000 $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" '_build/test/common/*.js' '_build/test/client/*.js' '_build/test/nbrowser/*.js' '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", + "test:nbrowser": "TEST_SUITE=nbrowser TEST_SUITE_FOR_TIMINGS=nbrowser TIMINGS_FILE=test/timings/nbrowser.txt GRIST_SESSION_COOKIE=grist_test_cookie GRIST_TEST_LOGIN=1 TEST_SUPPORT_API_KEY=api_key_for_support TEST_CLEAN_DATABASE=true LANGUAGE=en_US mocha ${DEBUG:+-b --no-exit} $([ -z $DEBUG ] && echo --forbid-only) -g \"${GREP_TESTS}\" --slow 8000 -R test/xunit-file '_build/test/nbrowser/**/*.js'", "test:client": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/client/**/*.js'", "test:common": "GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} '_build/test/common/**/*.js'", "test:server": "TEST_SUITE=server TEST_SUITE_FOR_TIMINGS=server TIMINGS_FILE=test/timings/server.txt GRIST_SESSION_COOKIE=grist_test_cookie mocha ${DEBUG:+'-b'} -R test/xunit-file '_build/test/server/**/*.js' '_build/test/gen-server/**/*.js'", From 7d57114d2140963e48fd13f5e2135af202b2cee5 Mon Sep 17 00:00:00 2001 From: jarek Date: Mon, 27 Nov 2023 13:45:38 +0100 Subject: [PATCH 03/11] Fixing widget selection (#777) --- test/nbrowser/FillLinkedRecords.ntest.js | 6 +++--- test/nbrowser/SavePosition.ntest.js | 2 +- test/nbrowser/TypeChange.ntest.js | 8 ++++---- test/nbrowser/Views.ntest.js | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/nbrowser/FillLinkedRecords.ntest.js b/test/nbrowser/FillLinkedRecords.ntest.js index 5bea983a..f479abe7 100644 --- a/test/nbrowser/FillLinkedRecords.ntest.js +++ b/test/nbrowser/FillLinkedRecords.ntest.js @@ -28,19 +28,19 @@ describe('FillLinkedRecords.ntest', function() { // Link the sections first since the sample document start with no links. // Connect Friends -> Films - await gu.getSection('Films record').click(); + await gu.actions.viewSection('Films record').selectSection(); await $('.test-right-select-by').click(); await $('.test-select-row:contains(Friends record)').click(); await gu.waitForServer(); // Connect Films -> Performances grid - await gu.getSection('Performances record').click(); + await gu.actions.viewSection('Performances record').selectSection(); await $('.test-right-select-by').click(); await $('.test-select-row:contains(Films record)').click(); await gu.waitForServer(); // Connect Films -> Performances detail - await gu.getSection('Performances detail').click(); + await gu.actions.viewSection('Performances detail').selectSection(); await $('.test-right-select-by').click(); await $('.test-select-row:contains(Films record)').click(); await gu.waitForServer(); diff --git a/test/nbrowser/SavePosition.ntest.js b/test/nbrowser/SavePosition.ntest.js index c54b36d6..51392d0f 100644 --- a/test/nbrowser/SavePosition.ntest.js +++ b/test/nbrowser/SavePosition.ntest.js @@ -25,7 +25,7 @@ describe('SavePosition.ntest', function() { await $('.test-config-data').click(); // Connect CITY -> CITY Card List - await gu.getSection('CITY Card List').click(); + await gu.actions.viewSection('CITY Card List').selectSection(); await $('.test-right-select-by').click(); await $('.test-select-row:contains(CITY)').click(); await gu.waitForServer(); diff --git a/test/nbrowser/TypeChange.ntest.js b/test/nbrowser/TypeChange.ntest.js index 1e70bf1d..c7d39e60 100644 --- a/test/nbrowser/TypeChange.ntest.js +++ b/test/nbrowser/TypeChange.ntest.js @@ -291,12 +291,12 @@ describe('TypeChange.ntest', function() { it('should trigger a transform when reference table is changed', async function() { // Set up conditions for the test - await gu.getSection('Table1').click(); + await gu.actions.viewSection('Table1').selectSection(); await gu.enterGridValues(2, 3, [['red', 'yellow']]); await gu.actions.addNewSection('New', 'Table'); - await gu.getSection('TABLE3').click(); + await gu.actions.viewSection('TABLE3').selectSection(); await gu.enterGridValues(0, 1, [['yellow', 'red', 'green', 'blue']]); - await gu.getSection('Table1').click(); + await gu.actions.viewSection('Table1').selectSection(); await gu.clickCellRC(0, 3); await gu.openSidePane('field'); await gu.setType('Reference'); @@ -365,7 +365,7 @@ describe('TypeChange.ntest', function() { // column were mistaken for row ids and converted to row values instead of AltText values. it('should properly convert from integer to reference', async function() { // Set up conditions for the test - await gu.getSection('TABLE3').click(); + await gu.actions.viewSection('TABLE3').selectSection(); await gu.enterGridValues(0, 2, [['3', '3', '4', '1']]); await gu.waitForServer(); await gu.setType('Integer'); diff --git a/test/nbrowser/Views.ntest.js b/test/nbrowser/Views.ntest.js index acc1f1c1..9b8e2e8a 100644 --- a/test/nbrowser/Views.ntest.js +++ b/test/nbrowser/Views.ntest.js @@ -111,7 +111,7 @@ describe('Views.ntest', function() { // Reference: https://phab.getgrist.com/T327 await gu.actions.addNewSection('New', 'Table'); await gu.waitForServer(); - await gu.getSection('TABLE4').click(); + await gu.actions.viewSection('TABLE4').selectSection(); // Delete the section await gu.actions.viewSection('TABLE4').selectMenuOption('viewLayout', 'Delete widget'); await gu.waitForServer(); From 961b1c1956a1de45db9faf5f0888ba7224520fc9 Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 27 Nov 2023 16:47:56 +0100 Subject: [PATCH 04/11] Introduce TYPEORM_EXTRA env variable (#770) Co-authored-by: Florent FAYOLLE --- README.md | 3 ++- app/server/lib/dbUtils.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c2bbca8b..5118c49a 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ etc. Grist Labs is an open-core company. We offer Grist hosting as a service, with free and paid plans. We also develop and sell features related to Grist using a proprietary license, targeted at the -needs of enterprises with large self-managed installations. +needs of enterprises with large self-managed installations. We see data portability and autonomy as a key value, and `grist-core` is an essential part of that. We are committed to maintaining and improving the `grist-core` codebase, and to be thoughtful about how proprietary offerings impact data portability and autonomy. @@ -396,6 +396,7 @@ TYPEORM_PASSWORD | password to use TYPEORM_PORT | port number for db if not the default for that db type TYPEORM_TYPE | set to 'sqlite' or 'postgres' TYPEORM_USERNAME | username to connect as +TYPEORM_EXTRA | any other properties to pass to TypeORM in JSON format #### Testing: diff --git a/app/server/lib/dbUtils.ts b/app/server/lib/dbUtils.ts index ec095dc3..4a823640 100644 --- a/app/server/lib/dbUtils.ts +++ b/app/server/lib/dbUtils.ts @@ -142,6 +142,7 @@ export function getTypeORMSettings(): DataSourceOptions { "subscribers": [ `${codeRoot}/app/gen-server/subscriber/*.js` ], + ...JSON.parse(process.env.TYPEORM_EXTRA || "{}"), ...cache, }; } From 3e39c048c767ac45cb659a3743d061671ad4f985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Sun, 26 Nov 2023 16:16:42 +0000 Subject: [PATCH 05/11] Translated using Weblate (Slovenian) Currently translated at 100.0% (1012 of 1012 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index bf76dc00..8b9015fc 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -99,8 +99,8 @@ "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?": "Želite izbrisati ključ API. To bo povzročilo zavrnitev vseh prihodnjih zahtevkov, ki bodo uporabljali ta ključ API. Ali še vedno želite izbrisati?", "Click to show": "Kliknite za prikaz", "Remove API Key": "Odstranite API ključ", - "This API key can be used to access this account anonymously via the API.": "Ta API ključ lahko uporabite za anonimen dostop do tega računa prek vmesnika API.", - "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "Ta ključ API lahko uporabite za dostop do svojega računa prek vmesnika API. Svojega ključa API ne delite z nikomer.", + "This API key can be used to access this account anonymously via the API.": "Ta ključ lahko uporabite za anonimen dostop do tega računa prek vmesnika API.", + "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "API ključ lahko uporabite za dostop do svojega računa prek API vmesnika. Svojega API ključa ne delite z nikomer.", "By generating an API key, you will be able to make API calls for your own account.": "Z ustvarjanjem API ključa boste lahko uporabljali klice API funkcij za svoj račun." }, "App": { @@ -269,7 +269,7 @@ "TOOLS": "ORODJA", "Settings": "Nastavitve", "Access Rules": "Pravila dostopa", - "Code View": "Pogled kode", + "Code View": "Koda", "Raw Data": "Neobdelani podatki", "Document History": "Zgodovina Dokumentov", "Validate Data": "Potrdi podatke", @@ -406,7 +406,7 @@ }, "CodeEditorPanel": { "Access denied": "Dostop zavrnjen", - "Code View is available only when you have full document access.": "Pogled kode je na voljo le, če imate popoln dostop do dokumenta." + "Code View is available only when you have full document access.": "Koda je na voljo le, če imate popoln dostop do dokumenta." }, "ColorSelect": { "Apply": "Uporabi", From 6282558abd7ea96fa1dd863666ad143caa5ee21e Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 27 Nov 2023 16:20:43 -0500 Subject: [PATCH 06/11] Check gvisor on startup of run.sh, and clean up scripts related to gvisor flags (#760) * Check gvisor on startup * Clear up get_checkpoint_path.sh script, so it doesn't hurt to run it twice --- sandbox/gvisor/get_checkpoint_path.sh | 10 +++++----- sandbox/run.sh | 11 ++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/sandbox/gvisor/get_checkpoint_path.sh b/sandbox/gvisor/get_checkpoint_path.sh index b09caf11..c054b175 100755 --- a/sandbox/gvisor/get_checkpoint_path.sh +++ b/sandbox/gvisor/get_checkpoint_path.sh @@ -31,7 +31,7 @@ function check_gvisor { return fi # Check if a trivial command works under gvisor with the proposed flags. - if runsc --network none "$@" do true 2> /dev/null; then + if runsc --network none "$@" "do" true 2> /dev/null; then export GVISOR_FLAGS="$@" export GVISOR_AVAILABLE=1 fi @@ -40,9 +40,9 @@ function check_gvisor { check_gvisor --unprivileged --ignore-cgroups check_gvisor --unprivileged -# If we can't use --unprivileged, stick with --rootless and no checkpoint -if [[ -z "$GVISOR_FLAGS" ]]; then - check_gvisor --rootless -else +# If we can't use --unprivileged, stick with --rootless. We will not make a checkpoint. +check_gvisor --rootless + +if [[ "$GVISOR_FLAGS" =~ "-unprivileged" ]]; then export GRIST_CHECKPOINT=/tmp/engine_$(echo $PWD | sed "s/[^a-zA-Z0-9]/_/g") fi diff --git a/sandbox/run.sh b/sandbox/run.sh index c213d485..89e24e00 100755 --- a/sandbox/run.sh +++ b/sandbox/run.sh @@ -3,8 +3,17 @@ set -e if [[ "$GRIST_SANDBOX_FLAVOR" = "gvisor" ]]; then - ./sandbox/gvisor/update_engine_checkpoint.sh source ./sandbox/gvisor/get_checkpoint_path.sh + + # Check GVISOR_FLAGS we ended up with. Don't ignore the output, it may be helpful in troubleshooting. + if runsc --network none $GVISOR_FLAGS "do" true; then + echo "gvisor check ok (flags: ${GVISOR_FLAGS})" + else + echo "gvisor check failed (flags: ${GVISOR_FLAGS}); consider different GVISOR_FLAGS or GRIST_SANDBOX_FLAVOR" + exit 1 + fi + + ./sandbox/gvisor/update_engine_checkpoint.sh fi NODE_PATH=_build:_build/stubs:_build/ext node _build/stubs/app/server/server.js From de13a2fd7a1391af70a840a1640f4a46c03843c6 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Tue, 28 Nov 2023 09:28:15 -0500 Subject: [PATCH 07/11] include the @gristlabs/grist-widget package, so Calendar is always available (#745) The Calendar feature was implemented as a custom widget. To make it available offline, we prepare a package that includes it, and add that to Grist. The PluginManager is configured to find it. An optional `GRIST_SKIP_BUNDLED_WIDGETS` flag is added to disable widgets bundled this way from being used. This may be needed by the tests in grist-widget to avoid getting an echo :-) --- README.md | 1 + app/server/lib/FlexServer.ts | 9 +++++++-- app/server/lib/PluginEndpoint.ts | 3 ++- app/server/lib/PluginManager.ts | 20 ++++++++++++++++---- package.json | 1 + yarn.lock | 5 +++++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5118c49a..d2a38ad7 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ GRIST_SERVERS | the types of server to setup. Comma separated values which may c GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN GRIST_SESSION_SECRET | a key used to encode sessions +GRIST_SKIP_BUNDLED_WIDGETS | if set, Grist will ignore any bundled widgets included via NPM packages. GRIST_ANON_PLAYGROUND | When set to 'false' deny anonymous users access to the home page GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymous access at all (features like sharing docs publicly requires authentication) GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 0706bbd7..277d671e 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1998,8 +1998,13 @@ export class FlexServer implements GristServer { // Only used as {userRoot}/plugins as a place for plugins in addition to {appRoot}/plugins const userRoot = path.resolve(process.env.GRIST_USER_ROOT || getAppPathTo(this.appRoot, '.grist')); this.info.push(['userRoot', userRoot]); - - const pluginManager = new PluginManager(this.appRoot, userRoot); + // Some custom widgets may be included as an npm package called @gristlabs/grist-widget. + const bundledRoot = isAffirmative(process.env.GRIST_SKIP_BUNDLED_WIDGETS) ? undefined : path.join( + getAppPathTo(this.appRoot, 'node_modules'), + '@gristlabs', 'grist-widget', 'dist' + ); + this.info.push(['bundledRoot', bundledRoot]); + const pluginManager = new PluginManager(this.appRoot, userRoot, bundledRoot); // `initialize()` is asynchronous and reads plugins manifests; if PluginManager is used before it // finishes, it will act as if there are no plugins. // ^ I think this comment was here to justify calling initialize without waiting for diff --git a/app/server/lib/PluginEndpoint.ts b/app/server/lib/PluginEndpoint.ts index fa04db82..2d8bee14 100644 --- a/app/server/lib/PluginEndpoint.ts +++ b/app/server/lib/PluginEndpoint.ts @@ -46,7 +46,8 @@ function servePluginContent(req: express.Request, res: express.Response, req.get('X-From-Plugin-WebView') === "true" || mimeTypes.lookup(path.extname(pluginPath)) === "application/javascript") { const dirs = pluginManager.dirs(); - const contentRoot = pluginKind === "installed" ? dirs.installed : dirs.builtIn; + const contentRoot = pluginKind === "installed" ? dirs.installed : + (pluginKind === "builtIn" ? dirs.builtIn : dirs.bundled); // Note that pluginPath may not be safe, but `sendFile` with the "root" option restricts // relative paths to be within the root folder (see the 3rd party library unit-test: // https://github.com/pillarjs/send/blob/3daa901cf731b86187e4449fa2c52f971e0b3dbc/test/send.js#L1363) diff --git a/app/server/lib/PluginManager.ts b/app/server/lib/PluginManager.ts index 92861030..35b345d1 100644 --- a/app/server/lib/PluginManager.ts +++ b/app/server/lib/PluginManager.ts @@ -14,9 +14,14 @@ export interface PluginDirectories { */ readonly builtIn?: string; /** - * Directory where user installed plugins are localted. + * Directory where user installed plugins are located. */ readonly installed?: string; + /** + * Yet another option, for plugins that are included + * during a build but not part of the codebase itself. + */ + readonly bundled?: string; } /** @@ -44,10 +49,12 @@ export class PluginManager { * @param {string} userRoot: path to user's grist directory; `null` is allowed, to only uses built in plugins. * */ - public constructor(public appRoot?: string, userRoot?: string) { + public constructor(public appRoot?: string, userRoot?: string, + public bundledRoot?: string) { this._dirs = { installed: userRoot ? path.join(userRoot, 'plugins') : undefined, - builtIn: appRoot ? getAppPathTo(appRoot, 'plugins') : undefined + builtIn: appRoot ? getAppPathTo(appRoot, 'plugins') : undefined, + bundled: bundledRoot ? getAppPathTo(bundledRoot, 'plugins') : undefined, }; } @@ -91,6 +98,11 @@ export class PluginManager { this._entries.push(...await scanDirectory(this._dirs.builtIn, "builtIn")); } + // Load bundled plugins + if (this._dirs.bundled) { + this._entries.push(...await scanDirectory(this._dirs.bundled, "bundled")); + } + if (!process.env.GRIST_EXPERIMENTAL_PLUGINS || process.env.GRIST_EXPERIMENTAL_PLUGINS === '0') { // Remove experimental plugins @@ -130,7 +142,7 @@ export class PluginManager { } -async function scanDirectory(dir: string, kind: "installed"|"builtIn"): Promise { +async function scanDirectory(dir: string, kind: "installed"|"builtIn"|"bundled"): Promise { const plugins: DirectoryScanEntry[] = []; let listDir; diff --git a/package.json b/package.json index 0b6ba4e6..a68d2bc9 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@googleapis/oauth2": "0.2.0", "@gristlabs/connect-sqlite3": "0.9.11-grist.5", "@gristlabs/express-session": "1.17.0", + "@gristlabs/grist-widget": "^0.0.4", "@gristlabs/moment-guess": "1.2.4-grist.1", "@gristlabs/pidusage": "2.0.17", "@gristlabs/sqlite3": "5.1.4-grist.8", diff --git a/yarn.lock b/yarn.lock index a3e0202c..84797afd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -303,6 +303,11 @@ safe-buffer "5.2.0" uid-safe "~2.1.5" +"@gristlabs/grist-widget@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@gristlabs/grist-widget/-/grist-widget-0.0.4.tgz#df50d988bcdf8fc26a876cf23b82e258bbdb0ccc" + integrity sha512-Q0k+GuudU2+0JkuvVkB9UZzqeUKJH8PsaO9ZfxKuqL9/ssIXUd080msB+PJLXB0TU9BkpzPSl7+kLqXTBSnA5g== + "@gristlabs/moment-guess@1.2.4-grist.1": version "1.2.4-grist.1" resolved "https://registry.npmjs.org/@gristlabs/moment-guess/-/moment-guess-1.2.4-grist.1.tgz" From cf0cbb404eb2434b0be76079d2e6193e8f492cb7 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 29 Nov 2023 21:13:29 +0100 Subject: [PATCH 08/11] Allow URLs with only a docID #768 (#771) Co-authored-by: Florent FAYOLLE --- app/client/models/AppModel.ts | 4 +-- app/client/ui/AccountWidget.ts | 2 +- app/client/ui/AppUI.ts | 2 +- app/client/ui/SupportGristNudge.ts | 8 ++--- app/client/ui/SupportGristPage.ts | 2 +- app/common/gristUrls.ts | 6 ++-- app/server/lib/AppEndpoint.ts | 2 +- app/server/lib/Telemetry.ts | 2 +- test/gen-server/seed.ts | 2 +- test/server/lib/Authorizer.ts | 58 ++++++++++++++++-------------- test/server/lib/DocApi.ts | 8 ++--- test/server/lib/Webhooks-Proxy.ts | 8 ++--- 12 files changed, 55 insertions(+), 49 deletions(-) diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index e83e50aa..21882ca5 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -41,7 +41,7 @@ export type PageType = | "billing" | "welcome" | "account" - | "support-grist" + | "support" | "activation"; const G = getBrowserGlobals('document', 'window'); @@ -316,7 +316,7 @@ export class AppModelImpl extends Disposable implements AppModel { } else if (state.account) { return 'account'; } else if (state.supportGrist) { - return 'support-grist'; + return 'support'; } else if (state.activation) { return 'activation'; } else { diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 86150d61..56c8d11d 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -226,7 +226,7 @@ export class AccountWidget extends Disposable { return menuItemLink( t('Support Grist'), cssHeartIcon('💛'), - urlState().setLinkUrl({supportGrist: 'support-grist'}), + urlState().setLinkUrl({supportGrist: 'support'}), testId('usermenu-support-grist'), ); } diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 25ce8a62..13a1bab3 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -77,7 +77,7 @@ function createMainPage(appModel: AppModel, appObj: App) { return dom.create(WelcomePage, appModel); } else if (pageType === 'account') { return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel))); - } else if (pageType === 'support-grist') { + } else if (pageType === 'support') { return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel))); } else if (pageType === 'activation') { return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel))); diff --git a/app/client/ui/SupportGristNudge.ts b/app/client/ui/SupportGristNudge.ts index 77de6c74..3a5de64d 100644 --- a/app/client/ui/SupportGristNudge.ts +++ b/app/client/ui/SupportGristNudge.ts @@ -22,7 +22,7 @@ type ButtonState = | 'expanded'; type CardPage = - | 'support-grist' + | 'support' | 'opted-in'; /** @@ -45,7 +45,7 @@ export class SupportGristNudge extends Disposable { this._buttonState = localStorageObs( `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded' ) as Observable; - this._currentPage = Observable.create(null, 'support-grist'); + this._currentPage = Observable.create(null, 'support'); this._isClosed = Observable.create(this, false); } @@ -122,7 +122,7 @@ export class SupportGristNudge extends Disposable { private _buildCard() { return cssCard( dom.domComputed(this._currentPage, page => { - if (page === 'support-grist') { + if (page === 'support') { return this._buildSupportGristCardContent(); } else { return this._buildOptedInCardContent(); @@ -205,7 +205,7 @@ function helpCenterLink() { function supportGristLink() { return cssLink( t('Support Grist page'), - {href: urlState().makeUrl({supportGrist: 'support-grist'}), target: '_blank'}, + {href: urlState().makeUrl({supportGrist: 'support'}), target: '_blank'}, ); } diff --git a/app/client/ui/SupportGristPage.ts b/app/client/ui/SupportGristPage.ts index f5a50586..72153cd6 100644 --- a/app/client/ui/SupportGristPage.ts +++ b/app/client/ui/SupportGristPage.ts @@ -194,7 +194,7 @@ export class SupportGristPage extends Disposable { const suffix = getPageTitleSuffix(getGristConfig()); switch (page) { case undefined: - case 'support-grist': { + case 'support': { return document.title = `Support Grist${suffix}`; } } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 16bd12f4..1138d077 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -43,7 +43,7 @@ export type ActivationPage = typeof ActivationPage.type; export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password'); export type LoginPage = typeof LoginPage.type; -export const SupportGristPage = StringUnion('support-grist'); +export const SupportGristPage = StringUnion('support'); export type SupportGristPage = typeof SupportGristPage.type; // Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience. @@ -408,8 +408,8 @@ export function decodeUrl(gristConfig: Partial, location: Locat state.activation = ActivationPage.parse(map.get('activation')) || 'activation'; } if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); } - if (map.has('support-grist')) { - state.supportGrist = SupportGristPage.parse(map.get('support-grist')) || 'support-grist'; + if (map.has('support')) { + state.supportGrist = SupportGristPage.parse(map.get('support')) || 'support'; } if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; } if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; } diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index eb6cc661..937303c7 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -212,6 +212,6 @@ export function attachAppEndpoint(options: AttachOptions): void { // The * is a wildcard in express 4, rather than a regex symbol. // See https://expressjs.com/en/guide/routing.html app.get('/doc/:urlId([^/]+):remainder(*)', ...docMiddleware, docHandler); - app.get('/:urlId([^/]{12,})/:slug([^/]+):remainder(*)', + app.get('/:urlId([^/]{12,})(/:slug([^/]+):remainder(*))?', ...docMiddleware, docHandler); } diff --git a/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts index 5ef44844..5bb48a50 100644 --- a/app/server/lib/Telemetry.ts +++ b/app/server/lib/Telemetry.ts @@ -186,7 +186,7 @@ export class Telemetry implements ITelemetry { public addPages(app: express.Application, middleware: express.RequestHandler[]) { if (this._deploymentType === 'core') { - app.get('/support-grist', ...middleware, expressWrap(async (req, resp) => { + app.get('/support', ...middleware, expressWrap(async (req, resp) => { return this._gristServer.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); })); diff --git a/test/gen-server/seed.ts b/test/gen-server/seed.ts index 7ef2d06b..b450e933 100644 --- a/test/gen-server/seed.ts +++ b/test/gen-server/seed.ts @@ -449,7 +449,7 @@ class Seed { const d = new Document(); d.name = doc; d.workspace = w; - d.id = `sample_${docId}`; + d.id = `sampledocid_${docId}`; docId++; await d.save(); const dgrps = await this.createGroups(w); diff --git a/test/server/lib/Authorizer.ts b/test/server/lib/Authorizer.ts index 12625239..d8d6389d 100644 --- a/test/server/lib/Authorizer.ts +++ b/test/server/lib/Authorizer.ts @@ -101,40 +101,46 @@ describe('Authorizer', function() { it.skip("viewer gets redirect by title", async function() { const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, chimpy); assert.equal(resp.status, 200); - assert.equal(getGristConfig(resp.data).assignmentId, 'sample_6'); - assert.match(resp.request.res.responseUrl, /\/doc\/sample_6$/); + assert.equal(getGristConfig(resp.data).assignmentId, 'sampledocid_6'); + assert.match(resp.request.res.responseUrl, /\/doc\/sampledocid_6$/); const resp2 = await axios.get(`${serverUrl}/o/nasa/doc/Pluto`, chimpy); assert.equal(resp2.status, 200); - assert.equal(getGristConfig(resp2.data).assignmentId, 'sample_2'); - assert.match(resp2.request.res.responseUrl, /\/doc\/sample_2$/); + assert.equal(getGristConfig(resp2.data).assignmentId, 'sampledocid_2'); + assert.match(resp2.request.res.responseUrl, /\/doc\/sampledocid_2$/); + }); + + it('viewer loads document without slug in the URL', async function () { + const docId = docs.Bananas.id; + const resp = await axios.get(`${serverUrl}/o/pr/${docId}`, chimpy); + assert.equal(resp.status, 200); }); it("stranger gets consistent refusal regardless of title", async function() { const resp = await axios.get(`${serverUrl}/o/pr/doc/Bananas`, charon); assert.equal(resp.status, 404); - assert.notMatch(resp.data, /sample_6/); + assert.notMatch(resp.data, /sampledocid_6/); const resp2 = await axios.get(`${serverUrl}/o/pr/doc/Bananas2`, charon); assert.equal(resp2.status, 404); - assert.notMatch(resp.data, /sample_6/); + assert.notMatch(resp.data, /sampledocid_6/); assert.deepEqual(withoutTimestamp(resp.data), withoutTimestamp(resp2.data)); }); it("viewer can access title", async function() { - const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, chimpy); + const resp = await axios.get(`${serverUrl}/o/pr/doc/sampledocid_6`, chimpy); assert.equal(resp.status, 200); const config = getGristConfig(resp.data); assert.equal(config.getDoc![config.assignmentId!].name, 'Bananas'); }); it("stranger cannot access title", async function() { - const resp = await axios.get(`${serverUrl}/o/pr/doc/sample_6`, charon); + const resp = await axios.get(`${serverUrl}/o/pr/doc/sampledocid_6`, charon); assert.equal(resp.status, 403); assert.notMatch(resp.data, /Bananas/); }); it("viewer cannot access document from wrong org", async function() { - const resp = await axios.get(`${serverUrl}/o/nasa/doc/sample_6`, chimpy); + const resp = await axios.get(`${serverUrl}/o/nasa/doc/sampledocid_6`, chimpy); assert.equal(resp.status, 404); }); @@ -142,7 +148,7 @@ describe('Authorizer', function() { const cli = await openClient(server, 'chimpy@getgrist.com', 'pr'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.send("openDoc", "sample_6"); + const openDoc = await cli.send("openDoc", "sampledocid_6"); assert.equal(openDoc.error, undefined); assert.match(JSON.stringify(openDoc.data), /Table1/); await cli.close(); @@ -152,7 +158,7 @@ describe('Authorizer', function() { const cli = await openClient(server, 'charon@getgrist.com', 'pr'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.send("openDoc", "sample_6"); + const openDoc = await cli.send("openDoc", "sampledocid_6"); assert.match(openDoc.error!, /No view access/); assert.equal(openDoc.data, undefined); assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/); @@ -163,7 +169,7 @@ describe('Authorizer', function() { const cli = await openClient(server, 'charon@getgrist.com', 'nasa'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.openDocOnConnect("sample_2"); + const openDoc = await cli.openDocOnConnect("sampledocid_2"); assert.equal(openDoc.error, undefined); const nonce = uuidv4(); const applyUserActions = await cli.send("applyUserActions", @@ -182,7 +188,7 @@ describe('Authorizer', function() { const cli = await openClient(server, 'chimpy@getgrist.com', 'nasa'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.openDocOnConnect("sample_2"); + const openDoc = await cli.openDocOnConnect("sampledocid_2"); assert.equal(openDoc.error, undefined); const nonce = uuidv4(); const applyUserActions = await cli.send("applyUserActions", @@ -209,9 +215,9 @@ describe('Authorizer', function() { editor.ignoreTrivialActions(); viewer.ignoreTrivialActions(); stranger.ignoreTrivialActions(); - assert.equal((await editor.send("openDoc", "sample_2")).error, undefined); - assert.equal((await viewer.send("openDoc", "sample_2")).error, undefined); - assert.match((await stranger.send("openDoc", "sample_2")).error!, /No view access/); + assert.equal((await editor.send("openDoc", "sampledocid_2")).error, undefined); + assert.equal((await viewer.send("openDoc", "sampledocid_2")).error, undefined); + assert.match((await stranger.send("openDoc", "sampledocid_2")).error!, /No view access/); const action = [0, [["UpdateRecord", "Table1", 1, {A: "foo"}]]]; assert.equal((await editor.send("applyUserActions", ...action)).error, undefined); @@ -224,7 +230,7 @@ describe('Authorizer', function() { const cli = await openClient(server, 'thumbnail@getgrist.com', 'nasa'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.send("openDoc", "sample_2"); + const openDoc = await cli.send("openDoc", "sampledocid_2"); assert.equal(openDoc.error, undefined); const nonce = uuidv4(); const applyUserActions = await cli.send("applyUserActions", @@ -243,12 +249,12 @@ describe('Authorizer', function() { const cli = await openClient(server, 'charon@getgrist.com', 'nasa'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - const openDoc = await cli.send("openDoc", "sample_2"); + const openDoc = await cli.send("openDoc", "sampledocid_2"); assert.equal(openDoc.error, undefined); const result = await cli.send("fork", 0); assert.equal(result.data.docId, result.data.urlId); const parts = parseUrlId(result.data.docId); - assert.equal(parts.trunkId, "sample_2"); + assert.equal(parts.trunkId, "sampledocid_2"); assert.isAbove(parts.forkId!.length, 4); assert.equal(parts.forkUserId, await dbManager.testGetId('Charon') as number); }); @@ -258,31 +264,31 @@ describe('Authorizer', function() { const cli = await openClient(server, 'anon@getgrist.com', 'nasa'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - let openDoc = await cli.send("openDoc", "sample_2"); + let openDoc = await cli.send("openDoc", "sampledocid_2"); assert.match(openDoc.error!, /No view access/); // grant anon access to doc and retry await dbManager.updateDocPermissions({ userId: await dbManager.testGetId('Chimpy') as number, - urlId: 'sample_2', + urlId: 'sampledocid_2', org: 'nasa' }, {users: {"anon@getgrist.com": "viewers"}}); dbManager.flushDocAuthCache(); - openDoc = await cli.send("openDoc", "sample_2"); + openDoc = await cli.send("openDoc", "sampledocid_2"); assert.equal(openDoc.error, undefined); // make a fork const result = await cli.send("fork", 0); assert.equal(result.data.docId, result.data.urlId); const parts = parseUrlId(result.data.docId); - assert.equal(parts.trunkId, "sample_2"); + assert.equal(parts.trunkId, "sampledocid_2"); assert.isAbove(parts.forkId!.length, 4); assert.equal(parts.forkUserId, undefined); }); it("can set user via GRIST_PROXY_AUTH_HEADER", async function() { // User can access a doc by setting header. - const docUrl = `${serverUrl}/o/pr/api/docs/sample_6`; + const docUrl = `${serverUrl}/o/pr/api/docs/sampledocid_6`; const resp = await axios.get(docUrl, { headers: {'X-email': 'chimpy@getgrist.com'} }); @@ -297,7 +303,7 @@ describe('Authorizer', function() { let cli = await openClient(server, 'chimpy@getgrist.com', 'pr', 'X-email'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - let openDoc = await cli.send("openDoc", "sample_6"); + let openDoc = await cli.send("openDoc", "sampledocid_6"); assert.equal(openDoc.error, undefined); assert.match(JSON.stringify(openDoc.data), /Table1/); await cli.close(); @@ -306,7 +312,7 @@ describe('Authorizer', function() { cli = await openClient(server, 'notchimpy@getgrist.com', 'pr', 'X-email'); cli.ignoreTrivialActions(); assert.equal((await cli.readMessage()).type, 'clientConnect'); - openDoc = await cli.send("openDoc", "sample_6"); + openDoc = await cli.send("openDoc", "sampledocid_6"); assert.match(openDoc.error!, /No view access/); assert.equal(openDoc.data, undefined); assert.match(openDoc.errorCode!, /AUTH_NO_VIEW/); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 102b579a..68133754 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -49,10 +49,10 @@ const support = configForUser('support'); // some doc ids const docIds: { [name: string]: string } = { - ApiDataRecordsTest: 'sample_7', - Timesheets: 'sample_13', - Bananas: 'sample_6', - Antartic: 'sample_11' + ApiDataRecordsTest: 'sampledocid_7', + Timesheets: 'sampledocid_13', + Bananas: 'sampledocid_6', + Antartic: 'sampledocid_11' }; // A testDir of the form grist_test_{USER}_{SERVER_NAME} diff --git a/test/server/lib/Webhooks-Proxy.ts b/test/server/lib/Webhooks-Proxy.ts index d8c45cc8..dfd8e11d 100644 --- a/test/server/lib/Webhooks-Proxy.ts +++ b/test/server/lib/Webhooks-Proxy.ts @@ -21,10 +21,10 @@ const chimpy = configForUser('Chimpy'); // some doc ids const docIds: { [name: string]: string } = { - ApiDataRecordsTest: 'sample_7', - Timesheets: 'sample_13', - Bananas: 'sample_6', - Antartic: 'sample_11' + ApiDataRecordsTest: 'sampledocid_7', + Timesheets: 'sampledocid_13', + Bananas: 'sampledocid_6', + Antartic: 'sampledocid_11', }; let dataDir: string; From 3e264debb62bc8018ea7cb37b763f750a565d107 Mon Sep 17 00:00:00 2001 From: Vincent Viers Date: Tue, 28 Nov 2023 14:09:31 +0000 Subject: [PATCH 09/11] Translated using Weblate (French) Currently translated at 100.0% (1015 of 1015 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/ --- static/locales/fr.client.json | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 164e28c0..6f73cad0 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -205,7 +205,13 @@ "Table ID copied to clipboard": "Identifiant de table copié", "Duplicate Table": "Dupliquer la table", "You do not have edit access to this document": "Vous n’avez pas accès en écriture à ce document", - "Delete {{formattedTableName}} data, and remove it from all pages?": "Supprimer les données de {{formattedTableName}} et les supprimer de toutes les pages ?" + "Delete {{formattedTableName}} data, and remove it from all pages?": "Supprimer les données de {{formattedTableName}} et les supprimer de toutes les pages ?", + "Edit Record Card": "Modifier la vue carte", + "Rename Table": "Renommer la table", + "{{action}} Record Card": "{{action}} la vue carte", + "Record Card": "Vue carte", + "Remove Table": "Supprimer la table", + "Record Card Disabled": "Vue carte désactivée" }, "DocHistory": { "Activity": "Activité", @@ -416,7 +422,9 @@ "Timestamp": "Horodatage", "no reference column": "pas de colonne de référence", "Adding UUID column": "Ajout d'une colonne UUID", - "Adding duplicates column": "Ajouter des colonnes dupliquées" + "Adding duplicates column": "Ajouter des colonnes dupliquées", + "Add formula column": "Ajouter une colonne formule", + "Add column with type": "Ajouter une colonne de type" }, "GristDoc": { "Import from file": "Importer depuis un fichier", @@ -613,7 +621,8 @@ "Duplicate rows_one": "Dupliquer la ligne", "Duplicate rows_other": "Dupliquer les lignes", "Delete": "Supprimer", - "Copy anchor link": "Copier l'ancre" + "Copy anchor link": "Copier l'ancre", + "View as card": "Voir en carte" }, "SelectionSummary": { "Copied to clipboard": "Copié dans le presse-papier" @@ -907,7 +916,8 @@ "Mixed format": "Format composite", "Revert field settings for {{colId}} to common": "Réinitialiser les paramètres par défaut pour {{colId}}", "Save field settings for {{colId}} as common": "Sauvegarder les paramètres pour {{colId}}", - "Use separate field settings for {{colId}}": "Utiliser des paramètres spécifiques pour {{colId}}" + "Use separate field settings for {{colId}}": "Utiliser des paramètres spécifiques pour {{colId}}", + "Changing column type": "Changement du type de colonne" }, "WelcomeTour": { "Customizing columns": "Personnaliser les colonnes", @@ -1053,7 +1063,12 @@ "end dates and event titles. Note each column's type.": "Pour configurer votre calendrier, sélectionnez les colonnes pour les dates de début/fin et le nom de l'évènement. Notez le type de chaque colonne." }, "Calendar": "Calendrier", - "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Impossible de trouver les bonnes colonnes ? Cliquez sur \"Changer de vue\" pour sélectionner la table contenant les évènements." + "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Impossible de trouver les bonnes colonnes ? Cliquez sur \"Changer de vue\" pour sélectionner la table contenant les évènements.", + "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "Un UUID est un texte généré aléatoirement qui est utile pour les identifiants uniques et les clés de jointures.", + "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Les formules supportent beaucoup de fonctions Excel et la syntaxe Python complète. Un assistant IA est disponible sur certaines instances.", + "Lookups return data from related tables.": "Récupère les données d'une table liée.", + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Vous pouvez choisir parmi les widgets disponibles dans le menu déroulant, ou utilisez le votre en fournissant son URL complète.", + "Use reference columns to relate data in different tables.": "Utilisez les colonnes de type Référence pour lier différentes tables entre elles." }, "ColumnTitle": { "Add description": "Ajouter une description", @@ -1228,5 +1243,13 @@ "Welcome back": "Bon retour parmi nous", "You can always switch sites using the account menu.": "Vous pouvez toujours changer d'espace en utilisant le menu du compte.", "You have access to the following Grist sites.": "Vous avez accès aux espaces Grist suivants." + }, + "CardContextMenu": { + "Insert card above": "Insérer une carte au dessus", + "Duplicate card": "Dupliquer la carte", + "Insert card below": "Insérer une carte en dessous", + "Delete card": "Supprimer la carte", + "Copy anchor link": "Copier le lien d'ancrage", + "Insert card": "Insérer une carte" } } From 02c0863cdba3f2dbc4fe2b6c14bbdfb9efae608d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Tue, 28 Nov 2023 21:27:02 +0000 Subject: [PATCH 10/11] Translated using Weblate (Russian) Currently translated at 99.6% (1011 of 1015 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index 2d0f686d..4477fb59 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -564,7 +564,9 @@ "Timestamp": "Метка времени", "Adding UUID column": "Добавление столбца UUID", "Adding duplicates column": "Добавление столбца дубликатов", - "Lookups": "Lookups" + "Lookups": "Lookups", + "Add formula column": "Добавить вычисляемый столбец", + "Add column with type": "Добавить столбец с типом" }, "FilterBar": { "SearchColumns": "Столбцы поиска", @@ -1063,7 +1065,8 @@ "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID - это случайно сгенерированная строка, которая полезна для уникальных идентификаторов и ключевых ссылок.", "Lookups return data from related tables.": "Lookups возвращают данные из связанных таблиц.", "Use reference columns to relate data in different tables.": "Используйте ссылочные столбцы для сопоставления данных в разных таблицах.", - "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Вы можете выбрать виджеты, доступные вам в раскрывающемся списке, или встроить свой собственный, указав его полный URL-адрес." + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Вы можете выбрать виджеты, доступные вам в раскрывающемся списке, или встроить свой собственный, указав его полный URL-адрес.", + "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI." }, "DescriptionConfig": { "DESCRIPTION": "ОПИСАНИЕ" From 048ce8af2dfaa192b1bb0470a8049fe4b0472c33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Wed, 29 Nov 2023 00:54:15 +0000 Subject: [PATCH 11/11] Translated using Weblate (Slovenian) Currently translated at 100.0% (1015 of 1015 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index 8b9015fc..0d2dab04 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -225,7 +225,9 @@ "Duplicate in {{- label}}": "Dvojnik v {{- label}}", "Search columns": "Preišči stolpce", "Adding UUID column": "Dodajanje UUID stolpca", - "Adding duplicates column": "Dodajanje podvojenega stolpca" + "Adding duplicates column": "Dodajanje podvojenega stolpca", + "Add formula column": "Dodaj stolpec z formulo", + "Add column with type": "Dodaj stolpec z tipom" }, "HomeLeftPane": { "Trash": "Koš", @@ -685,7 +687,8 @@ "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID je naključno ustvarjen niz, ki je uporaben za edinstvene identifikatorje in ključe povezav.", "Lookups return data from related tables.": "Iskanje vrne podatke iz povezanih tabel.", "Use reference columns to relate data in different tables.": "Uporabite referenčne stolpce za povezavo podatkov v različnih tabelah.", - "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Izbirate lahko med pripomočki, ki so vam na voljo v spustnem meniju, ali vdelate svojega tako, da navedete njegov polni URL." + "You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Izbirate lahko med pripomočki, ki so vam na voljo v spustnem meniju, ali vdelate svojega tako, da navedete njegov polni URL.", + "Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Formule podpirajo številne Excelove funkcije, polno Pythonovo sintakso in vključujejo koristnega AI pomočnika." }, "UserManager": { "Anyone with link ": "Vsakdo s povezavo ",