Merge branch 'main' into header-from-row

pull/848/head
CamilleLegeron 3 months ago
commit d57f4ab99d

@ -1,8 +1,8 @@
# Welcome to the contribution guide for Grist!
You are eager to contribute to Grist? That's awesome! See below some contributions you can make:
- [translate](/documentation/translate.md)
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help)
- [translate](/documentation/translations.md)
- [write tutorials and user documentation](https://github.com/gristlabs/grist-help?tab=readme-ov-file#grist-help-center)
- [develop](/documentation/develop.md)
- [report issues or suggest enhancement](https://github.com/gristlabs/grist-core/issues/new)

@ -0,0 +1,259 @@
import { AppModel } from 'app/client/models/AppModel';
import { createAppPage } from 'app/client/ui/createAppPage';
import { pagePanels } from 'app/client/ui/PagePanels';
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
const cssBody = styled('div', `
padding: 20px;
overflow: auto;
`);
const cssHeader = styled('div', `
padding: 20px;
`);
const cssResult = styled('div', `
max-width: 500px;
`);
/**
*
* A "boot" page for inspecting the state of the Grist installation.
*
* TODO: deferring using any localization machinery so as not
* to have to worry about its failure modes yet, but it should be
* fine as long as assets served locally are used.
*
*/
export class Boot extends Disposable {
// The back end will offer a set of probes (diagnostics) we
// can use. Probes have unique IDs.
public probes: Observable<BootProbeInfo[]>;
// Keep track of probe results we have received, by probe ID.
public results: Map<string, Observable<BootProbeResult>>;
// Keep track of probe requests we are making, by probe ID.
public requests: Map<string, BootProbe>;
constructor(_appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this,
// based on other similar pages.
document.title = 'Booting Grist';
this.probes = Observable.create(this, []);
this.results = new Map();
this.requests = new Map();
}
/**
* Set up the page. Uses the generic Grist layout with an empty
* side panel, just for convenience. Could be made a lot prettier.
*/
public buildDom() {
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
fetch(url.href).then(async resp => {
const _probes = await resp.json();
this.probes.set(_probes.probes);
}).catch(e => reportError(e));
}
const rootNode = dom('div',
dom.domComputed(
use => {
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false),
hideOpener: true,
header: null,
content: null,
},
headerMain: cssHeader(dom('h1', 'Grist Boot')),
contentMain: this.buildBody(use, {errMessage}),
});
}
),
);
return rootNode;
}
/**
* The body of the page is very simple right now, basically a
* placeholder. Make a section for each probe, and kick them off in
* parallel, showing results as they come in.
*/
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
if (options.errMessage) {
return cssBody(cssResult(this.buildError()));
}
return cssBody([
...use(this.probes).map(probe => {
const {id} = probe;
let result = this.results.get(id);
if (!result) {
result = Observable.create(this, {});
this.results.set(id, result);
}
let request = this.requests.get(id);
if (!request) {
request = new BootProbe(id, this);
this.requests.set(id, request);
}
request.start();
return cssResult(
this.buildResult(probe, use(result), probeDetails[id]));
}),
]);
}
/**
* This is used when there is an attempt to access the boot page
* but something isn't right - either the page isn't enabled, or
* the key in the URL is wrong. Give the user some information about
* how to set things up.
*/
public buildError() {
return dom(
'div',
dom('p',
'A diagnostics page can be made available at:',
dom('blockquote', '/boot/GRIST_BOOT_KEY'),
'GRIST_BOOT_KEY is an environment variable ',
' set before Grist starts. It should only',
' contain characters that are valid in a URL.',
' It should be a secret, since no authentication is needed',
' to visit the diagnostics page.'),
dom('p',
'You are seeing this page because either the key is not set,',
' or it is not in the URL.'),
);
}
/**
* An ugly rendering of information returned by the probe.
*/
public buildResult(info: BootProbeInfo, result: BootProbeResult,
details: ProbeDetails|undefined) {
const out: (HTMLElement|string|null)[] = [];
out.push(dom('h2', info.name));
if (details) {
out.push(dom('p', '> ', details.info));
}
if (result.verdict) {
out.push(dom('pre', result.verdict));
}
if (result.success !== undefined) {
out.push(result.success ? '✅' : '❌');
}
if (result.done === true) {
out.push(dom('p', 'no fault detected'));
}
if (result.details) {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
key,
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
return out;
}
}
/**
* Represents a single diagnostic.
*/
export class BootProbe {
constructor(public id: string, public boot: Boot) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
const ob = boot.results.get(id);
if (ob) {
ob.set(_probes);
}
}).catch(e => console.error(e));
}
public start() {
let result = this.boot.results.get(this.id);
if (!result) {
result = Observable.create(this.boot, {});
this.boot.results.set(this.id, result);
}
}
}
/**
* Create a stripped down page to show boot information.
* Make sure the API isn't used since it may well be unreachable
* due to a misconfiguration, especially in multi-server setups.
*/
createAppPage(appModel => {
return dom.create(Boot, appModel);
}, {
useApi: false,
});
/**
* Basic information about diagnostics is kept on the server,
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
it off when configuration is ok (by unsetting GRIST_BOOT_KEY)
or make GRIST_BOOT_KEY long and cryptographically secure.
`,
},
'health-check': {
info: `
Grist has a small built-in health check often used when running
it as a container.
`,
},
'host-header': {
info: `
Requests arriving to Grist should have an accurate Host
header. This is essential when GRIST_SERVE_SAME_ORIGIN
is set.
`,
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
`,
},
'reachable': {
info: `
The main page of Grist should be available.
`
},
};
/**
* Information about the probe.
*/
interface ProbeDetails {
info: string;
}

@ -2,7 +2,7 @@
* dispose.js provides tools to components that needs to dispose of resources, such as
* destroy DOM, and unsubscribe from events. The motivation with examples is presented here:
*
* https://phab.getgrist.com/w/disposal/
* /documentation/disposal/disposal.md
*/
@ -191,7 +191,7 @@ Object.assign(Disposable.prototype, {
}
// Finish by wiping out the object, since nothing should use it after dispose().
// See https://phab.getgrist.com/w/disposal/ for more motivation.
// See /documentation/disposal.md for more motivation.
wipeOutObject(this);
}
});

