diff --git a/README.md b/README.md index c2bbca8b..d2a38ad7 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. @@ -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 @@ -396,6 +397,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/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/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/app/server/lib/Telemetry.ts b/app/server/lib/Telemetry.ts index b45da5ea..c290e556 100644 --- a/app/server/lib/Telemetry.ts +++ b/app/server/lib/Telemetry.ts @@ -187,7 +187,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/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, }; } diff --git a/package.json b/package.json index 9edc8019..a68d2bc9 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'", @@ -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/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 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" 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" } } 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": "ОПИСАНИЕ" diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index bf76dc00..0d2dab04 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": { @@ -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š", @@ -269,7 +271,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 +408,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", @@ -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 ", 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/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(); 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; 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"