@ -146,6 +146,11 @@ export interface AppModel {
switchUser(user: FullUser, org?: string): Promise<void>;
}
export interface TopAppModelOptions {
/** Defaults to true. */
useApi?: boolean;
}
export class TopAppModelImpl extends Disposable implements TopAppModel {
public readonly isSingleOrg: boolean;
public readonly productFlavor: ProductFlavor;
@ -163,14 +168,16 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
// up new widgets - that seems ok.
private readonly _widgets: AsyncCreate<ICustomWidget[]>;
constructor(window: {gristConfig?: GristLoadConfig}, public readonly api: UserAPI = newUserAPIImpl()) {
constructor(window: {gristConfig?: GristLoadConfig},
public readonly api: UserAPI = newUserAPIImpl(),
public readonly options: TopAppModelOptions = {}) {
super();
setErrorNotifier(this.notifier);
this.isSingleOrg = Boolean(window.gristConfig && window.gristConfig.singleOrg);
this.productFlavor = getFlavor(window.gristConfig && window.gristConfig.org);
this._gristConfig = window.gristConfig;
this._widgets = new AsyncCreate<ICustomWidget[]>(async () => {
const widgets = await this.api.getWidgets();
const widgets = this.options.useApi === false ? [] : await this.api.getWidgets();
this.customWidgets.set(widgets);
return widgets;
});
@ -180,7 +187,9 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize()));
this.plugins = this._gristConfig?.plugins || [];
this.fetchUsersAndOrgs().catch(reportError);
if (this.options.useApi !== false) {
this.fetchUsersAndOrgs().catch(reportError);
}
}
public initialize(): void {
@ -237,6 +246,10 @@ export class TopAppModelImpl extends Disposable implements TopAppModel {
private async _doInitialize() {
this.appObs.set(null);
if (this.options.useApi === false) {
AppModelImpl.create(this.appObs, this, null, null, {error: 'no-api', status: 500});
return;
}
try {
const {user, org, orgError} = await this.api.getSessionActive();
if (this.isDisposed()) { return; }

@ -1,6 +1,6 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {setupLocale} from 'app/client/lib/localization';
import {AppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {AppModel, TopAppModelImpl, TopAppModelOptions} from 'app/client/models/AppModel';
import {reportError, setUpErrorHandling} from 'app/client/models/errors';
import {buildSnackbarDom} from 'app/client/ui/NotifyUI';
import {addViewportTag} from 'app/client/ui/viewport';
@ -14,10 +14,12 @@ const G = getBrowserGlobals('document', 'window');
* Sets up the application model, error handling, and global styles, and replaces
* the DOM body with the result of calling `buildAppPage`.
*/
export function createAppPage(buildAppPage: (appModel: AppModel) => DomContents) {
export function createAppPage(
buildAppPage: (appModel: AppModel) => DomContents,
modelOptions: TopAppModelOptions = {}) {
setUpErrorHandling();
const topAppModel = TopAppModelImpl.create(null, {});
const topAppModel = TopAppModelImpl.create(null, {}, undefined, modelOptions);
addViewportTag();
attachCssRootVars(topAppModel.productFlavor);

@ -0,0 +1,22 @@
export type BootProbeIds =
'boot-page' |
'health-check' |
'reachable' |
'host-header' |
'system-user'
;
export interface BootProbeResult {
verdict?: string;
success?: boolean;
done?: boolean;
severity?: 'fault' | 'warning' | 'hmm';
details?: Record<string, any>;
}
export interface BootProbeInfo {
id: BootProbeIds;
name: string;
}

@ -938,7 +938,7 @@ export function extractOrgParts(reqHost: string|undefined, reqPath: string): Org
orgFromHost = getOrgFromHost(reqHost);
if (orgFromHost) {
// Some subdomains are shared, and do not reflect the name of an organization.
// See https://phab.getgrist.com/w/hosting/v1/urls/ for a list.
// See /documentation/urls.md for a list.
if (/^(api|v1-.*|doc-worker-.*)$/.test(orgFromHost)) {
orgFromHost = null;
}

@ -31,7 +31,7 @@ const BLACKLISTED_SUBDOMAINS = new Set([
/**
*
* Checks whether the subdomain is on the list of forbidden subdomains.
* See https://phab.getgrist.com/w/hosting/v1/urls/#organization-subdomains
* See /documentation/urls.md#organization-subdomains
*
* Also enforces various sanity checks.
*

@ -9,7 +9,7 @@ export interface GristTable {
// This is documenting what is currently returned by the core plugins. Capitalization
// is python-style.
//
// TODO: could be worth reconciling with: https://phab.getgrist.com/w/grist_data_format/.
// TODO: could be worth reconciling with: /documentation/grist-data-format.md.
table_name: string | null; // currently allow names to be null
column_metadata: GristColumn[];
table_data: any[][];

@ -0,0 +1,185 @@
import { ApiError } from 'app/common/ApiError';
import { BootProbeIds, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
import { GristServer } from 'app/server/lib/GristServer';
import * as express from 'express';
import fetch from 'node-fetch';
/**
* Self-diagnostics useful when installing Grist.
*/
export class BootProbes {
// List of probes.
public _probes = new Array<Probe>();
// Probes indexed by id.
public _probeById = new Map<string, Probe>();
public constructor(private _app: express.Application,
private _server: GristServer,
private _base: string) {
this._addProbes();
}
public addEndpoints() {
// Return a list of available probes.
this._app.use(`${this._base}/probe$`, expressWrap(async (_, res) => {
res.json({
'probes': this._probes.map(probe => {
return { id: probe.id, name: probe.name };
}),
});
}));
// Return result of running an individual probe.
this._app.use(`${this._base}/probe/:probeId`, expressWrap(async (req, res) => {
const probe = this._probeById.get(req.params.probeId);
if (!probe) {
throw new ApiError('unknown probe', 400);
}
const result = await probe.apply(this._server, req);
res.json(result);
}));
// Fall-back for errors.
this._app.use(`${this._base}/probe`, jsonErrorHandler);
}
private _addProbes() {
this._probes.push(_homeUrlReachableProbe);
this._probes.push(_statusCheckProbe);
this._probes.push(_userProbe);
this._probes.push(_bootProbe);
this._probes.push(_hostHeaderProbe);
this._probeById = new Map(this._probes.map(p => [p.id, p]));
}
}
/**
* An individual probe has an id, a name, an optional description,
* and a method that returns a probe result.
*/
export interface Probe {
id: BootProbeIds;
name: string;
description?: string;
apply: (server: GristServer, req: express.Request) => Promise<BootProbeResult>;
}
const _homeUrlReachableProbe: Probe = {
id: 'reachable',
name: 'Grist is reachable',
apply: async (server, req) => {
const url = server.getHomeUrl(req);
try {
const resp = await fetch(url);
if (resp.status !== 200) {
throw new ApiError(await resp.text(), resp.status);
}
return {
success: true,
};
} catch (e) {
return {
success: false,
details: {
error: String(e),
},
severity: 'fault',
};
}
}
};
const _statusCheckProbe: Probe = {
id: 'health-check',
name: 'Built-in Health check',
apply: async (server, req) => {
const baseUrl = server.getHomeUrl(req);
const url = new URL(baseUrl);
url.pathname = removeTrailingSlash(url.pathname) + '/status';
try {
const resp = await fetch(url);
if (resp.status !== 200) {
throw new Error(`Failed with status ${resp.status}`);
}
const txt = await resp.text();
if (!txt.includes('is alive')) {
throw new Error(`Failed, page has unexpected content`);
}
return {
success: true,
};
} catch (e) {
return {
success: false,
error: String(e),
severity: 'fault',
};
}
},
};
const _userProbe: Probe = {
id: 'system-user',
name: 'System user is sane',
apply: async () => {
if (process.getuid && process.getuid() === 0) {
return {
success: false,
verdict: 'User appears to be root (UID 0)',
severity: 'warning',
};
} else {
return {
success: true,
};
}
},
};
const _bootProbe: Probe = {
id: 'boot-page',
name: 'Boot page exposure',
apply: async (server) => {
if (!server.hasBoot) {
return { success: true };
}
const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10;
return {
success: maybeSecureEnough,
severity: 'hmm',
};
},
};
/**
* Based on:
* https://github.com/gristlabs/grist-core/issues/228#issuecomment-1803304438
*
* When GRIST_SERVE_SAME_ORIGIN is set, requests arriving to Grist need
* to have an accurate Host header.
*/
const _hostHeaderProbe: Probe = {
id: 'host-header',
name: 'Host header is sane',
apply: async (server, req) => {
const host = req.header('host');
const url = new URL(server.getHomeUrl(req));
if (url.hostname === 'localhost') {
return {
done: true,
};
}
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
return {
success: false,
severity: 'hmm',
};
}
return {
done: true,
};
},
};

@ -28,6 +28,7 @@ import {appSettings} from 'app/server/lib/AppSettings';
import {addRequestUser, getTransitiveHeaders, getUser, getUserId, isAnonymousUser,
isSingleUserMode, redirectToLoginUnconditionally} from 'app/server/lib/Authorizer';
import {redirectToLogin, RequestWithLogin, signInStatusMiddleware} from 'app/server/lib/Authorizer';
import {BootProbes} from 'app/server/lib/BootProbes';
import {forceSessionChange} from 'app/server/lib/BrowserSession';
import {Comm} from 'app/server/lib/Comm';
import {create} from 'app/server/lib/create';
@ -175,6 +176,7 @@ export class FlexServer implements GristServer {
private _getLoginSystem?: () => Promise<GristLoginSystem>;
// Set once ready() is called
private _isReady: boolean = false;
private _probes: BootProbes;
constructor(public port: number, public name: string = 'flexServer',
public readonly options: FlexServerOptions = {}) {
@ -481,6 +483,57 @@ export class FlexServer implements GristServer {
});
}
/**
*
* Adds a /boot/$GRIST_BOOT_KEY page that shows diagnostics.
* Accepts any /boot/... URL in order to let the front end
* give some guidance if the user is stumbling around trying
* to find the boot page, but won't actually provide diagnostics
* unless GRIST_BOOT_KEY is set in the environment, and is present
* in the URL.
*
* We take some steps to make the boot page available even when
* things are going wrong, and should take more in future.
*
* When rendering the page a hardcoded 'boot' tag is used, which
* is used to ensure that static assets are served locally and
* we aren't relying on APP_STATIC_URL being set correctly.
*
* We use a boot key so that it is more acceptable to have this
* boot page living outside of the authentication system, which
* could be broken.
*
* TODO: there are some configuration problems that currently
* result in Grist not running at all. ideally they would result in
* Grist running in a limited mode that is enough to bring up the boot
* page.
*
*/
public addBootPage() {
if (this._check('boot')) { return; }
const bootKey = appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
const base = `/boot/${bootKey}`;
this._probes = new BootProbes(this.app, this, base);
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
// a helpful message even if user gets KEY wrong or omits it.
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
const goodKey = bootKey && req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
} : {
errMessage: 'not-the-key',
}, tag: 'boot',
});
});
this._probes.addEndpoints();
}
public hasBoot(): boolean {
return Boolean(this._probes);
}
public denyRequestsIfNotReady() {
this.app.use((_req, res, next) => {
if (!this._isReady) {

@ -60,6 +60,7 @@ export interface GristServer {
getPlugins(): LocalPlugin[];
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
hasBoot(): boolean;
}
export interface GristLoginSystem {
@ -147,6 +148,7 @@ export function createDummyGristServer(): GristServer {
servesPlugins() { return false; },
getPlugins() { return []; },
getBundledWidgets() { return []; },
hasBoot() { return false; },
};
}

@ -139,8 +139,11 @@ export function makeSendAppPage(opts: {
const needTagManager = (options.googleTagManager === 'anon' && isAnonymousUser(req)) ||
options.googleTagManager === true;
const tagManagerSnippet = needTagManager ? getTagManagerSnippet(process.env.GOOGLE_TAG_MANAGER_ID) : '';
const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${options.tag || tag}/`;
const staticTag = options.tag || tag;
// If boot tag is used, serve assets locally, otherwise respect
// APP_STATIC_URL.
const staticOrigin = staticTag === 'boot' ? '' : (process.env.APP_STATIC_URL || '');
const staticBaseUrl = `${staticOrigin}/v/${staticTag}/`;
const customHeadHtmlSnippet = server.create.getExtraHeadHtml?.() ?? "";
const warning = testLogin ? "<div class=\"dev_warning\">Authentication is not enforced</div>" : "";
// Preload all languages that will be used or are requested by client.

@ -104,6 +104,9 @@ export async function main(port: number, serverTypes: ServerType[],
}
server.addHealthCheck();
if (includeHome || includeApp) {
server.addBootPage();
}
server.denyRequestsIfNotReady();
if (includeHome || includeStatic || includeApp) {

@ -14,6 +14,7 @@ module.exports = {
main: "app/client/app",
errorPages: "app/client/errorMain",
apiconsole: "app/client/apiconsole",
boot: "app/client/boot",
billing: "app/client/billingMain",
form: "app/client/formMain",
// Include client test harness if it is present (it won't be in

@ -0,0 +1,148 @@
# Disposal and Cleanup
Garbage-collected languages make you think that you don't need to worry about cleanup for your objects. In reality, there are still often cases when you do. This page gives some examples, and describes a library to simplify it.
## What's the problem
In the examples, we care about a situation when you have a JS object that is responsible for certain UI, i.e. DOM, listening to DOM changes to update state elsewhere, and listening to outside changes to update state to the DOM.
### DOM Elements
So this JS object knows how to create the DOM. Removing the DOM, when the component is to be removed, is usually easy: `parentNode.removeNode(child)`. Since it's a manual operation, you may define some method to do this, named perhaps "destroy" or "dispose" or "cleanup".
If there is logic tied to your DOM either via JQuery events, or KnockoutJS bindings, you'll want to clean up the node specially: for JQuery, use `.remove()` or `.empty()` methods; for KnockoutJS, use `ko.removeNode()` or `ko.cleanNode()`. KnockoutJS's methods automatically call JQuery-related cleanup functions if JQuery is loaded in the page.
### Subscriptions and Computed Observables
But there is more. Consider this knockout code, adapted from their simplest example of a computed observable:
function FullNameWidget(firstName, lastName) {
this.fullName = ko.computed(function() {
return firstName() + " " + lastName();
});
...
}
Here we have a constructor for a component which takes two observables as constructor parameters, and creates a new observable which depends on the two inputs. Whenever `firstName` or `lastName` changes, `this.fullName` get recomputed. This makes it easy to create knockout-based bindings, e.g. to have a DOM element reflect the full name when either first or last name changes.
Now, what happens when this component is destroyed? It removes its associated DOM. Now when `firstName` or `lastName` change, there are no visible changes. But the function to recompute `this.fullName` still gets called, and still retains a reference to `this`, preventing the object from being garbage-collected.
The issue is that `this.fullName` is subscribed to `firstName` and `lastName` observables. It needs to be unsubscribed when the component is destroyed.
KnockoutJS recognizes it, and makes it easy: just call `this.firstName.dispose()`. We just have to remember to do it when we destroy the component.
This situation would exist without knockout too: the issue is that the component is listening to external changes to update the DOM that it is responsible for. When the component is gone, it should stop listening.
### Tying life of subscriptions to DOM
Since the situation above is so common in KnockoutJS, it offers some assistance. Specifically, when a computed observable is created using knockout's own binding syntax (by specifying a JS expression in an HTML attribute), knockout will clean it up automatically when the DOM node is removed using `ko.removeNode()` or `ko.cleanNode()`.
Knockout also allows to tie other cleanup to DOM node removal, documented at [Custom disposal logic](http://knockoutjs.com/documentation/custom-bindings-disposal.html) page.
In the example above, you could use `ko.utils.domNodeDisposal.addDisposeCallback(node, function() { self.fullName.dispose(); })`, and when you destroy the component and remove the `node` via `ko.removeNode()` or `ko.cleanNode()`, the `fullName` observable will be properly disposed.
### Other knockout subscriptions
There are other situations with subscriptions. For example, we may want to subscribe to a `viewId` observable, and when it changes, replace the currently-rendered View component. This might look like so
function GristDoc() {
this.viewId = ko.observable();
this.viewId.subscribe(function(viewId) {
this.loadView(viewId);
}, this);
}
Once GristDoc is destroyed, the subscription to `this.viewId` still exists, so `this.viewId` retains a reference to `this` (for calling the callback). Technically, there is no problem: as long as there are no references to `this.viewId` from outside this object, the whole cycle should be garbage-collected.
But it's very risky: if anything else has a reference to `this.viewId` (e.g. if `this.viewId` is itself subscribed to, say, `window.history` changes), then the entire `GristDoc` is unavailable to garbage-collection, including all the DOM to which it probably retains references even after that DOM is detached from the page.
Beside the memory leak, it means that when `this.viewId` changes, it will continue calling `this.loadView()`, continuing to update DOM that no one will ever see. Over time, that would of course slow down the browser, but would be hard to detect and debug.
Again, KnockoutJS offers a way to unsubscribe: `.subscribe()` returns a `ko.subscription` object, which in turn has a `dispose()` method. We just need to call it, and the callback will be unsubscribed.
### Backbone Events
To be clear, the problem isn't with Knockout, it's with the idea of subscribing to outside events. Backbone allows listening to events, which creates the same problem, and Backbone offers a similar solution.
For example, let's say you have a component that listens to an outside event and does stuff. With a made-up example, you might have a constructor like:
function Game(basket) {
basket.on('points:scored', function(team, points) {
// Update UI to show updated points for the team.
});
}
Let's say that a `Game` object is destroyed, and a new one created, but the `basket` persists across Games. As the user continues to score points on the basket, the old (supposedly destroyed) Game object continues to have that inline callback called. It may not be showing anything, but only because the DOM it's updating is no longer attached to the page. It's still taking resources, and may even continue to send stuff to the server.
We need to clean up when we destroy the Game object. In this example, it's pretty annoying. We'd have to save the `basket` object and callback in member variables (like `this.basket`, `this.callback`), so that in the cleanup method, we could call `this.basket.off('points:scored', this.callback)`.
Many people have gotten bitten with that in Backbone (see this [stackoverflow post](http://stackoverflow.com/questions/14041042/backbone-0-9-9-difference-between-listento-and-on)) with a bunch of links to blog posts about it).
Backbone's solution is `listenTo()` method. You'd use it like so:
function Game(basket) {
this.listenTo(basket, 'points:scored', function(team, points) {
// Update UI to show updated points for the team.
});
}
Then when you destroy the Game object, you only have to call `this.stopListening()`. It keeps track of what you listened to, and unsubscribes. You just have to remember to call it. (Certain objects in Backbone will call `stopListening()` automatically when they are being cleaned up.)
### Internal events
If a component listens to an event on a DOM element it itself owns, and if it's using JQuery, then we don't need to do anything special. If on destruction of the component, we clean up the DOM element using `ko.removeNode()`, the JQuery event bindings should automatically be removed. (This hasn't been rigorously verified, but if correct, is a reason to use JQuery for browser events rather than native `addEventListener`.)
## How to do cleanup uniformly
Since we need to destroy the components' DOM explicitly, the components should provide a method to call for that. By analogy with KnockoutJS, let's call it `dispose()`.
- We know that it needs to remove the DOM that the component is responsible for, probably using `ko.removeNode`.
- If the component used Backbone's `listenTo()`, it should call `stopListening()` to unsubscribe from Backbone events.
- If the component maintains any knockout subscriptions or computed observables, it should call `.dispose()` on them.
- If the component owns other components, then those should be cleaned up recursively, by calling `.dispose()` on those.
The trick is how to make it easy to remember to do all necessary cleanup. I propose keeping track when the object to clean up first enters the picture.
## 'Disposable' class
The idea is to have a class that can be mixed into (or inherited by) any object, and whose purpose is to keep track of things this object "owns", that it should be responsible for cleaning up. To combine the examples above:
function Component(firstName, lastName, basket) {
this.fullName = this.autoDispose(ko.computed(function() {
return firstName() + " " + lastName();
}));
this.viewId = ko.observable();
this.autoDispose(this.viewId.subscribe(function(viewId) {
this.loadView(viewId);
}, this));
this.ourDom = this.autoDispose(somewhere.appendChild(some_dom_we_create));
this.listenTo(basket, 'points:scored', function(team, points) {
// Update UI to show updated points for the team.
});
}
Note the `this.autoDispose()` calls. They mark the argument as being owned by `this`. When `this.dispose()` is called, those values get disposed of as well.
The disposal itself is fairly straightforward: if the object has a `dispose` method, we'll call that. If it's a DOM node, we'll call `ko.removeNode` on it. The `dispose()` method of Disposable objects will always call `this.stopListening()` if such a method exists, so that subscriptions using Backbone's `listenTo` are cleaned up automatically.
To do additional cleanup when `dispose()` is called, the derived class can override `dispose()`, do its other cleanup, then call `Disposable.prototype.dispose.call(this)`.
For convenience, Disposable class provides a few other methods:
- `disposeRelease(part)`: releases an owned object, so that it doesn't get auto-disposed.
- `disposeDiscard(part)`: disposes of an owned object early (rather than wait for `this.dispose`).
- `isDisposed()`: returns whether `this.dispose()` has already been called.
### Destroying destroyed objects
There is one more thing that Disposable class's `dispose()` method will do: destroy the object, as in ruin, wreck, wipe out. Specifically, it will go through all properties of `this`, and set each to a junk value. This achieves two goals:
1. In any of the examples above, if you forgot to mark anything with `this.autoDispose()`, and some callback continues to be called after the object has been destroyed, you'll get errors. Not just silent waste of resources that slow down the site and are hard to detect.
2. It removes references, potentially breaking references. Imagine that something wrongly retains a reference to a destroyed object (which logically nothing should, but something might by mistake). If it tries to use the object, it will fail (see point 1). But even if it doesn't access the object, it's preventing the garbage collector from cleaning any of the object. If we break references, then in this situation the GC can still collect all the properties of the destroyed object.
## Conclusion
All JS client-side components that need cleanup (e.g. maintain DOM, observables, listen to events, or subscribe to anything), should inherit from `Disposable`. To destroy them, call their `.dispose()` method. Whenever they take responsibility for any piece that requires cleanup, they should wrap that piece in `this.autoDispose()`.
This should go a long way towards avoiding leaks and slowdowns.

@ -0,0 +1,218 @@
# Grist Data Format
Grist Data Format is used to send and receive data from a Grist document. For example, an implementer of an import module would need to translate data to Grist Data Format. A user of Grist Basket APIs would fetch and upload data in Grist Data Format.
The format is optimized for tabular data. A table consists of rows and columns, containing a single value for each row for each column. Various types are supported for the values.
Each column has a name and a type. The type is not strict: a column may contain values of other types. However, the type is the intended type of the value for that column, and allows those values to be represented more efficiently.
Grist Data Format is readily serialized to JSON. Other serializations are possible, for example, see below for a .proto file that allows to serialize Grist Data Format as a protocol buffer.
## Format Specification
### Document
At the top, Grist Data Format is a Document object with a single key “tables” mapping to an array of Tables:
```javascript
{
tables: [Tables…]
}
```
### Table
```javascript
{
name: "TableName",
colinfo: [ColInfo…],
columns: ColData
}
```
The `name` is the name of the table. The `colinfo` array has an item to describe each column, and `columns` is the actual table data in column-oriented layout.
### ColInfo
```javascript
{
name: "ColName",
type: "ColType",
options: <arbitrary options>
}
```
The `name` is the name of the column, and `type` is its type. The field `options` optionally specifies type-specific options that affect the column (e.g. the number of decimal places to display for a floating-point number).
### ColData
```javascript
{
<colName1>: ColValues,
<colName2>: ColValues,
...
}
```
The data in the table is represented as an object mapping a column name to an array of values for the column. This column-oriented representation allows for the representation of data to be more concise.
### ColValues
```javascript
[CellValue, CellValue, ...]
```
ColValues is an array of all values for the column. We'll refer to the type of each value as `CellValue`. ColValues has an entry for each row in the table. In particular, each ColValues array in a ColData object has the same number of entries.
### CellValue
CellValue represents the value in one cell. We support various types of values, documented below. When represented as JSON, CellValue is one of the following JSON types:
- string
- number
- bool
- null
- array of the form `[typeCode, args...]`
The interpretation of CellValue is affected by the columns type, and described in more detail below.
## JSON Schema
The description above can be summarized by this JSON Schema:
```json
{
"definitions": {
"Table": {
"type": "object",
"properties": {
"name": { "type": "string" },
"colinfo": { "type": "array", "items": { "$ref": "#/definitions/ColInfo" } }
"columns": { "$ref": "#/definitions/ColData" }
}
},
"ColInfo": {
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"options": { "type": "object" }
}
},
"ColData": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/ColValues" }
},
"ColValues": {
"type": "array",
"items": { "type": "CellValue" }
}
},
"type": "object",
"properties": {
"tables": { "type": "array", "items": { "$ref": "#/definitions/Table" } }
}
}
```
## Record identifiers
Each table should have a column named `id`, whose values should be unique across the table. It is used to identify records in queries and actions. Its details, including its type, are left for now outside the scope of this specification, because the format isn't affected by them.
## Naming
Names for tables and columns must consist of alphanumeric ASCII characters or underscore (i.e. `[0-9a-zA-Z_]`). They may not start with an underscore or a digit. Different tables and different columns within a table must have unique names case-insensitively (i.e. they cannot differ in case only).
Certain names (`id` being one of them) may be reserved, e.g. by Grist, for internal purposes, and would not be usable for user data. Such restrictions are outside the scope of this specification.
Note that this combination of rules allows tables and column names to be valid identifiers in pretty much every programming language (including Python and Javascript), as well as valid names of columns in databases.
## Value Types
The format supports a number of data types. Some types have a short representation (e.g. `Numeric` as a JSON `number`, and `Text` as a JSON `string`), but all types have an explicit representation as well.
The explicit representation of a value is an array `[typeCode, args...]`. The first member of the array is a string code that defines the type of the value. The rest of the elements are arguments used to construct the actual value.
The following table lists currently supported types and their short and explicit representations.
| **Type Name** | **Short Repr** | **[Type Code, Args...]** | **Description** |
| `Numeric` | `number`* | `['n',number]` | double-precision floating point number |
| `Text` | `string`* | `['s',string]` | Unicode string |
| `Bool` | `bool`* | `['b',bool]` | Boolean value (true or false) |
| `Null` | `null`* | `null` | Null value (no special explicit representation) |
| `Int` | `number` | `['i',number]` | 32-bit integer |
| `Date` | `number` | `['d',number]` | Calendar date, represented as seconds since Epoch to 00:00 UTC on that date. |
| `DateTime` | `number` | `['D',number]` | Instance in time, represented as seconds since Epoch |
| `Reference` | `number` | `['R',number]` | Identifier of a record in a table. |
| `ReferenceList` | | `['L',number,...]` | List of record identifiers |
| `Choice` | `string` | `['C',string]` | Unicode string selected from a list of choices. |
| `PositionNumber` | `number` | `['P',number]` | a double used to order records relative to each other. |
| `Image` | | `['I',string]` | Binary data representing an image, encoded as base64 |
| `List` | | `['l',values,...]` | List of values of any type. |
| `JSON` | | `['J',object]` | JSON-serializable object |
| `Error` | | `['E',string,string?,value?]` | Exception, with first argument exception type, second an optional message, and optionally a third containing additional info. |
An important goal is to represent data efficiently in the common case. When a value matches the column's type, the short representation is used. For example, in a Numeric column, a Numeric value is represented as a `number`, and in a Date column, a Date value is represented as a `number`.
If a value does not match the column's type, then the short representation is used when it's one of the starred types in the table AND the short type is different from the column's short type.
For example:
- In a Numeric column, Numeric is `number`, Text is `string` (being a starred type), but a Date is `['d',number]`.
- In a Date column, Date is `number`, and Numeric value is `['n',number]`, because even though it's starred, it conflicts with Date's own short type.
- In a Text column, Text is `string`, Numeric is `number` (starred), and Date is `['d',number]` (not starred).
Note how for the common case of a value matching the column's type, we can always use the short representation. But the format still allows values to have an explicit type that's different from the specified one.
Note also that columns of any of the starred types use the same interpretation for contained values.
The primary use case is to allow, for example, storing a value like "N/A" or "TBD" or "Ask Bob" in a column of type Numeric or Date. Another important case is to store errors produced by a computation.
Other complex types may be added in the future.
## Column Types
Any of the types listed in the table above may be specified as a column type.
In addition, a column type may specify type `Any`. For the purpose of type interpretations, it works the same as any of the starred types, but it does not convey anything about the expected type of value for the column.
## Other serializations
Grist Data Format is naturally serialized to JSON, which is fast and convenient to use in Javascript code. It is also possible to serialize it in other ways, e.g. as a Google protobuf.
Here is a `.proto` definition file that allows for efficient protobuf representation of data in Grist Data Format.
```proto
message Document {
repeated Table tables = 1;
}
message Table {
string name = 1;
repeated ColInfo colinfo = 2;
repeated ColData columns = 3;
}
message ColInfo {
string name = 1;
string type = 2;
string options = 3;
}
message ColData {
repeated Value value = 1;
}
message Value {
oneof value {
double vNumeric = 1;
string vText = 2;
bool vBool = 3;
// Absence of a set field represents a null
int32 vInt = 5;
double vDate = 6;
double vDateTime = 7;
int32 vReference = 8;
List vReferenceList = 9;
string vChoice = 10;
double vPositionNumber = 11;
bytes vImage = 12;
List vList = 13;
string vJSON = 14;
List vError = 15;
}
}
message ValueList {
repeated Value value = 1;
}
```

@ -0,0 +1,42 @@
# Migrations
If you change Grist schema, i.e. the schema of the Grist metadata tables (in `sandbox/grist/schema.py`), you'll have to increment the `SCHEMA_VERSION` (on top of that file) and create a migration. A migration is a set of actions that would get applied to a document at the previous version, to make it satisfy the new schema.
To add a migration, add a function to `sandbox/grist/migrations.py`, of this form (using the new version number):
```lang=python
@migration(schema_version=11)
def migration11(tdset):
return tdset.apply_doc_actions([
add_column('_grist_Views_section', 'embedId', 'Text'),
])
```
Some migrations need to actually add or modify the data in a document. You can look at other migrations in that file for examples.
If you are doing anything other than adding a column or a table, you must read this document to the end.
## Philosophy of migrations
Migrations are tricky. Normally, we think about the software we are writing, but migrations work with documents that were created by an older version of the software, which may not have the logic you think our software has, and MAY have logic that the current version knows nothing about.
This is why migrations code uses its own "dumb" implementation for loading and examining data (see `sandbox/grist/table_data_set.py`), because trying to load an older document using our primary code base will usually fail, since the document will not satisfy our current assumptions.
## Restrictions
The rules below should make it at least barely possible to share documents by people who are not all on the same Grist version (even so, it will require more work). It should also make it somewhat safe to upgrade and then open the document with a previous version.
WARNING: Do not remove, modify, or rename metadata tables or columns.
Mark old columns and tables as deprecated using a comment. We may want to add a feature to mark them in code, to prevent their use in new versions. For now, it's enough to add a comment and remove references to the deprecated entities throughout code. An important goal is to prevent adding same-named entities in the future, or reusing the same column with a different meaning. So please add a comment of the form:
```lang=python
# <columnName> is deprecated as of version XX. Do not remove or reuse.
```
To justify keeping old columns around, consider what would happen if A (at version 10) communicates with B (at version 11). If column "foo" exists in v10, and is deleted in v11, then A may send actions that refer to "foo", and B would consider them invalid, since B's code has no idea what "foo" is. The solution is that B needs to still know about "foo", hence we don't remove old columns.
Similar justification applies to renaming columns, or modifying them (e.g. changing a type).
WARNING: If you change the meaning or type of a column, you have to create a new column with a new name.
You'll also need to write a migration to fill it from the old column, and would mark the old column as deprecated.

@ -0,0 +1,53 @@
Document URLs
-----------------
Status: WIP
Options
* An id (e.g. google)
* Several ids (e.g. airtable)
* A text name
* Several text names (e.g. github)
* An id and friendly name (e.g. dropbox)
Leaning towards an id and friendly name. Only id is interpreted by router. Name is checked only to make sure it matches current name of document. If not, we redirect to revised url before proceeding.
Length of ids depends on whether we'll be using them for obscurity to enable anyone-who-has-link-can-view style security.
Possible URLs
---------------
* docs.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name
* orgname.getgrist.com/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/tblWVZDtvlsIFsuOR/viwpHfmtMHmKBUSyh/Document+Name
* getgrist.com/d/dd5bf494e709246c7601e27722e3aee656b900082c3f5f1598ae1475c35c2c4b/Document+Name
* getgrist.com/doc/fTSIMrZT3fDTvW7XDBq1b7nhWa24Zl55EVpsaO3TBBE/Document%20Name
Organization subdomains
------------------------------
Organizations get to choose a subdomain, and will access their workspaces and documents at `orgname.getgrist.com`. In addition, personal workspaces need to be uniquely determined by a URL, using `docs-` followed by the numeric id of the "personal organization":
* docs-1234.getgrist.com/
* docs.getgrist.com/o/docs-1234/
Since subdomains need to play along with all the other subdomains we use for getgrist.com, the following is a list of names that may NOT be used by any organization:
* `docs-\d+` to identify personal workspaces
* Anything that starts with underscore (`_`) (this includes special subdomains like `_domainkey`)
* Subdomains used by us for various purposes. As of 2018-10-09, these include:
* aws
* gristlogin
* issues
* metrics
* phab
* releases
* test
* vpn
* www
Some more reserved subdomains:
* doc-worker-NN
* v1-* (this could be released eventually, but currently in our code and/or routing "v1-mock", "v1-docs", "v1-static", and any other "v1-*" are special
* docs
* api

@ -4,7 +4,7 @@ create tables, add and remove columns, etc, Grist stores various document metada
users' tables, views, etc.) also in tables.
Before changing this file, please review:
https://phab.getgrist.com/w/migrations/
/documentation/migrations.md
"""

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<!-- INSERT LOCALE -->
<!-- INSERT CONFIG -->
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<script crossorigin="anonymous" src="boot.bundle.js"></script>
</body>
</html>

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Beim Hinzufügen von Tabellenregeln wird automatisch eine Regel hinzugefügt, um BESITZER vollen Zugriff zu gewähren.",
"Permission to edit document structure": "Berechtigung zur Bearbeitung der Dokumentenstruktur",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Erlauben Sie Editoren, Struktur zu bearbeiten (z.B. Tabellen, Spalten, Layouts zu ändern und zu löschen) und Formeln zu schreiben, die unabhängig von Leseeinschränkungen Zugriff auf alle Daten geben.",
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. "
"This default should be changed if editors' access is to be limited. ": "Diese Standardeinstellung sollte geändert werden, wenn der Zugriff von Editoren eingeschränkt werden soll. ",
"Add Table-wide Rule": "Tabellenweite Regel hinzufügen"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "Dokument-ID in die Zwischenablage kopiert",
"Ok": "OK",
"Webhooks": "Webhaken",
"Manage Webhooks": "Webhaken verwalten"
"Manage Webhooks": "Webhaken verwalten",
"API Console": "API-Konsole"
},
"DocumentUsage": {
"Attachments Size": "Größe der Anhänge",
@ -464,7 +466,19 @@
"Add column": "Spalte hinzufügen",
"Last updated by": "Zuletzt aktualisiert von",
"Detect duplicates in...": "Erkennen Sie Duplikate in...",
"Last updated at": "Zuletzt aktualisiert am"
"Last updated at": "Zuletzt aktualisiert am",
"Reference List": "Referenzliste",
"Text": "Text",
"Date": "Datum",
"DateTime": "DatumUhrzeit",
"Choice": "Auswahl",
"Choice List": "Auswahlliste",
"Reference": "Referenz",
"Attachment": "Anhang",
"Any": "Jegliche",
"Numeric": "Numerisch",
"Integer": "Ganze Zahl",
"Toggle": "Umschalten"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Neuer verlinkter Abschnitt zur Ansicht hinzugefügt {{viewName}}",
@ -667,7 +681,24 @@
"Fields_one": "Feld",
"Fields_other": "Felder",
"Add referenced columns": "Referenzspalten hinzufügen",
"Reset form": "Formular zurücksetzen"
"Reset form": "Formular zurücksetzen",
"Enter text": "Text eingeben",
"Layout": "Layout",
"Submission": "Einreichung",
"Redirect automatically after submission": "Nach Eingabe automatisch umleiten",
"Redirection": "Umleitung",
"Submit another response": "Eine weitere Antwort einreichen",
"Required field": "Erforderliches Feld",
"Table column name": "Name der Spalte in der Tabelle",
"Enter redirect URL": "Weiterleitungs-URL eingeben",
"Display button": "Anzeigetaste",
"Field rules": "Feldregeln",
"Success text": "Erfolgstext",
"Configuration": "Konfiguration",
"Default field value": "Standard-Feldwert",
"Field title": "Feldtitel",
"Hidden field": "Verborgenes Feld",
"Submit button label": "Beschriftung der Schaltfläche Senden"
},
"RowContextMenu": {
"Copy anchor link": "Ankerlink kopieren",
@ -813,7 +844,8 @@
"Show raw data": "Rohdaten anzeigen",
"Widget options": "Widget Optionen",
"Add to page": "Zur Seite hinzufügen",
"Collapse widget": "Widget einklappen"
"Collapse widget": "Widget einklappen",
"Create a form": "Formular erstellen"
},
"ViewSectionMenu": {
"(customized)": "(angepasst)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Sie haben keinen Zugriff auf die Dokumente dieser Organisation.",
"Account deleted{{suffix}}": "Konto gelöscht{{suffix}}",
"Your account has been deleted.": "Ihr Konto wurde gelöscht.",
"Sign up": "Anmelden"
"Sign up": "Anmelden",
"An unknown error occurred.": "Ein unbekannter Fehler ist aufgetreten.",
"Powered by": "Angetrieben durch",
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Form not found": "Formular nicht gefunden"
},
"menus": {
"* Workspaces are available on team plans. ": "* Arbeitsbereiche sind in Teamplänen verfügbar. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Abbrechen",
"Ok": "OK",
"Save": "Speichern"
"Save": "Speichern",
"Delete": "Löschen",
"Are you sure you want to delete these records?": "Sind Sie sicher, dass Sie diese Datensätze löschen wollen?",
"Are you sure you want to delete this record?": "Sind Sie sicher, dass Sie diesen Datensatz löschen wollen?",
"Undo to restore": "Rückgängig machen zum Wiederherstellen",
"Don't show again": "Nicht mehr anzeigen",
"Got it": "Verstanden",
"Dismiss": "Ablehnen",
"Don't ask again.": "Frag nicht mehr.",
"Don't show again.": "Zeig nicht mehr.",
"Don't show tips": "Keine Tipps anzeigen"
},
"pages": {
"Duplicate Page": "Seite duplizieren",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "Lookups geben Daten aus Bezugstabellen zurück.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Sie können aus Widgets wählen, die Ihnen im Dropdown zur Verfügung stehen, oder Sie selbst einbetten, indem Sie seine volle URL angeben.",
"Use reference columns to relate data in different tables.": "Verwenden Sie Referenzspalten, um Daten in verschiedenen Tabellen zu beziehen.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Die Formeln unterstützen viele Excel-Funktionen, die vollständige Python-Syntax und enthalten einen hilfreichen KI-Assistenten.",
"Learn more": "Mehr erfahren",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Erstellen Sie einfache Formulare direkt in Grist und teilen Sie sie mit einem Klick mit unserem neuen Widget. {{learnMoreButton}}",
"Forms are here!": "Die Formulare sind da!",
"These rules are applied after all column rules have been processed, if applicable.": "Diese Regeln werden angewendet, nachdem alle Spaltenregeln abgearbeitet wurden, falls zutreffend."
},
"DescriptionConfig": {
"DESCRIPTION": "BESCHREIBUNG"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Ausgeblendete Felder"
},
"FormView": {
"Publish your form?": "Ihr Formular veröffentlichen?",
"Unpublish": "Unveröffentlichen",
"Unpublish your form?": "Ihr Formular unveröffentlichen?",
"Publish": "Veröffentlichen"
},
"Editor": {
"Delete": "Löschen"
},
"Menu": {
"Building blocks": "Bausteine",
"Unmapped fields": "Nicht zugeordnete Felder",
"Separator": "Abscheider",
"Header": "Kopfzeile",
"Cut": "Schneiden",
"Insert question above": "Frage oben einfügen",
"Insert question below": "Frage unten einfügen",
"Paragraph": "Absatz",
"Paste": "Einfügen",
"Columns": "Spalten",
"Copy": "Kopieren"
},
"FormContainer": {
"Build your own form": "Erstellen Sie Ihr eigenes Formular",
"Powered by": "Angetrieben durch"
},
"FormErrorPage": {
"Error": "Fehler"
},
"FormModel": {
"Oops! This form is no longer published.": "Huch! Dieses Formular wird nicht mehr veröffentlicht.",
"Oops! The form you're looking for doesn't exist.": "Huch! Das Formular, das Sie suchen, existiert nicht.",
"You don't have access to this form.": "Sie haben keinen Zugang zu diesem Formular.",
"There was a problem loading the form.": "Beim Laden des Formulars ist ein Problem aufgetreten."
},
"WelcomeCoachingCall": {
"free coaching call": "kostenloser Coaching-Anruf",
"Schedule Call": "Anruf planen",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Planen Sie Ihre {{freeCoachingCall}} mit einem Mitglied unseres Teams.",
"Maybe Later": "Vielleicht später",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Während des Gesprächs nehmen wir uns die Zeit, Ihre Bedürfnisse zu verstehen und das Gespräch auf Sie zuzuschneiden. Wir können Ihnen die Grundlagen von Grist zeigen oder sofort mit Ihren Daten arbeiten, um die von Ihnen benötigten Dashboards zu erstellen."
},
"UnmappedFieldsConfig": {
"Clear": "Leeren",
"Map fields": "Felder zuordnen",
"Unmap fields": "Felder freigeben",
"Unmapped": "Nicht zugeordnet",
"Mapped": "Zugeordnet",
"Select All": "Alle auswählen"
},
"FormConfig": {
"Field rules": "Feldregeln",
"Required field": "Erforderliches Feld"
},
"CustomView": {
"Some required columns aren't mapped": "Einige erforderliche Spalten sind nicht zugeordnet",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Um dieses Widget zu verwenden, ordnen Sie bitte alle nicht-optionalen Spalten im Ersteller-Panel auf der rechten Seite zu."
},
"FormSuccessPage": {
"Form Submitted": "Formular eingereicht",
"Thank you! Your response has been recorded.": "Danke! Ihre Antwort wurde aufgezeichnet."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
}
}

@ -37,7 +37,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Al agregar reglas de tabla, agregue automáticamente una regla para otorgar acceso completo al PROPIETARIO.",
"Permission to edit document structure": "Permiso para editar la estructura del documento",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permitir a los editores editar la estructura (por ejemplo, modificar y eliminar tablas, columnas, diseños), y escribir fórmulas, que dan acceso a todos los datos independientemente de las restricciones de lectura.",
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. "
"This default should be changed if editors' access is to be limited. ": "Este valor predeterminado debe cambiarse si se quiere limitar el acceso de los editores. ",
"Add Table-wide Rule": "Añadir regla a toda la tabla"
},
"AccountPage": {
"API": "API",
@ -1176,7 +1177,8 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Las fórmulas admiten muchas funciones de Excel, sintaxis completa de Python e incluyen un útil asistente de inteligencia artificial.",
"Forms are here!": "¡Los formularios están aquí!",
"Learn more": "Más información",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}"
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Cree formularios sencillos directamente en Grist y compártalos en un clic con nuestro nuevo widget. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Estas reglas se aplican después de que se hayan procesado todas las reglas de columna, si procede."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
@ -1403,5 +1405,29 @@
"FormConfig": {
"Field rules": "Reglas del campo",
"Required field": "Campo obligatorio"
},
"CustomView": {
"Some required columns aren't mapped": "Algunas columnas obligatorias no están asignadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para utilizar este widget, asigne todas las columnas no opcionales desde el panel del creador de la derecha."
},
"FormContainer": {
"Build your own form": "Cree su propio formulario",
"Powered by": "Desarrollado por"
},
"FormErrorPage": {
"Error": "Error"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "¡Vaya! El formulario que busca no existe.",
"Oops! This form is no longer published.": "¡Vaya! Este formulario ya no se publica.",
"There was a problem loading the form.": "Hubo un problema al cargar el formulario.",
"You don't have access to this form.": "No tiene acceso a este formulario."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Se ha producido un error al enviar el formulario. Por favor, inténtelo de nuevo."
},
"FormSuccessPage": {
"Form Submitted": "Formulario enviado",
"Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada."
}
}

@ -39,10 +39,11 @@
"View As": "Voir en tant que",
"Remove column {{- colId }} from {{- tableId }} rules": "Supprimer la colonne {{-colId}} des règles de la table {{-tableId}}",
"Seed rules": "Règles par défaut",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Pour chaque ajout de règle pour une table, ajouter automatiquement une règle donnant tous les droits au groupe OWNER.",
"Permission to edit document structure": "Droits d'édition de la structure",
"This default should be changed if editors' access is to be limited. ": "Cette valeur par défaut doit être modifiée si l'on souhaite limiter l'accès des éditeurs. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page...) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Autorise les éditeurs à éditer la structure (modifier/supprimer des tables, colonnes, mises en page) et à écrire des formules, ce qui donne accès à l'ensemble des données sans prendre en compte d'éventuelles restrictions de droits de lecture.",
"Add Table-wide Rule": "Ajouter une règle pour l'ensemble du tableau"
},
"AccountPage": {
"Account settings": "Paramètres du compte",
@ -51,15 +52,15 @@
"Email": "E-mail",
"Name": "Nom",
"Save": "Enregistrer",
"Password & Security": "Mot de passe & Sécurité",
"Password & Security": "Mot de passe et sécurité",
"Login Method": "Mode de connexion",
"Change Password": "Modifier le mot de passe",
"Allow signing in to this account with Google": "Autoriser la connexion à ce compte avec Google",
"Two-factor authentication": "Authentification à deux facteurs",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "L'authentification à double facteur est une couche additionnelle de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connaît votre mot de passe.",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "L'authentification à double facteur est une couche supplémentaire de sécurité pour votre compte Grist qui permet de s'assurer que vous êtes la seule personne qui peut accéder à votre compte, même si quelqu'un d'autre connaît votre mot de passe.",
"Theme": "Thème",
"API Key": "Clé dAPI",
"Names only allow letters, numbers and certain special characters": "Les noms d'utilisateurs ne doivent contenir que des lettres, des chiffres, et certains caractères spéciaux",
"Names only allow letters, numbers and certain special characters": "Les noms d'utilisateurs ne doivent contenir que des lettres, des chiffres et certains caractères spéciaux",
"Language": "Langue"
},
"AccountWidget": {
@ -414,11 +415,11 @@
"Hidden Columns": "Colonnes cachées",
"Lookups": "Champ rapporté",
"No reference columns.": "Pas de colonne de référence.",
"Apply on record changes": "Appliquer lors de changements d'enregistrements",
"Apply on record changes": "Appliquer lors des modifications de l'enregistrement",
"Duplicate in {{- label}}": "Duplica dans {{-label}}",
"Created By": "Créé(e) par",
"Last Updated At": "Dernière mise à jour le",
"Apply to new records": "Appliquer au nouveaux enregistrements",
"Apply to new records": "Appliquer aux nouvelles lignes",
"Search columns": "Chercher des colonnes",
"Timestamp": "Horodatage",
"no reference column": "pas de colonne de référence",
@ -628,7 +629,7 @@
"Detach": "Détacher",
"SELECT BY": "SÉLECTIONNER PAR",
"Select Widget": "Choisir la vue",
"SELECTOR FOR": "SÉLECTEUR",
"SELECTOR FOR": "SÉLECTEUR POUR",
"Save": "Enregistrer",
"You do not have edit access to this document": "Vous navez pas accès en écriture à ce document",
"Add referenced columns": "Ajouter une colonne référencée",
@ -643,11 +644,11 @@
"Hidden field": "Champ caché",
"Layout": "Mise en page",
"Submission": "Soumission",
"Submit button label": "Libellé du bouton de soumission",
"Submit button label": "Libellé du bouton de validation",
"Success text": "Message de succès",
"Table column name": "Nom de la colonne",
"Enter redirect URL": "Saisir l'URL de redirection",
"Reset form": "Restaurer le formulaire",
"Reset form": "Réinitialiser le formulaire",
"Submit another response": "Soumettre une autre réponse",
"Required field": "Champ obligatoire"
},
@ -730,7 +731,7 @@
},
"TriggerFormulas": {
"Any field": "N'importe quel champ",
"Apply to new records": "Nouveaux enregistrements",
"Apply to new records": "Appliquer sur les nouvelles lignes uniquement",
"Apply on changes to:": "Appliquer sur les modifications à :",
"Apply on record changes": "Réappliquer en cas de modification de la ligne",
"Current field ": "Champ actif ",
@ -893,7 +894,7 @@
"Save": "Enregistrer",
"Cancel": "Annuler",
"Ok": "OK",
"Don't show tips": "Ne pas montrer les astuces",
"Don't show tips": "Masquer les astuces",
"Undo to restore": "Annuler et rétablir",
"Don't show again": "Ne plus montrer",
"Delete": "Supprimer",
@ -1124,7 +1125,8 @@
"Use reference columns to relate data in different tables.": "Utilisez les colonnes de type Référence pour lier différentes tables entre elles.",
"Learn more": "En savoir plus",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}",
"Forms are here!": "Les formulaires sont là!"
"Forms are here!": "Les formulaires sont là!",
"These rules are applied after all column rules have been processed, if applicable.": "Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant."
},
"ColumnTitle": {
"Add description": "Ajouter une description",
@ -1312,7 +1314,7 @@
"Maybe Later": "Peut-être plus tard",
"free coaching call": "appel d'assistance gratuit",
"Schedule Call": "Planifier l'appel",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Lors de l'appel, nous prendrons le temps de comprendre vos besoins et d'adapter l'appel à vos besoins. Nous pouvons vous montrer les bases de Grist, ou commencer à travailler avec vos données immédiatement pour construire les tableaux de bord dont vous avez besoin.",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Lors de l'appel, nous prendrons le temps de comprendre vos besoins et d'adapter l'appel à ces derniers. Nous pouvons vous montrer les bases de Grist, ou commencer tout de suite à travailler avec vos données pour construire les tableaux de bord dont vous avez besoin.",
"Schedule your {{freeCoachingCall}} with a member of our team.": "Planifiez votre {{freeCoachingCall}} avec un membre de notre équipe."
},
"FormView": {
@ -1338,10 +1340,10 @@
"Header": "Titre"
},
"UnmappedFieldsConfig": {
"Mapped": "Utilisés",
"Mapped": "Utilisé",
"Select All": "Tout sélectionner",
"Unmap fields": "Champs non utilisés",
"Unmapped": "Non utilisés",
"Unmapped": "Non utilisé",
"Clear": "Effacer",
"Map fields": "Champs utilisés"
},
@ -1351,5 +1353,29 @@
},
"Editor": {
"Delete": "Supprimer"
},
"CustomView": {
"Some required columns aren't mapped": "Certaines colonnes obligatoires ne sont pas utilisées",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Pour utiliser cette vue, utilisez toutes les colonnes obligatoires à partir du panneau du créateur sur la droite."
},
"FormContainer": {
"Build your own form": "Créez votre propre formulaire",
"Powered by": "Créé avec"
},
"FormErrorPage": {
"Error": "Erreur"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Oups! Le formulaire que vous recherchez n'existe pas.",
"Oops! This form is no longer published.": "Oups! Ce formulaire n'est plus publié.",
"There was a problem loading the form.": "Il y a eu un problème de chargement du formulaire.",
"You don't have access to this form.": "Vous n'avez pas accès à ce formulaire."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Une erreur s'est produite lors de l'envoi de votre formulaire. Veuillez réessayer."
},
"FormSuccessPage": {
"Form Submitted": "Formulaire envoyé",
"Thank you! Your response has been recorded.": "Nous vous remercions. Votre réponse a été enregistrée."
}
}

@ -105,7 +105,27 @@
"Freeze {{count}} more columns_other": "さらに {{count}} 列固定する",
"Hide {{count}} columns_one": "列非表示",
"Insert column to the left": "左側に列を挿入",
"Sorted (#{{count}})_other": "ソート (#{{count}})"
"Sorted (#{{count}})_other": "ソート (#{{count}})",
"Attachment": "添付ファイル",
"Add column": "列を追加",
"Adding UUID column": "UUID列を追加",
"Detect Duplicates in...": "重複を検出...",
"UUID": "UUID",
"Add column with type": "型を指定して列を追加",
"Add formula column": "数式列を追加",
"Apply to new records": "新しいレコードに適用",
"Apply on record changes": "レコードの変更に適用",
"Authorship": "作成者名",
"Timestamp": "タイムスタンプ",
"Detect duplicates in...": "重複を検出...",
"Numeric": "数値",
"Text": "テキスト",
"Integer": "整数",
"Toggle": "トグル",
"Date": "日付",
"DateTime": "日時",
"Choice": "選択",
"Choice List": "複数選択"
},
"DocMenu": {
"This service is not available right now": "このサービスは現在ご利用いただけません",
@ -899,7 +919,7 @@
"Close": "閉じる",
"Cancel": "キャンセル",
"Apply on changes to:": "変更を適用する:",
"Apply to new records": "新しいレコードに適用する",
"Apply to new records": "新しいレコードに適用",
"OK": "OK",
"Current field ": "現在のフィールド ",
"Any field": "任意のフィールド",
@ -913,7 +933,11 @@
"Click the Add New button to create new documents or workspaces, or import data.": "「新規追加」ボタンをクリックして、新しいドキュメントまたはワークスペースを作成するか、データをインポートします。",
"Apply conditional formatting to rows based on formulas.": "数式に基づいて条件付き書式を行に適用します。",
"Click on “Open row styles” to apply conditional formatting to rows.": "行に条件付き書式を適用するには、「行書式を開く」をクリックする。",
"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "参照列のセルは常にそのテーブル内の {{entire}} レコードを識別しますが、そのレコードからどの列を表示するかを選択することもできます。"
"Cells in a reference column always identify an {{entire}} record in that table, but you may select which column from that record to show.": "参照列のセルは常にそのテーブル内の {{entire}} レコードを識別しますが、そのレコードからどの列を表示するかを選択することもできます。",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "数式は多くの Excel 関数、完全な Python 構文をサポートし、便利な AI アシスタントが含まれています。",
"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUIDはランダムに生成される文字列で、一意の識別子やキーとして役立ちます。",
"The total size of all data in this document, excluding attachments.": "添付ファイルを除く、このドキュメント内のすべてのデータの合計サイズ。",
"Lookups return data from related tables.": "Lookupは関連テーブルからデータを返します。"
},
"AppHeader": {
"Personal Site": "個人サイト",
@ -928,7 +952,13 @@
"You do not have edit access to this document": "このドキュメントへのアクセス権がありません",
"Delete {{formattedTableName}} data, and remove it from all pages?": "{{formattedTableName}} データを削除し、すべてのページから削除しますか?",
"Click to copy": "クリックしてコピー",
"Table ID copied to clipboard": "クリップボードにテーブルIDをコピーしました"
"Table ID copied to clipboard": "クリップボードにテーブルIDをコピーしました",
"Edit Record Card": "レコードカードを編集",
"Record Card": "レコードカード",
"Record Card Disabled": "レコードカードを無効化",
"Remove Table": "テーブルを削除",
"Rename Table": "テーブル名を編集",
"{{action}} Record Card": "レコードカードを{{action}}"
},
"NTextBox": {
"false": "false",

@ -42,7 +42,8 @@
"When adding table rules, automatically add a rule to grant OWNER full access.": "Ao adicionar regras de tabela, adicione automaticamente uma regra para conceder ao PROPRIETÁRIO acesso total.",
"Permission to edit document structure": "Permissão para editar a estrutura do documento",
"This default should be changed if editors' access is to be limited. ": "Esse padrão deve ser alterado se o acesso dos editores for limitado. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Permita que os editores editem a estrutura (por exemplo, modifiquem e excluam tabelas, colunas, layouts) e escrevam fórmulas, que dão acesso a todos os dados, independentemente das restrições de leitura.",
"Add Table-wide Rule": "Adicionar regra para toda a tabela"
},
"AccountPage": {
"API": "API",
@ -305,7 +306,8 @@
"Document ID copied to clipboard": "ID do documento copiado para a área de transferência",
"API": "API",
"Manage Webhooks": "Gerenciar ganchos web",
"Webhooks": "Ganchos Web"
"Webhooks": "Ganchos Web",
"API Console": "Consola API"
},
"DocumentUsage": {
"Attachments Size": "Tamanho dos Anexos",
@ -464,7 +466,19 @@
"Add column": "Adicionar coluna",
"Last updated by": "Última atualização por",
"Detect duplicates in...": "Detectar duplicações em...",
"Last updated at": "Última atualização em"
"Last updated at": "Última atualização em",
"Any": "Qualquer",
"Numeric": "Numérico",
"Text": "Texto",
"Date": "Data",
"DateTime": "DataHora",
"Choice List": "Lista de opções",
"Reference": "Referência",
"Reference List": "Lista de referências",
"Attachment": "Anexo",
"Integer": "Inteiro",
"Toggle": "Alternar",
"Choice": "Opção"
},
"GristDoc": {
"Added new linked section to view {{viewName}}": "Adicionada nova seção vinculada para visualizar {{viewName}}}",
@ -667,7 +681,24 @@
"Fields_one": "Campo",
"Fields_other": "Campos",
"Add referenced columns": "Adicionar colunas referenciadas",
"Reset form": "Restaurar formulário"
"Reset form": "Restaurar formulário",
"Default field value": "Valor padrão do campo",
"Display button": "Botão de exibição",
"Enter text": "Digite texto",
"Field rules": "Regras de campo",
"Field title": "Título do campo",
"Hidden field": "Campo escondido",
"Redirection": "Redirecionamento",
"Submission": "Envio",
"Required field": "Campo necessário",
"Submit another response": "Enviar outra resposta",
"Submit button label": "Etiqueta do botão de envio",
"Table column name": "Nome da coluna da tabela",
"Enter redirect URL": "Insira URL de redirecionamento",
"Redirect automatically after submission": "Redirecionar automaticamente após o envio",
"Configuration": "Configuração",
"Success text": "Texto de sucesso",
"Layout": "Leiaute"
},
"RowContextMenu": {
"Copy anchor link": "Copiar o link de ancoragem",
@ -813,7 +844,8 @@
"Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget",
"Collapse widget": "Colapsar widget",
"Add to page": "Adicionar à página"
"Add to page": "Adicionar à página",
"Create a form": "Criar um formulário"
},
"ViewSectionMenu": {
"(customized)": "(personalizado)",
@ -896,7 +928,11 @@
"You do not have access to this organization's documents.": "Você não tem acesso aos documentos desta organização.",
"Account deleted{{suffix}}": "Conta excluída{{suffix}}",
"Your account has been deleted.": "Sua conta foi excluída.",
"Sign up": "Cadastre-se"
"Sign up": "Cadastre-se",
"An unknown error occurred.": "Ocorreu um erro desconhecido.",
"Form not found": "Formulário não encontrado",
"Powered by": "Desenvolvido por",
"Build your own form": "Construa seu próprio formulário"
},
"menus": {
"* Workspaces are available on team plans. ": "* As áreas de trabalho estão disponíveis nos planos de equipe. ",
@ -919,7 +955,17 @@
"modals": {
"Cancel": "Cancelar",
"Ok": "OK",
"Save": "Salvar"
"Save": "Salvar",
"Are you sure you want to delete these records?": "Tem a certeza de que deseja apagar esses registros?",
"Undo to restore": "Desfazer para restaurar",
"Delete": "Eliminar",
"Are you sure you want to delete this record?": "Tem certeza de que deseja apagar este registro?",
"Dismiss": "Descartar",
"Don't ask again.": "Não perguntar novamente",
"Got it": "Entendido",
"Don't show again": "Não mostrar novamente",
"Don't show again.": "Não mostrar novamente.",
"Don't show tips": "Não mostrar dicas"
},
"pages": {
"Duplicate Page": "Duplicar a Página",
@ -1138,7 +1184,11 @@
"Lookups return data from related tables.": "As pesquisas retornam dados de tabelas relacionadas.",
"You can choose from widgets available to you in the dropdown, or embed your own by providing its full URL.": "Você pode escolher entre os widgets disponíveis no menu suspenso ou incorporar o seu próprio widget fornecendo o URL completo.",
"Use reference columns to relate data in different tables.": "Use colunas de referência para relacionar dados em diferentes tabelas.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "As fórmulas suportam muitas funções do Excel, sintaxe Python completa e incluem um assistente de IA útil.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}",
"Forms are here!": "Os formulários chegaram!",
"Learn more": "Saiba mais",
"These rules are applied after all column rules have been processed, if applicable.": "Estas regras são aplicadas após todas as regras da coluna terem sido processadas, se aplicável."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIÇÃO"
@ -1324,5 +1374,70 @@
},
"HiddenQuestionConfig": {
"Hidden fields": "Campos ocultos"
},
"FormView": {
"Publish": "Publicar",
"Publish your form?": "Publicar o seu formulário?",
"Unpublish your form?": "Despublicar seu formulário?",
"Unpublish": "Cancelar publicação"
},
"Menu": {
"Columns": "Colunas",
"Cut": "Cortar",
"Building blocks": "Blocos de construção",
"Unmapped fields": "Campos não mapeados",
"Insert question above": "Insira a questão acima",
"Insert question below": "Inserir questão abaixo",
"Paragraph": "Parágrafo",
"Paste": "Colar",
"Separator": "Separador",
"Copy": "Copiar",
"Header": "Cabeçalho"
},
"UnmappedFieldsConfig": {
"Unmap fields": "Desmapear campos",
"Unmapped": "Desmapeado",
"Mapped": "Mapeado",
"Select All": "Selecionar Todos",
"Map fields": "Mapear campos",
"Clear": "Limpar"
},
"Editor": {
"Delete": "Eliminar"
},
"WelcomeCoachingCall": {
"Schedule your {{freeCoachingCall}} with a member of our team.": "Programe seu {{freeCoachingCall}} com um membro da nossa equipe.",
"free coaching call": "chamada gratuita de treinamento",
"Maybe Later": "Talvez mais tarde",
"On the call, we'll take the time to understand your needs and tailor the call to you. We can show you the Grist basics, or start working with your data right away to build the dashboards you need.": "Na chamada, vamos ter tempo para entender suas necessidades e adaptar a chamada para você. Podemos mostrar-lhe os princípios básicos do Grist ou começar a trabalhar com os seus dados imediatamente para construir os painéis que você precisa.",
"Schedule Call": "Agendar chamada"
},
"FormConfig": {
"Field rules": "Regras de campo",
"Required field": "Campo obrigatório"
},
"CustomView": {
"Some required columns aren't mapped": "Algumas colunas obrigatórias não estão mapeadas",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Para usar este widget, mapeie todas as colunas não opcionais do painel criador à direita."
},
"FormContainer": {
"Build your own form": "Crie seu próprio formulário",
"Powered by": "Desenvolvido por"
},
"FormErrorPage": {
"Error": "Erro"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ops! O formulário que você está procurando não existe.",
"Oops! This form is no longer published.": "Ops! Este formulário não está mais publicado.",
"There was a problem loading the form.": "Houve um problema ao carregar o formulário.",
"You don't have access to this form.": "Você não tem acesso a este formulário."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Houve um erro ao enviar seu formulário. Por favor, tente novamente."
},
"FormSuccessPage": {
"Form Submitted": "Formulário enviado",
"Thank you! Your response has been recorded.": "Obrigado! Sua resposta foi registrada."
}
}

@ -36,7 +36,8 @@
"Permission to edit document structure": "Разрешение на редактирование структуры документа",
"When adding table rules, automatically add a rule to grant OWNER full access.": "При добавлении правил таблицы, автоматически добавить правило для предоставления ВЛАДЕЛЬЦУ полного доступа.",
"This default should be changed if editors' access is to be limited. ": "Это значение по умолчанию следует изменить, если требуется ограничить доступ редакторов. ",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение."
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Позволяет редакторам редактировать структуру (например, изменять и удалять таблицы, столбцы, макеты) и писать формулы, которые предоставляют доступ ко всем данным независимо от ограничений на чтение.",
"Add Table-wide Rule": "Добавить обще-табличное правило"
},
"ACUserManager": {
"Enter email address": "Введите адрес электронной почты",
@ -1119,7 +1120,11 @@
"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-адрес.",
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI."
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "Формулы поддерживают множество функций Excel, полный синтаксис Python и включает полезного помощника AI.",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Создавайте простые формы прямо в Grist и делитесь ими одним щелчком мыши с помощью нашего нового виджета.. {{learnMoreButton}}",
"Forms are here!": "Формы уже здесь!",
"Learn more": "Узнать больше",
"These rules are applied after all column rules have been processed, if applicable.": "Эти правила применяются после обработки всех правил столбцов, если это применимо."
},
"DescriptionConfig": {
"DESCRIPTION": "ОПИСАНИЕ"
@ -1342,5 +1347,33 @@
},
"Editor": {
"Delete": "Удалить"
},
"FormConfig": {
"Field rules": "Правила полей",
"Required field": "Обязательное поле"
},
"CustomView": {
"Some required columns aren't mapped": "Некоторые обязательные столбцы не сопоставлены",
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Чтобы использовать этот виджет, сопоставьте все необязательные столбцы с панели создателя справа."
},
"FormContainer": {
"Build your own form": "Создайте свою собственную форму",
"Powered by": "Разработано"
},
"FormModel": {
"There was a problem loading the form.": "Возникла проблема с загрузкой формы.",
"You don't have access to this form.": "У вас нет доступа к этой форме.",
"Oops! The form you're looking for doesn't exist.": "Ой! Форма, которую вы ищете, не существует.",
"Oops! This form is no longer published.": "Ой! Эта форма больше не публикуется."
},
"FormErrorPage": {
"Error": "Ошибка"
},
"FormPage": {
"There was an error submitting your form. Please try again.": "При отправке формы произошла ошибка. Пожалуйста, попробуйте еще раз."
},
"FormSuccessPage": {
"Form Submitted": "Форма отправлена",
"Thank you! Your response has been recorded.": "Спасибо! Ваш ответ учтен."
}
}

@ -36,7 +36,8 @@
"Attribute to Look Up": "Atribut za iskanje",
"Lookup Table": "Preglednica za iskanje",
"This default should be changed if editors' access is to be limited. ": "To privzeto nastavitev je treba spremeniti, če je treba omejiti dostop urednikov. ",
"Seed rules": "Privzete pravice"
"Seed rules": "Privzete pravice",
"Add Table-wide Rule": "Dodaj pravilo za celotno tabelo"
},
"ACUserManager": {
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
@ -730,7 +731,8 @@
"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.",
"Forms are here!": "Obrazci so tukaj!",
"Learn more": "Nauči se več",
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}"
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Ustvari preproste obrazce neposredno v Gristu in jih deli z enim klikom z našim novim pripomočkom. {{learnMoreButton}}",
"These rules are applied after all column rules have been processed, if applicable.": "Ta pravila se uporabijo, ko so obdelana vsa pravila stolpcev, če so na voljo."
},
"UserManager": {
"Anyone with link ": "Vsakdo s povezavo ",
@ -1349,5 +1351,29 @@
"FormConfig": {
"Required field": "Obvezno polje",
"Field rules": "Pravila polj"
},
"CustomView": {
"To use this widget, please map all non-optional columns from the creator panel on the right.": "Če želite uporabiti ta pripomoček, preslikajte vse neobvezne stolpce na plošči za ustvarjanje na desni.",
"Some required columns aren't mapped": "Nekateri zahtevani stolpci niso preslikani"
},
"FormContainer": {
"Build your own form": "Ustvari svoj obrazec",
"Powered by": "Poganja ga"
},
"FormErrorPage": {
"Error": "Napaka"
},
"FormModel": {
"Oops! The form you're looking for doesn't exist.": "Ups! Obrazec, ki ga iščeš, ne obstaja.",
"Oops! This form is no longer published.": "Ups! Ta obrazec ni več objavljen.",
"There was a problem loading the form.": "Pri nalaganju obrazca je prišlo do težave.",
"You don't have access to this form.": "Nimaš dostopa do tega obrazca."
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Pri pošiljanju obrazca je prišlo do napake. Prosim poskusi ponovno."
},
"FormSuccessPage": {
"Form Submitted": "Obrazec oddan",
"Thank you! Your response has been recorded.": "Hvala ti! Tvoj odgovor je bil zabeležen."
}
}

@ -0,0 +1,52 @@
import {assert, driver} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils';
describe('Boot', function() {
this.timeout(30000);
setupTestSuite();
let oldEnv: testUtils.EnvironmentSnapshot;
afterEach(() => gu.checkForErrors());
async function hasPrompt() {
assert.include(
await driver.findContentWait('p', /diagnostics page/, 2000).getText(),
'A diagnostics page can be made available');
}
it('gives prompt about how to enable boot page', async function() {
await driver.get(`${server.getHost()}/boot`);
await hasPrompt();
});
describe('with a GRIST_BOOT_KEY', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.GRIST_BOOT_KEY = 'lala';
await server.restart();
});
after(async function() {
oldEnv.restore();
await server.restart();
});
it('gives prompt when key is missing', async function() {
await driver.get(`${server.getHost()}/boot`);
await hasPrompt();
});
it('gives prompt when key is wrong', async function() {
await driver.get(`${server.getHost()}/boot/bilbo`);
await hasPrompt();
});
it('gives page when key is right', async function() {
await driver.get(`${server.getHost()}/boot/lala`);
await driver.findContentWait('h2', /Grist is reachable/, 2000);
});
});
});
Loading…
Cancel
Save