From 5e3cd941776009ec4443363dc7152a8766c75301 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 14 May 2024 18:58:41 +0200 Subject: [PATCH 01/26] Introduce APP_HOME_INTERNAL_URL and fix duplicate docs (#915) Context: On self-hosted instances, some places in the code rely on the fact that we resolves public domains while being behind reverse proxies. This leads to cases where features are not available, such as the "Duplicate document" one. Bugs that are solved - n self-hosted instances: Impossible to open templates and tutorials right after having converted them; Impossible to submit forms since version 1.1.13; Impossible to restore a previous version of a document (snapshot); Impossible to copy a document; Solution: Introduce the APP_HOME_INTERNAL_URL env variable, which is quite the same as APP_DOC_INTERNAL_URL except that it may point to any home worker; Make /api/worker/:assignmentId([^/]+)/?* return not only the doc worker public url but also the internal one, and adapt the call points like fetchDocs; Ensure that the home and doc worker internal urls are trusted by trustOrigin; --------- Co-authored-by: jordigh --- README.md | 5 +- app/common/UserAPI.ts | 49 +++-- app/common/gristUrls.ts | 27 ++- app/gen-server/lib/DocApiForwarder.ts | 6 +- app/server/lib/AppEndpoint.ts | 49 +++-- app/server/lib/Authorizer.ts | 8 +- app/server/lib/BootProbes.ts | 4 +- app/server/lib/DocApi.ts | 23 ++- app/server/lib/DocWorkerUtils.ts | 51 +++-- app/server/lib/FlexServer.ts | 31 ++- app/server/lib/GristServer.ts | 2 + app/server/lib/ITestingHooks-ti.ts | 1 - app/server/lib/ITestingHooks.ts | 1 - app/server/lib/TestingHooks.ts | 5 - app/server/lib/requestUtils.ts | 2 +- app/server/lib/uploads.ts | 38 ++-- test/server/lib/DocApi.ts | 208 +++++++++++++++++---- test/server/lib/helpers/TestProxyServer.ts | 2 - test/server/lib/helpers/TestServer.ts | 163 ++++++++++++++-- 19 files changed, 516 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index c950d741..4582729d 100644 --- a/README.md +++ b/README.md @@ -235,10 +235,11 @@ Grist can be configured in many ways. Here are the main environment variables it | Variable | Purpose | |------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. | +| ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. | | APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis) | -| APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). Defaults to `APP_DOC_URL` | +| APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). It only makes sense to define this value in the doc worker. Defaults to `APP_DOC_URL`. | | APP_HOME_URL | url prefix for home api (home and doc servers need this) | +| APP_HOME_INTERNAL_URL | like `APP_HOME_URL` but used by the home and the doc servers to reach any home workers using an internal domain name resolution (like in a docker environment). Defaults to `APP_HOME_URL` | | APP_STATIC_URL | url prefix for static resources | | APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages | | APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. | diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 28daa159..15bc3b61 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -773,11 +773,11 @@ export class UserAPIImpl extends BaseAPI implements UserAPI { } public async getWorker(key: string): Promise { - const json = await this.requestJson(`${this._url}/api/worker/${key}`, { + const json = (await this.requestJson(`${this._url}/api/worker/${key}`, { method: 'GET', credentials: 'include' - }); - return getDocWorkerUrl(this._homeUrl, json); + })) as PublicDocWorkerUrlInfo; + return getPublicDocWorkerUrl(this._homeUrl, json); } public async getWorkerAPI(key: string): Promise { @@ -1156,6 +1156,27 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { } } +/** + * Represents information to build public doc worker url. + * + * Structure that may contain either **exclusively**: + * - a selfPrefix when no pool of doc worker exist. + * - a public doc worker url otherwise. + */ +export type PublicDocWorkerUrlInfo = { + selfPrefix: string; + docWorkerUrl: null; +} | { + selfPrefix: null; + docWorkerUrl: string; +} + +export function getUrlFromPrefix(homeUrl: string, prefix: string) { + const url = new URL(homeUrl); + url.pathname = prefix + url.pathname; + return url.href; +} + /** * Get a docWorkerUrl from information returned from backend. When the backend * is fully configured, and there is a pool of workers, this is straightforward, @@ -1164,19 +1185,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI { * use the homeUrl of the backend, with extra path prefix information * given by selfPrefix. At the time of writing, the selfPrefix contains a * doc-worker id, and a tag for the codebase (used in consistency checks). + * + * @param {string} homeUrl + * @param {string} docWorkerInfo The information to build the public doc worker url + * (result of the call to /api/worker/:docId) */ -export function getDocWorkerUrl(homeUrl: string, docWorkerInfo: { - docWorkerUrl: string|null, - selfPrefix?: string, -}): string { - if (!docWorkerInfo.docWorkerUrl) { - if (!docWorkerInfo.selfPrefix) { - // This should never happen. - throw new Error('missing selfPrefix for docWorkerUrl'); - } - const url = new URL(homeUrl); - url.pathname = docWorkerInfo.selfPrefix + url.pathname; - return url.href; - } - return docWorkerInfo.docWorkerUrl; +export function getPublicDocWorkerUrl(homeUrl: string, docWorkerInfo: PublicDocWorkerUrlInfo) { + return docWorkerInfo.selfPrefix !== null ? + getUrlFromPrefix(homeUrl, docWorkerInfo.selfPrefix) : + docWorkerInfo.docWorkerUrl; } diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 182a1ea1..431fd49e 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -186,10 +186,23 @@ export interface OrgUrlInfo { orgInPath?: string; // If /o/{orgInPath} should be used to access the requested org. } -function isDocInternalUrl(host: string) { - if (!process.env.APP_DOC_INTERNAL_URL) { return false; } - const internalUrl = new URL('/', process.env.APP_DOC_INTERNAL_URL); - return internalUrl.host === host; +function hostMatchesUrl(host?: string, url?: string) { + return host !== undefined && url !== undefined && new URL(url).host === host; +} + +/** + * Returns true if: + * - the server is a home worker and the host matches APP_HOME_INTERNAL_URL; + * - or the server is a doc worker and the host matches APP_DOC_INTERNAL_URL; + * + * @param {string?} host The host to check + */ +function isOwnInternalUrlHost(host?: string) { + // Note: APP_HOME_INTERNAL_URL may also be defined in doc worker as well as in home worker + if (process.env.APP_HOME_INTERNAL_URL && hostMatchesUrl(host, process.env.APP_HOME_INTERNAL_URL)) { + return true; + } + return Boolean(process.env.APP_DOC_INTERNAL_URL) && hostMatchesUrl(host, process.env.APP_DOC_INTERNAL_URL); } /** @@ -209,7 +222,11 @@ export function getHostType(host: string, options: { const hostname = host.split(":")[0]; if (!options.baseDomain) { return 'native'; } - if (hostname === 'localhost' || isDocInternalUrl(host) || hostname.endsWith(options.baseDomain)) { + if ( + hostname === 'localhost' || + isOwnInternalUrlHost(host) || + hostname.endsWith(options.baseDomain) + ) { return 'native'; } return 'custom'; diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index 4be608af..3545a63a 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -104,7 +104,11 @@ export class DocApiForwarder { url.pathname = removeTrailingSlash(docWorkerUrl.pathname) + url.pathname; const headers: {[key: string]: string} = { - ...getTransitiveHeaders(req), + // At this point, we have already checked and trusted the origin of the request. + // See FlexServer#addApiMiddleware(). So don't include the "Origin" header. + // Including this header also would break features like form submissions, + // as the "Host" header is not retrieved when calling getTransitiveHeaders(). + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': req.get('Content-Type') || 'application/json', }; for (const key of ['X-Sort', 'X-Limit']) { diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index 05fd54e1..b327a4e1 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -9,17 +9,18 @@ import {ApiError} from 'app/common/ApiError'; import {getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX} from 'app/common/gristUrls'; import {LocalPlugin} from "app/common/plugin"; import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry'; -import {Document as APIDocument} from 'app/common/UserAPI'; +import {Document as APIDocument, PublicDocWorkerUrlInfo} from 'app/common/UserAPI'; import {Document} from "app/gen-server/entity/Document"; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser, RequestWithLogin} from 'app/server/lib/Authorizer'; import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; -import {customizeDocWorkerUrl, getWorker, useWorkerPool} from 'app/server/lib/DocWorkerUtils'; +import { + customizeDocWorkerUrl, getDocWorkerInfoOrSelfPrefix, getWorker, useWorkerPool +} from 'app/server/lib/DocWorkerUtils'; import {expressWrap} from 'app/server/lib/expressWrap'; import {DocTemplate, GristServer} from 'app/server/lib/GristServer'; import {getCookieDomain} from 'app/server/lib/gristSessions'; -import {getAssignmentId} from 'app/server/lib/idUtils'; import log from 'app/server/lib/log'; import {addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils'; import {ISendAppPageOptions} from 'app/server/lib/sendAppPage'; @@ -48,32 +49,18 @@ export function attachAppEndpoint(options: AttachOptions): void { app.get('/apiconsole', expressWrap(async (req, res) => sendAppPage(req, res, {path: 'apiconsole.html', status: 200, config: {}}))); - app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => { - if (!useWorkerPool()) { - // Let the client know there is not a separate pool of workers, - // so they should continue to use the same base URL for accessing - // documents. For consistency, return a prefix to add into that - // URL, as there would be for a pool of workers. It would be nice - // to go ahead and provide the full URL, but that requires making - // more assumptions about how Grist is configured. - // Alternatives could be: have the client to send their base URL - // in the request; or use headers commonly added by reverse proxies. - const selfPrefix = "/dw/self/v/" + gristServer.getTag(); - res.json({docWorkerUrl: null, selfPrefix}); - return; - } + app.get('/api/worker/:docId([^/]+)/?*', expressWrap(async (req, res) => { if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); } res.header("Access-Control-Allow-Credentials", "true"); - if (!docWorkerMap) { - return res.status(500).json({error: 'no worker map'}); - } - const assignmentId = getAssignmentId(docWorkerMap, req.params.assignmentId); - const {docStatus} = await getWorker(docWorkerMap, assignmentId, '/status'); - if (!docStatus) { - return res.status(500).json({error: 'no worker'}); - } - res.json({docWorkerUrl: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)}); + const {selfPrefix, docWorker} = await getDocWorkerInfoOrSelfPrefix( + req.params.docId, docWorkerMap, gristServer.getTag() + ); + const info: PublicDocWorkerUrlInfo = selfPrefix ? + { docWorkerUrl: null, selfPrefix } : + { docWorkerUrl: customizeDocWorkerUrl(docWorker!.publicUrl, req), selfPrefix: null }; + + return res.json(info); })); // Handler for serving the document landing pages. Expects the following parameters: @@ -160,7 +147,7 @@ export function attachAppEndpoint(options: AttachOptions): void { // TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set. const headers = { Accept: 'application/json', - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: true }), }; const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, {headers}); docStatus = workerInfo.docStatus; @@ -206,10 +193,16 @@ export function attachAppEndpoint(options: AttachOptions): void { }); } + // Without a public URL, we're in single server mode. + // Use a null workerPublicURL, to signify that the URL prefix serving the + // current endpoint is the only one available. + const publicUrl = docStatus?.docWorker?.publicUrl; + const workerPublicUrl = publicUrl !== undefined ? customizeDocWorkerUrl(publicUrl, req) : null; + await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200, googleTagManager: 'anon', config: { assignmentId: docId, - getWorker: {[docId]: customizeDocWorkerUrl(docStatus?.docWorker?.publicUrl, req)}, + getWorker: {[docId]: workerPublicUrl }, getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)}, plugins }}); diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 761e76f2..b8c85981 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -677,7 +677,10 @@ export function assertAccess( * Pull out headers to pass along to a proxied service. Focused primarily on * authentication. */ -export function getTransitiveHeaders(req: Request): {[key: string]: string} { +export function getTransitiveHeaders( + req: Request, + { includeOrigin }: { includeOrigin: boolean } +): {[key: string]: string} { const Authorization = req.get('Authorization'); const Cookie = req.get('Cookie'); const PermitHeader = req.get('Permit'); @@ -685,13 +688,14 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} { const XRequestedWith = req.get('X-Requested-With'); const Origin = req.get('Origin'); // Pass along the original Origin since it may // play a role in granular access control. + const result: Record = { ...(Authorization ? { Authorization } : undefined), ...(Cookie ? { Cookie } : undefined), ...(Organization ? { Organization } : undefined), ...(PermitHeader ? { Permit: PermitHeader } : undefined), ...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined), - ...(Origin ? { Origin } : undefined), + ...((includeOrigin && Origin) ? { Origin } : undefined), }; const extraHeader = process.env.GRIST_FORWARD_AUTH_HEADER; const extraHeaderValue = extraHeader && req.get(extraHeader); diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts index 7cddb99f..8edc28ae 100644 --- a/app/server/lib/BootProbes.ts +++ b/app/server/lib/BootProbes.ts @@ -77,7 +77,7 @@ const _homeUrlReachableProbe: Probe = { id: 'reachable', name: 'Grist is reachable', apply: async (server, req) => { - const url = server.getHomeUrl(req); + const url = server.getHomeInternalUrl(); try { const resp = await fetch(url); if (resp.status !== 200) { @@ -102,7 +102,7 @@ const _statusCheckProbe: Probe = { id: 'health-check', name: 'Built-in Health check', apply: async (server, req) => { - const baseUrl = server.getHomeUrl(req); + const baseUrl = server.getHomeInternalUrl(); const url = new URL(baseUrl); url.pathname = removeTrailingSlash(url.pathname) + '/status'; try { diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 95d54540..223b08bd 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -1098,10 +1098,11 @@ export class DocWorkerApi { if (req.body.sourceDocId) { options.sourceDocId = await this._confirmDocIdForRead(req, String(req.body.sourceDocId)); // Make sure that if we wanted to download the full source, we would be allowed. - const result = await fetch(this._grist.getHomeUrl(req, `/api/docs/${options.sourceDocId}/download?dryrun=1`), { + const homeUrl = this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/download?dryrun=1`); + const result = await fetch(homeUrl, { method: 'GET', headers: { - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', } }); @@ -1111,10 +1112,10 @@ export class DocWorkerApi { } // We should make sure the source document has flushed recently. // It may not be served by the same worker, so work through the api. - await fetch(this._grist.getHomeUrl(req, `/api/docs/${options.sourceDocId}/flush`), { + await fetch(this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/flush`), { method: 'POST', headers: { - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', } }); @@ -1170,12 +1171,16 @@ export class DocWorkerApi { const showDetails = isAffirmative(req.query.detail); const docSession = docSessionFromRequest(req); const {states} = await this._getStates(docSession, activeDoc); - const ref = await fetch(this._grist.getHomeUrl(req, `/api/docs/${req.params.docId2}/states`), { + const ref = await fetch(this._grist.getHomeInternalUrl(`/api/docs/${req.params.docId2}/states`), { headers: { - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', } }); + if (!ref.ok) { + res.status(ref.status).send(await ref.text()); + return; + } const states2: DocState[] = (await ref.json()).states; const left = states[0]; const right = states2[0]; @@ -1199,9 +1204,9 @@ export class DocWorkerApi { // Calculate changes from the (common) parent to the current version of the other document. const url = `/api/docs/${req.params.docId2}/compare?left=${parent.h}`; - const rightChangesReq = await fetch(this._grist.getHomeUrl(req, url), { + const rightChangesReq = await fetch(this._grist.getHomeInternalUrl(url), { headers: { - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', } }); @@ -1644,7 +1649,7 @@ export class DocWorkerApi { let uploadResult; try { const accessId = makeAccessId(req, getAuthorizedUserId(req)); - uploadResult = await fetchDoc(this._grist, sourceDocumentId, req, accessId, asTemplate); + uploadResult = await fetchDoc(this._grist, this._docWorkerMap, sourceDocumentId, req, accessId, asTemplate); globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, `${documentName}.grist`); } catch (err) { if ((err as ApiError).status === 403) { diff --git a/app/server/lib/DocWorkerUtils.ts b/app/server/lib/DocWorkerUtils.ts index 1396b876..060cef6f 100644 --- a/app/server/lib/DocWorkerUtils.ts +++ b/app/server/lib/DocWorkerUtils.ts @@ -1,11 +1,12 @@ import {ApiError} from 'app/common/ApiError'; import {parseSubdomainStrictly} from 'app/common/gristUrls'; import {removeTrailingSlash} from 'app/common/gutil'; -import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; +import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap'; import log from 'app/server/lib/log'; import {adaptServerUrl} from 'app/server/lib/requestUtils'; import * as express from 'express'; import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch'; +import {getAssignmentId} from './idUtils'; /** * This method transforms a doc worker's public url as needed based on the request. @@ -35,16 +36,7 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch'; * TODO: doc worker registration could be redesigned to remove the assumption * of a fixed base domain. */ -export function customizeDocWorkerUrl( - docWorkerUrlSeed: string|undefined, - req: express.Request -): string|null { - if (!docWorkerUrlSeed) { - // When no doc worker seed, we're in single server mode. - // Return null, to signify that the URL prefix serving the - // current endpoint is the only one available. - return null; - } +export function customizeDocWorkerUrl( docWorkerUrlSeed: string, req: express.Request): string { const docWorkerUrl = new URL(docWorkerUrlSeed); const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org; adaptServerUrl(docWorkerUrl, req); @@ -152,6 +144,43 @@ export async function getWorker( } } +export type DocWorkerInfoOrSelfPrefix = { + docWorker: DocWorkerInfo, + selfPrefix?: never, +} | { + docWorker?: never, + selfPrefix: string +}; + +export async function getDocWorkerInfoOrSelfPrefix( + docId: string, + docWorkerMap?: IDocWorkerMap | null, + tag?: string +): Promise { + if (!useWorkerPool()) { + // Let the client know there is not a separate pool of workers, + // so they should continue to use the same base URL for accessing + // documents. For consistency, return a prefix to add into that + // URL, as there would be for a pool of workers. It would be nice + // to go ahead and provide the full URL, but that requires making + // more assumptions about how Grist is configured. + // Alternatives could be: have the client to send their base URL + // in the request; or use headers commonly added by reverse proxies. + const selfPrefix = "/dw/self/v/" + tag; + return { selfPrefix }; + } + + if (!docWorkerMap) { + throw new Error('no worker map'); + } + const assignmentId = getAssignmentId(docWorkerMap, docId); + const { docStatus } = await getWorker(docWorkerMap, assignmentId, '/status'); + if (!docStatus) { + throw new Error('no worker'); + } + return { docWorker: docStatus.docWorker }; +} + // Return true if document related endpoints are served by separate workers. export function useWorkerPool() { return process.env.GRIST_SINGLE_PORT !== 'true'; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 17ac46a3..59d8a3e5 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -295,6 +295,13 @@ export class FlexServer implements GristServer { return homeUrl; } + /** + * Same as getDefaultHomeUrl, but for internal use. + */ + public getDefaultHomeInternalUrl(): string { + return process.env.APP_HOME_INTERNAL_URL || this.getDefaultHomeUrl(); + } + /** * Get a url for the home server api, adapting it to match the base domain in the * requested url. This adaptation is important for cookie-based authentication. @@ -309,6 +316,14 @@ export class FlexServer implements GristServer { return homeUrl.href; } + /** + * Same as getHomeUrl, but for requesting internally. + */ + public getHomeInternalUrl(relPath: string = ''): string { + const homeUrl = new URL(relPath, this.getDefaultHomeInternalUrl()); + return homeUrl.href; + } + /** * Get a home url that is appropriate for the given document. For now, this * returns a default that works for all documents. That could change in future, @@ -316,7 +331,7 @@ export class FlexServer implements GristServer { * based on domain). */ public async getHomeUrlByDocId(docId: string, relPath: string = ''): Promise { - return new URL(relPath, this.getDefaultHomeUrl()).href; + return new URL(relPath, this.getDefaultHomeInternalUrl()).href; } // Get the port number the server listens on. This may be different from the port @@ -1429,12 +1444,12 @@ export class FlexServer implements GristServer { return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}}); })); - const createDoom = async (req: express.Request) => { + const createDoom = async () => { const dbManager = this.getHomeDBManager(); const permitStore = this.getPermitStore(); const notifier = this.getNotifier(); const loginSystem = await this.resolveLoginSystem(); - const homeUrl = this.getHomeUrl(req).replace(/\/$/, ''); + const homeUrl = this.getHomeInternalUrl().replace(/\/$/, ''); return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl); }; @@ -1458,7 +1473,7 @@ export class FlexServer implements GristServer { // Reuse Doom cli tool for account deletion. It won't allow to delete account if it has access // to other (not public) team sites. - const doom = await createDoom(req); + const doom = await createDoom(); await doom.deleteUser(userId); this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount'); return resp.status(200).json(true); @@ -1491,7 +1506,7 @@ export class FlexServer implements GristServer { } // Reuse Doom cli tool for org deletion. Note, this removes everything as a super user. - const doom = await createDoom(req); + const doom = await createDoom(); await doom.deleteOrg(org.id); this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', { @@ -1980,7 +1995,7 @@ export class FlexServer implements GristServer { // Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put // in temporary files, and the DocWorker needs to be on the same machine to have access to them. // This doesn't check for doc access permissions because the request isn't tied to a document. - addUploadRoute(this, this.app, this._trustOriginsMiddleware, ...basicMiddleware); + addUploadRoute(this, this.app, this._docWorkerMap, this._trustOriginsMiddleware, ...basicMiddleware); this.app.get('/attachment', ...docAccessMiddleware, expressWrap(async (req, res) => this._docWorker.getAttachment(req, res))); @@ -2418,10 +2433,10 @@ export class FlexServer implements GristServer { const workspace = workspaces.find(w => w.name === 'Home'); if (!workspace) { throw new Error('Home workspace not found'); } - const copyDocUrl = this.getHomeUrl(req, '/api/docs'); + const copyDocUrl = this.getHomeInternalUrl('/api/docs'); const response = await fetch(copyDocUrl, { headers: { - ...getTransitiveHeaders(req), + ...getTransitiveHeaders(req, { includeOrigin: false }), 'Content-Type': 'application/json', }, method: 'POST', diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index 9c53f347..f40bf109 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -35,6 +35,7 @@ export interface GristServer { settings?: Readonly>; getHost(): string; getHomeUrl(req: express.Request, relPath?: string): string; + getHomeInternalUrl(relPath?: string): string; getHomeUrlByDocId(docId: string, relPath?: string): Promise; getOwnUrl(): string; getOrgUrl(orgKey: string|number): Promise; @@ -127,6 +128,7 @@ export function createDummyGristServer(): GristServer { settings: {}, getHost() { return 'localhost:4242'; }, getHomeUrl() { return 'http://localhost:4242'; }, + getHomeInternalUrl() { return 'http://localhost:4242'; }, getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); }, getMergedOrgUrl() { return 'http://localhost:4242'; }, getOwnUrl() { return 'http://localhost:4242'; }, diff --git a/app/server/lib/ITestingHooks-ti.ts b/app/server/lib/ITestingHooks-ti.ts index f1454e4b..15c34f5a 100644 --- a/app/server/lib/ITestingHooks-ti.ts +++ b/app/server/lib/ITestingHooks-ti.ts @@ -11,7 +11,6 @@ export const ClientJsonMemoryLimits = t.iface([], { }); export const ITestingHooks = t.iface([], { - "getOwnPort": t.func("number"), "getPort": t.func("number"), "setLoginSessionProfile": t.func("void", t.param("gristSidCookie", "string"), t.param("profile", t.union("UserProfile", "null")), t.param("org", "string", true)), "setServerVersion": t.func("void", t.param("version", t.union("string", "null"))), diff --git a/app/server/lib/ITestingHooks.ts b/app/server/lib/ITestingHooks.ts index 3f5fa8d7..4a0f20a0 100644 --- a/app/server/lib/ITestingHooks.ts +++ b/app/server/lib/ITestingHooks.ts @@ -7,7 +7,6 @@ export interface ClientJsonMemoryLimits { } export interface ITestingHooks { - getOwnPort(): Promise; getPort(): Promise; setLoginSessionProfile(gristSidCookie: string, profile: UserProfile|null, org?: string): Promise; setServerVersion(version: string|null): Promise; diff --git a/app/server/lib/TestingHooks.ts b/app/server/lib/TestingHooks.ts index 0fb2fb8e..3c695662 100644 --- a/app/server/lib/TestingHooks.ts +++ b/app/server/lib/TestingHooks.ts @@ -68,11 +68,6 @@ export class TestingHooks implements ITestingHooks { private _workerServers: FlexServer[] ) {} - public async getOwnPort(): Promise { - log.info("TestingHooks.getOwnPort called"); - return this._server.getOwnPort(); - } - public async getPort(): Promise { log.info("TestingHooks.getPort called"); return this._port; diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 6424d6d9..94165405 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -1,5 +1,5 @@ import {ApiError} from 'app/common/ApiError'; -import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls'; +import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from 'app/common/gristUrls'; import * as gutil from 'app/common/gutil'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; diff --git a/app/server/lib/uploads.ts b/app/server/lib/uploads.ts index f4170da2..08c35852 100644 --- a/app/server/lib/uploads.ts +++ b/app/server/lib/uploads.ts @@ -1,7 +1,7 @@ import {ApiError} from 'app/common/ApiError'; import {InactivityTimer} from 'app/common/InactivityTimer'; import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads'; -import {getDocWorkerUrl} from 'app/common/UserAPI'; +import {getUrlFromPrefix} from 'app/common/UserAPI'; import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer'; import {expressWrap} from 'app/server/lib/expressWrap'; @@ -21,6 +21,8 @@ import * as multiparty from 'multiparty'; import fetch, {Response as FetchResponse} from 'node-fetch'; import * as path from 'path'; import * as tmp from 'tmp'; +import {IDocWorkerMap} from './DocWorkerMap'; +import {getDocWorkerInfoOrSelfPrefix} from './DocWorkerUtils'; // After some time of inactivity, clean up the upload. We give an hour, which seems generous, // except that if one is toying with import options, and leaves the upload in an open browser idle @@ -39,7 +41,12 @@ export interface FormResult { /** * Adds an upload route to the given express app, listening for POST requests at UPLOAD_URL_PATH. */ -export function addUploadRoute(server: GristServer, expressApp: Application, ...handlers: RequestHandler[]): void { +export function addUploadRoute( + server: GristServer, + expressApp: Application, + docWorkerMap: IDocWorkerMap, + ...handlers: RequestHandler[] +): void { // When doing a cross-origin post, the browser will check for access with options prior to posting. // We need to reassure it that the request will be accepted before it will go ahead and post. @@ -72,7 +79,7 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ... if (!docId) { throw new Error('doc must be specified'); } const accessId = makeAccessId(req, getAuthorizedUserId(req)); try { - const uploadResult: UploadResult = await fetchDoc(server, docId, req, accessId, + const uploadResult: UploadResult = await fetchDoc(server, docWorkerMap, docId, req, accessId, req.query.template === '1'); if (name) { globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name); @@ -404,24 +411,21 @@ async function _fetchURL(url: string, accessId: string|null, options?: FetchUrlO * Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials * supplied in the current request. */ -export async function fetchDoc(server: GristServer, docId: string, req: Request, accessId: string|null, - template: boolean): Promise { +export async function fetchDoc( + server: GristServer, + docWorkerMap: IDocWorkerMap, + docId: string, + req: Request, + accessId: string|null, + template: boolean +): Promise { // Prepare headers that preserve credentials of current user. - const headers = getTransitiveHeaders(req); - - // Passing the Origin header would serve no purpose here, as we are - // constructing an internal request to fetch from our own doc worker - // URL. Indeed, it may interfere, as it could incur a CORS check in - // `trustOrigin`, which we do not need. - delete headers.Origin; + const headers = getTransitiveHeaders(req, { includeOrigin: false }); // Find the doc worker responsible for the document we wish to copy. // The backend needs to be well configured for this to work. - const homeUrl = server.getHomeUrl(req); - const fetchUrl = new URL(`/api/worker/${docId}`, homeUrl); - const response: FetchResponse = await Deps.fetch(fetchUrl.href, {headers}); - await _checkForError(response); - const docWorkerUrl = getDocWorkerUrl(server.getOwnUrl(), await response.json()); + const { selfPrefix, docWorker } = await getDocWorkerInfoOrSelfPrefix(docId, docWorkerMap, server.getTag()); + const docWorkerUrl = docWorker ? docWorker.internalUrl : getUrlFromPrefix(server.getHomeInternalUrl(), selfPrefix); // Download the document, in full or as a template. const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`, docWorkerUrl.replace(/\/*$/, '/')); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 156353f3..3d9b1f85 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -35,19 +35,13 @@ import {serveSomething, Serving} from 'test/server/customUtil'; import {prepareDatabase} from 'test/server/lib/helpers/PrepareDatabase'; import {prepareFilesystemDirectoryForTests} from 'test/server/lib/helpers/PrepareFilesystemDirectoryForTests'; import {signal} from 'test/server/lib/helpers/Signal'; -import {TestServer} from 'test/server/lib/helpers/TestServer'; +import {TestServer, TestServerReverseProxy} from 'test/server/lib/helpers/TestServer'; import * as testUtils from 'test/server/testUtils'; import {waitForIt} from 'test/server/wait'; import defaultsDeep = require('lodash/defaultsDeep'); import pick = require('lodash/pick'); import { getDatabase } from 'test/testUtils'; -const chimpy = configForUser('Chimpy'); -const kiwi = configForUser('Kiwi'); -const charon = configForUser('Charon'); -const nobody = configForUser('Anonymous'); -const support = configForUser('support'); - // some doc ids const docIds: { [name: string]: string } = { ApiDataRecordsTest: 'sampledocid_7', @@ -68,6 +62,18 @@ let hasHomeApi: boolean; let home: TestServer; let docs: TestServer; let userApi: UserAPIImpl; +let extraHeadersForConfig = {}; + +function makeConfig(username: string): AxiosRequestConfig { + const originalConfig = configForUser(username); + return { + ...originalConfig, + headers: { + ...originalConfig.headers, + ...extraHeadersForConfig + } + }; +} describe('DocApi', function () { this.timeout(30000); @@ -77,12 +83,7 @@ describe('DocApi', function () { before(async function () { oldEnv = new testUtils.EnvironmentSnapshot(); - // Clear redis test database if redis is in use. - if (process.env.TEST_REDIS_URL) { - const cli = createClient(process.env.TEST_REDIS_URL); - await cli.flushdbAsync(); - await cli.quitAsync(); - } + await flushAllRedis(); // Create the tmp dir removing any previous one await prepareFilesystemDirectoryForTests(tmpDir); @@ -136,6 +137,7 @@ describe('DocApi', function () { }); it('should not allow anonymous users to create new docs', async () => { + const nobody = makeConfig('Anonymous'); const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody); assert.equal(resp.status, 403); }); @@ -158,6 +160,95 @@ describe('DocApi', function () { testDocApi(); }); + describe('behind a reverse-proxy', function () { + async function setupServersWithProxy(suitename: string, overrideEnvConf?: NodeJS.ProcessEnv) { + const proxy = new TestServerReverseProxy(); + const additionalEnvConfiguration = { + ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`, + GRIST_DATA_DIR: dataDir, + APP_HOME_URL: await proxy.getServerUrl(), + GRIST_ORG_IN_PATH: 'true', + GRIST_SINGLE_PORT: '0', + ...overrideEnvConf + }; + const home = await TestServer.startServer('home', tmpDir, suitename, additionalEnvConfiguration); + const docs = await TestServer.startServer( + 'docs', tmpDir, suitename, additionalEnvConfiguration, home.serverUrl + ); + proxy.requireFromOutsideHeader(); + + await proxy.start(home, docs); + + homeUrl = serverUrl = await proxy.getServerUrl(); + hasHomeApi = true; + extraHeadersForConfig = { + Origin: serverUrl, + ...TestServerReverseProxy.FROM_OUTSIDE_HEADER, + }; + + return {proxy, home, docs}; + } + + async function tearDown(proxy: TestServerReverseProxy, servers: TestServer[]) { + proxy.stop(); + for (const server of servers) { + await server.stop(); + } + await flushAllRedis(); + } + + let proxy: TestServerReverseProxy; + + describe('should run usual DocApi test', function () { + setup('behind-proxy-testdocs', async () => { + ({proxy, home, docs} = await setupServersWithProxy(suitename)); + }); + + after(() => tearDown(proxy, [home, docs])); + + testDocApi(); + }); + + async function testCompareDocs(proxy: TestServerReverseProxy, home: TestServer) { + const chimpy = makeConfig('chimpy'); + const userApiServerUrl = await proxy.getServerUrl(); + const chimpyApi = home.makeUserApi( + ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record } + ); + const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id; + const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1); + const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1); + const doc1 = chimpyApi.getDocAPI(docId1); + + return doc1.compareDoc(docId2); + } + + describe('with APP_HOME_INTERNAL_URL', function () { + setup('behind-proxy-with-apphomeinternalurl', async () => { + // APP_HOME_INTERNAL_URL will be set by TestServer. + ({proxy, home, docs} = await setupServersWithProxy(suitename)); + }); + after(() => tearDown(proxy, [home, docs])); + + it('should succeed to compare docs', async function () { + const res = await testCompareDocs(proxy, home); + assert.exists(res); + }); + }); + + describe('without APP_HOME_INTERNAL_URL', function () { + setup('behind-proxy-without-apphomeinternalurl', async () => { + ({proxy, home, docs} = await setupServersWithProxy(suitename, {APP_HOME_INTERNAL_URL: ''})); + }); + after(() => tearDown(proxy, [home, docs])); + + it('should succeed to compare docs', async function () { + const promise = testCompareDocs(proxy, home); + await assert.isRejected(promise, /TestServerReverseProxy: called public URL/); + }); + }); + }); + describe("should work directly with a docworker", async () => { setup('docs', async () => { const additionalEnvConfiguration = { @@ -233,6 +324,17 @@ describe('DocApi', function () { // Contains the tests. This is where you want to add more test. function testDocApi() { + let chimpy: AxiosRequestConfig, kiwi: AxiosRequestConfig, + charon: AxiosRequestConfig, nobody: AxiosRequestConfig, support: AxiosRequestConfig; + + before(function () { + chimpy = makeConfig('Chimpy'); + kiwi = makeConfig('Kiwi'); + charon = makeConfig('Charon'); + nobody = makeConfig('Anonymous'); + support = makeConfig('support'); + }); + async function generateDocAndUrl(docName: string = "Dummy") { const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id; const docId = await userApi.newDoc({name: docName}, wid); @@ -1341,7 +1443,7 @@ function testDocApi() { it(`GET /docs/{did}/tables/{tid}/data supports sorts and limits in ${mode}`, async function () { function makeQuery(sort: string[] | null, limit: number | null) { const url = new URL(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`); - const config = configForUser('chimpy'); + const config = makeConfig('chimpy'); if (mode === 'url') { if (sort) { url.searchParams.append('sort', sort.join(',')); @@ -2615,6 +2717,18 @@ function testDocApi() { await worker1.copyDoc(docId, undefined, 'copy'); }); + it("POST /docs/{did} with sourceDocId copies a document", async function () { + const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME); + const resp = await axios.post(`${serverUrl}/api/docs`, { + sourceDocumentId: docIds.TestDoc, + documentName: 'copy of TestDoc', + asTemplate: false, + workspaceId: chimpyWs + }, chimpy); + assert.equal(resp.status, 200); + assert.isString(resp.data); + }); + it("GET /docs/{did}/download/csv serves CSV-encoded document", async function () { const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/csv?tableId=Table1`, chimpy); assert.equal(resp.status, 200); @@ -2801,7 +2915,7 @@ function testDocApi() { }); it('POST /workspaces/{wid}/import handles empty filenames', async function () { - if (!process.env.TEST_REDIS_URL) { + if (!process.env.TEST_REDIS_URL || docs.proxiedServer) { this.skip(); } const worker1 = await userApi.getWorkerAPI('import'); @@ -2809,7 +2923,7 @@ function testDocApi() { const fakeData1 = await testUtils.readFixtureDoc('Hello.grist'); const uploadId1 = await worker1.upload(fakeData1, '.grist'); const resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1}, - configForUser('Chimpy')); + makeConfig('Chimpy')); assert.equal(resp.status, 200); assert.equal(resp.data.title, 'Untitled upload'); assert.equal(typeof resp.data.id, 'string'); @@ -2855,11 +2969,11 @@ function testDocApi() { }); it("document is protected during upload-and-import sequence", async function () { - if (!process.env.TEST_REDIS_URL) { + if (!process.env.TEST_REDIS_URL || home.proxiedServer) { this.skip(); } // Prepare an API for a different user. - const kiwiApi = new UserAPIImpl(`${home.serverUrl}/o/Fish`, { + const kiwiApi = new UserAPIImpl(`${homeUrl}/o/Fish`, { headers: {Authorization: 'Bearer api_key_for_kiwi'}, fetch: fetch as any, newFormData: () => new FormData() as any, @@ -2875,18 +2989,18 @@ function testDocApi() { // Check that kiwi only has access to their own upload. let wid = (await kiwiApi.getOrgWorkspaces('current')).find((w) => w.name === 'Big')!.id; let resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1}, - configForUser('Kiwi')); + makeConfig('Kiwi')); assert.equal(resp.status, 403); assert.deepEqual(resp.data, {error: "access denied"}); resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId2}, - configForUser('Kiwi')); + makeConfig('Kiwi')); assert.equal(resp.status, 200); // Check that chimpy has access to their own upload. wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id; resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1}, - configForUser('Chimpy')); + makeConfig('Chimpy')); assert.equal(resp.status, 200); }); @@ -2963,10 +3077,11 @@ function testDocApi() { }); it('filters urlIds by org', async function () { + if (home.proxiedServer) { this.skip(); } // Make two documents with same urlId const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; const doc1 = await userApi.newDoc({name: 'testdoc1', urlId: 'urlid'}, ws1); - const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, { + const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, { headers: {Authorization: 'Bearer api_key_for_chimpy'}, fetch: fetch as any, newFormData: () => new FormData() as any, @@ -2995,9 +3110,10 @@ function testDocApi() { it('allows docId access to any document from merged org', async function () { // Make two documents + if (home.proxiedServer) { this.skip(); } const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; const doc1 = await userApi.newDoc({name: 'testdoc1'}, ws1); - const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, { + const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, { headers: {Authorization: 'Bearer api_key_for_chimpy'}, fetch: fetch as any, newFormData: () => new FormData() as any, @@ -3125,11 +3241,17 @@ function testDocApi() { }); it("GET /docs/{did1}/compare/{did2} tracks changes between docs", async function () { - const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; - const docId1 = await userApi.newDoc({name: 'testdoc1'}, ws1); - const docId2 = await userApi.newDoc({name: 'testdoc2'}, ws1); - const doc1 = userApi.getDocAPI(docId1); - const doc2 = userApi.getDocAPI(docId2); + // Pass kiwi's headers as it contains both Authorization and Origin headers + // if run behind a proxy, so we can ensure that the Origin header check is not made. + const userApiServerUrl = docs.proxiedServer ? serverUrl : undefined; + const chimpyApi = home.makeUserApi( + ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record } + ); + const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id; + const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1); + const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1); + const doc1 = chimpyApi.getDocAPI(docId1); + const doc2 = chimpyApi.getDocAPI(docId2); // Stick some content in column A so it has a defined type // so diffs are smaller and simpler. @@ -3327,6 +3449,9 @@ function testDocApi() { }); it('doc worker endpoints ignore any /dw/.../ prefix', async function () { + if (docs.proxiedServer) { + this.skip(); + } const docWorkerUrl = docs.serverUrl; let resp = await axios.get(`${docWorkerUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy); assert.equal(resp.status, 200); @@ -4974,18 +5099,26 @@ function testDocApi() { } }); - const chimpyConfig = configForUser("Chimpy"); - const anonConfig = configForUser("Anonymous"); + const chimpyConfig = makeConfig("Chimpy"); + const anonConfig = makeConfig("Anonymous"); delete chimpyConfig.headers!["X-Requested-With"]; delete anonConfig.headers!["X-Requested-With"]; + let allowedOrigin; + // Target a more realistic Host than "localhost:port" - anonConfig.headers!.Host = chimpyConfig.headers!.Host = 'api.example.com'; + // (if behind a proxy, we already benefit from a custom and realistic host). + if (!home.proxiedServer) { + anonConfig.headers!.Host = chimpyConfig.headers!.Host = + 'api.example.com'; + allowedOrigin = 'http://front.example.com'; + } else { + allowedOrigin = serverUrl; + } const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`; const data = { records: [{ fields: {} }] }; - const allowedOrigin = 'http://front.example.com'; const forbiddenOrigin = 'http://evil.com'; // Normal same origin requests @@ -5217,6 +5350,7 @@ function setup(name: string, cb: () => Promise) { before(async function () { suitename = name; dataDir = path.join(tmpDir, `${suitename}-data`); + await flushAllRedis(); await fse.mkdirs(dataDir); await setupDataDir(dataDir); await cb(); @@ -5235,6 +5369,7 @@ function setup(name: string, cb: () => Promise) { // stop all servers await home.stop(); await docs.stop(); + extraHeadersForConfig = {}; }); } @@ -5263,3 +5398,12 @@ async function flushAuth() { await home.testingHooks.flushAuthorizerCache(); await docs.testingHooks.flushAuthorizerCache(); } + +async function flushAllRedis() { + // Clear redis test database if redis is in use. + if (process.env.TEST_REDIS_URL) { + const cli = createClient(process.env.TEST_REDIS_URL); + await cli.flushdbAsync(); + await cli.quitAsync(); + } +} diff --git a/test/server/lib/helpers/TestProxyServer.ts b/test/server/lib/helpers/TestProxyServer.ts index d5d171dc..e255d5e3 100644 --- a/test/server/lib/helpers/TestProxyServer.ts +++ b/test/server/lib/helpers/TestProxyServer.ts @@ -7,7 +7,6 @@ export class TestProxyServer { const server = new TestProxyServer(); await server._prepare(portNumber); return server; - } private _proxyCallsCounter: number = 0; @@ -38,7 +37,6 @@ export class TestProxyServer { } res.sendStatus(responseCode); res.end(); - //next(); }); }, portNumber); } diff --git a/test/server/lib/helpers/TestServer.ts b/test/server/lib/helpers/TestServer.ts index 51a5d39f..95946859 100644 --- a/test/server/lib/helpers/TestServer.ts +++ b/test/server/lib/helpers/TestServer.ts @@ -1,15 +1,20 @@ import {connectTestingHooks, TestingHooksClient} from "app/server/lib/TestingHooks"; import {ChildProcess, execFileSync, spawn} from "child_process"; +import * as http from "http"; import FormData from 'form-data'; import path from "path"; import * as fse from "fs-extra"; import * as testUtils from "test/server/testUtils"; import {UserAPIImpl} from "app/common/UserAPI"; -import {exitPromise} from "app/server/lib/serverUtils"; +import {exitPromise, getAvailablePort} from "app/server/lib/serverUtils"; import log from "app/server/lib/log"; import {delay} from "bluebird"; import fetch from "node-fetch"; import {Writable} from "stream"; +import express from "express"; +import { AddressInfo } from "net"; +import { isAffirmative } from "app/common/gutil"; +import httpProxy from 'http-proxy'; /** * This starts a server in a separate process. @@ -24,18 +29,26 @@ export class TestServer { options: {output?: Writable} = {}, // Pipe server output to the given stream ): Promise { - const server = new TestServer(serverTypes, tempDirectory, suitename); + const server = new this(serverTypes, tempDirectory, suitename); await server.start(_homeUrl, customEnv, options); return server; } public testingSocket: string; public testingHooks: TestingHooksClient; - public serverUrl: string; public stopped = false; + public get serverUrl() { + if (this._proxiedServer) { + throw new Error('Direct access to this test server is disallowed'); + } + return this._serverUrl; + } + public get proxiedServer() { return this._proxiedServer; } + private _serverUrl: string; private _server: ChildProcess; private _exitPromise: Promise; + private _proxiedServer: boolean = false; private readonly _defaultEnv; @@ -44,9 +57,6 @@ export class TestServer { GRIST_INST_DIR: this.rootDir, GRIST_DATA_DIR: path.join(this.rootDir, "data"), GRIST_SERVERS: this._serverTypes, - // with port '0' no need to hard code a port number (we can use testing hooks to find out what - // port server is listening on). - GRIST_PORT: '0', GRIST_DISABLE_S3: 'true', REDIS_URL: process.env.TEST_REDIS_URL, GRIST_TRIGGER_WAIT_DELAY: '100', @@ -68,9 +78,16 @@ export class TestServer { // Unix socket paths typically can't be longer than this. Who knew. Make the error obvious. throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`); } - const env = { - APP_HOME_URL: _homeUrl, + + const port = await getAvailablePort(); + this._serverUrl = `http://localhost:${port}`; + const homeUrl = _homeUrl ?? (this._serverTypes.includes('home') ? this._serverUrl : undefined); + + const env: NodeJS.ProcessEnv = { + APP_HOME_URL: homeUrl, + APP_HOME_INTERNAL_URL: homeUrl, GRIST_TESTING_SOCKET: this.testingSocket, + GRIST_PORT: String(port), ...this._defaultEnv, ...customEnv }; @@ -98,7 +115,7 @@ export class TestServer { .catch(() => undefined); await this._waitServerReady(); - log.info(`server ${this._serverTypes} up and listening on ${this.serverUrl}`); + log.info(`server ${this._serverTypes} up and listening on ${this._serverUrl}`); } public async stop() { @@ -125,11 +142,9 @@ export class TestServer { // create testing hooks and get own port this.testingHooks = await connectTestingHooks(this.testingSocket); - const port: number = await this.testingHooks.getOwnPort(); - this.serverUrl = `http://localhost:${port}`; // wait for check - return (await fetch(`${this.serverUrl}/status/hooks`, {timeout: 1000})).ok; + return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok; } catch (err) { log.warn("Failed to initialize server", err); return false; @@ -142,14 +157,32 @@ export class TestServer { // Returns the promise for the ChildProcess's signal or exit code. public getExitPromise(): Promise { return this._exitPromise; } - public makeUserApi(org: string, user: string = 'chimpy'): UserAPIImpl { - return new UserAPIImpl(`${this.serverUrl}/o/${org}`, { - headers: {Authorization: `Bearer api_key_for_${user}`}, + public makeUserApi( + org: string, + user: string = 'chimpy', + { + headers = {Authorization: `Bearer api_key_for_${user}`}, + serverUrl = this._serverUrl, + }: { + headers?: Record + serverUrl?: string, + } = { headers: undefined, serverUrl: undefined }, + ): UserAPIImpl { + return new UserAPIImpl(`${serverUrl}/o/${org}`, { + headers, fetch: fetch as unknown as typeof globalThis.fetch, newFormData: () => new FormData() as any, }); } + /** + * Assuming that the server is behind a reverse-proxy (like TestServerReverseProxy), + * disallow access to the serverUrl to prevent the tests to join the server directly. + */ + public disallowDirectAccess() { + this._proxiedServer = true; + } + private async _waitServerReady() { // It's important to clear the timeout, because it can prevent node from exiting otherwise, // which is annoying when running only this test for debugging. @@ -170,3 +203,103 @@ export class TestServer { } } } + +/** + * Creates a reverse-proxy for a home and a doc worker. + * + * The workers are then disallowed to be joined directly, the tests are assumed to + * pass through this reverse-proxy. + * + * You may use it like follow: + * ```ts + * const proxy = new TestServerReverseProxy(); + * // Create here a doc and a home workers with their env variables + * proxy.requireFromOutsideHeader(); // Optional + * await proxy.start(home, docs); + * ``` + */ +export class TestServerReverseProxy { + + // Use a different hostname for the proxy than the doc and home workers' + // so we can ensure that either we omit the Origin header (so the internal calls to home and doc workers + // are not considered as CORS requests), or otherwise we fail because the hostnames are different + // https://github.com/gristlabs/grist-core/blob/24b39c651b9590cc360cc91b587d3e1b301a9c63/app/server/lib/requestUtils.ts#L85-L98 + public static readonly HOSTNAME: string = 'grist-test-proxy.127.0.0.1.nip.io'; + + public static FROM_OUTSIDE_HEADER = {"X-FROM-OUTSIDE": true}; + + private _app = express(); + private _proxyServer: http.Server; + private _proxy: httpProxy = httpProxy.createProxy(); + private _address: Promise; + private _requireFromOutsideHeader = false; + + public get stopped() { return !this._proxyServer.listening; } + + public constructor() { + this._proxyServer = this._app.listen(0); + this._address = new Promise((resolve) => { + this._proxyServer.once('listening', () => { + resolve(this._proxyServer.address() as AddressInfo); + }); + }); + } + + /** + * Require the reverse-proxy to be called from the outside world. + * This assumes that every requests to the proxy includes the header + * provided in TestServerReverseProxy.FROM_OUTSIDE_HEADER + * + * If a call is done by a worker (assuming they don't include that header), + * the proxy rejects with a FORBIDEN http status. + */ + public requireFromOutsideHeader() { + this._requireFromOutsideHeader = true; + } + + public async start(homeServer: TestServer, docServer: TestServer) { + this._app.all(['/dw/dw1', '/dw/dw1/*'], (oreq, ores) => this._getRequestHandlerFor(docServer)); + this._app.all('/*', this._getRequestHandlerFor(homeServer)); + + // Forbid now the use of serverUrl property, so we don't allow the tests to + // call the workers directly + homeServer.disallowDirectAccess(); + docServer.disallowDirectAccess(); + + log.info('proxy server running on ', await this.getServerUrl()); + } + + public async getAddress() { + return this._address; + } + + public async getServerUrl() { + const address = await this.getAddress(); + return `http://${TestServerReverseProxy.HOSTNAME}:${address.port}`; + } + + public stop() { + if (this.stopped) { + return; + } + log.info("Stopping node TestServerReverseProxy"); + this._proxyServer.close(); + this._proxy.close(); + } + + private _getRequestHandlerFor(server: TestServer) { + const serverUrl = new URL(server.serverUrl); + + return (oreq: express.Request, ores: express.Response) => { + log.debug(`[proxy] Requesting (method=${oreq.method}): ${new URL(oreq.url, serverUrl).href}`); + + // See the requireFromOutsideHeader() method for the explanation + if (this._requireFromOutsideHeader && !isAffirmative(oreq.get("X-FROM-OUTSIDE"))) { + log.error('TestServerReverseProxy: called public URL from internal'); + return ores.status(403).json({error: "TestServerReverseProxy: called public URL from internal "}); + } + + this._proxy.web(oreq, ores, { target: serverUrl }); + }; + } +} From 6d9df7de7e9648dafb59a73a2be72fa5c0629050 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 May 2024 14:24:30 -0400 Subject: [PATCH 02/26] automated update to translation keys (#974) Co-authored-by: Paul's Grist Bot --- static/locales/en.client.json | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/static/locales/en.client.json b/static/locales/en.client.json index c26932fc..ef888e63 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -300,7 +300,34 @@ "Ok": "OK", "Manage Webhooks": "Manage Webhooks", "Webhooks": "Webhooks", - "API Console": "API Console" + "API Console": "API Console", + "API URL copied to clipboard": "API URL copied to clipboard", + "API console": "API console", + "API documentation.": "API documentation.", + "Base doc URL: {{docApiUrl}}": "Base doc URL: {{docApiUrl}}", + "Coming soon": "Coming soon", + "Copy to clipboard": "Copy to clipboard", + "Currency": "Currency", + "Data Engine": "Data Engine", + "Default for DateTime columns": "Default for DateTime columns", + "Document ID": "Document ID", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}", + "Find slow formulas": "Find slow formulas", + "For currency columns": "For currency columns", + "For number and date formats": "For number and date formats", + "Formula times": "Formula times", + "Hard reset of data engine": "Hard reset of data engine", + "ID for API use": "ID for API use", + "Locale": "Locale", + "Manage webhooks": "Manage webhooks", + "Notify other services on doc changes": "Notify other services on doc changes", + "Python": "Python", + "Python version used": "Python version used", + "Reload": "Reload", + "Time Zone": "Time Zone", + "Try API calls from the browser": "Try API calls from the browser", + "python2 (legacy)": "python2 (legacy)", + "python3 (recommended)": "python3 (recommended)" }, "DocumentUsage": { "Attachments Size": "Size of Attachments", @@ -567,7 +594,8 @@ }, "OnBoardingPopups": { "Finish": "Finish", - "Next": "Next" + "Next": "Next", + "Previous": "Previous" }, "OpenVideoTour": { "Grist Video Tour": "Grist Video Tour", @@ -921,7 +949,8 @@ "Don't show tips": "Don't show tips", "Undo to restore": "Undo to restore", "Got it": "Got it", - "Don't show again": "Don't show again" + "Don't show again": "Don't show again", + "TIP": "TIP" }, "pages": { "Duplicate Page": "Duplicate Page", @@ -1543,5 +1572,11 @@ "Error in dropdown condition": "Error in dropdown condition", "No choices matching condition": "No choices matching condition", "No choices to select": "No choices to select" + }, + "FormRenderer": { + "Reset": "Reset", + "Search": "Search", + "Select...": "Select...", + "Submit": "Submit" } } From c74530bf0fcabe215039ecce26b749af6866c996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Tue, 14 May 2024 16:09:05 -0400 Subject: [PATCH 03/26] v1.1.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0b4ea33a..69683443 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grist-core", - "version": "1.1.13", + "version": "1.1.14", "license": "Apache-2.0", "description": "Grist is the evolution of spreadsheets", "homepage": "https://github.com/gristlabs/grist-core", From f4f6c3870103b9fe7c9a4fc748836b5e7d2e0aab Mon Sep 17 00:00:00 2001 From: Camille L Date: Wed, 15 May 2024 18:34:07 +0000 Subject: [PATCH 04/26] Translated using Weblate (French) Currently translated at 99.3% (1249 of 1257 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/ --- static/locales/fr.client.json | 46 +++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 6c6b0d2c..cb42b57a 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -1146,7 +1146,9 @@ "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à !", - "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." + "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.", + "Example: {{example}}": "Exemple : {{example}}", + "Filter displayed dropdown values with a condition.": "Filtrer les valeurs affichées dans la liste déroulante en fonction d'une condition." }, "ColumnTitle": { "Add description": "Ajouter une description", @@ -1473,7 +1475,24 @@ "Admin Panel": "Panneau d'administration", "Sponsor": "Parrainage", "Support Grist": "Soutenir Grist", - "Version": "Version" + "Version": "Version", + "Check now": "Vérifier", + "Checking for updates...": "Vérifier les mises à jour...", + "Error": "Erreur", + "Error checking for updates": "Erreur lors de la vérification des mises à jour", + "Grist is up to date": "Grist est à jour", + "Last checked {{time}}": "Dernière vérification {{time}}", + "Learn more.": "En savoir plus.", + "No information available": "Aucune information disponible", + "OK": "OK", + "Security Settings": "Paramètres de sécurité", + "Grist releases are at ": "Les releases de Grist sont disponibles sur ", + "Newer version available": "Nouvelle version disponible", + "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Grist permet d'utiliser des formules très puissantes, en utilisant Python. Nous recommandons de définir la variable d'environnement GRIST_SANDBOX_FLAVOR sur gvisor si votre machine le supporte (la plupart le supportent), afin d'exécuter des formules dans chaque document à l'intérieur d'une sandbox isolée des autres documents et du réseau.", + "Updates": "Mises à jour", + "unconfigured": "non configuré", + "unknown": "inconnu", + "Auto-check when this page loads": "Vérification automatique au chargement de cette page" }, "Field": { "No choices configured": "Aucun choix configuré", @@ -1500,5 +1519,28 @@ }, "Columns": { "Remove Column": "Supprimer la colonne" + }, + "ChoiceEditor": { + "Error in dropdown condition": "Erreur dans la condition de la liste déroulante", + "No choices to select": "Aucun choix à sélectionner", + "No choices matching condition": "Aucun choix correspondant à la condition" + }, + "ChoiceListEditor": { + "Error in dropdown condition": "Erreur dans la condition de la liste déroulante", + "No choices to select": "Aucun choix à sélectionner", + "No choices matching condition": "Aucun choix correspondant à la condition" + }, + "DropdownConditionConfig": { + "Invalid columns: {{colIds}}": "Colonnes invalides : {{colIds}}", + "Set dropdown condition": "Définir la condition de la liste déroulante", + "Dropdown Condition": "Condition de la liste déroulante" + }, + "DropdownConditionEditor": { + "Enter condition.": "Saisir la condition." + }, + "ReferenceUtils": { + "Error in dropdown condition": "Erreur dans la condition de la liste déroulant", + "No choices matching condition": "Aucun choix correspondant à la condition", + "No choices to select": "Aucun choix à sélectionner" } } From 2beba6572664b8e1e00c1418b7bbe68d43cf7274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=BB=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=20=D0=92?= Date: Tue, 14 May 2024 22:46:12 +0000 Subject: [PATCH 05/26] Translated using Weblate (Russian) Currently translated at 99.6% (1252 of 1257 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/ru/ --- static/locales/ru.client.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/locales/ru.client.json b/static/locales/ru.client.json index 5b838d27..56ba5593 100644 --- a/static/locales/ru.client.json +++ b/static/locales/ru.client.json @@ -1315,7 +1315,7 @@ "Opt out of Telemetry": "Отказаться от телеметрии", "Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub", "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.", - "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов.", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в {{link}} и никогда не собираем содержимое документов.", "Sponsor": "Спонсор" }, "buildViewSectionDom": { From 1d55b5bcce57e39430c0f68ba2ba72d503e48309 Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Thu, 16 May 2024 09:44:00 +0200 Subject: [PATCH 06/26] Added translation using Weblate (Bulgarian) --- static/locales/bg.client.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 static/locales/bg.client.json diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/static/locales/bg.client.json @@ -0,0 +1 @@ +{} From bb249ff462f0e54e15de9babdeccfd65475b1fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 16 May 2024 17:15:14 +0200 Subject: [PATCH 07/26] fix: cancel when escape (#986) --- app/client/ui/ColumnFilterMenu.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 613550e7..581ff764 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -130,7 +130,10 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio dom.onDispose(() => cancel ? doCancel() : doSave()), dom.onKeyDown({ Enter: () => onClose(), - Escape: () => onClose(), + Escape: () => { + cancel = true; + onClose(); + }, }), // Filter by range From d8f4e075fedfe7a6db0361ee16877450e6d65cfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 16 May 2024 17:31:37 +0200 Subject: [PATCH 08/26] feat: ADD Terms of service link (#985) Adds an optional terms of service link for sites that need it. --------- Co-authored-by: Jonathan Perret --- README.md | 1 + app/client/ui/HomeLeftPane.ts | 8 +++++++ app/common/gristUrls.ts | 13 ++++++++++++ app/server/lib/sendAppPage.ts | 2 ++ static/locales/fr.client.json | 3 ++- test/nbrowser/TermsOfService.ts | 37 +++++++++++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/nbrowser/TermsOfService.ts diff --git a/README.md b/README.md index 4582729d..1c9c46d8 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,7 @@ Grist can be configured in many ways. Here are the main environment variables it | GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org | | GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org | | GRIST_HELP_CENTER | set the help center link ref | +| GRIST_TERMS_OF_SERVICE_URL | if set, adds terms of service link | | FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool) | | GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form) | | GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) | diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index e695345d..d30e6696 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -146,6 +146,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom ) : null ), createHelpTools(home.app), + (commonUrls.termsOfService ? + cssPageEntry( + cssPageLink(cssPageIcon('Memo'), cssLinkText(t("Terms of service")), + { href: commonUrls.termsOfService, target: '_blank' }, + testId('dm-tos'), + ), + ) : null + ), ) ) ); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 431fd49e..ae7f8f4f 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -91,6 +91,7 @@ export const commonUrls = { helpAPI: 'https://support.getgrist.com/api', freeCoachingCall: getFreeCoachingCallUrl(), contactSupport: getContactSupportUrl(), + termsOfService: getTermsOfServiceUrl(), plans: "https://www.getgrist.com/pricing", sproutsProgram: "https://www.getgrist.com/sprouts-program", contact: "https://www.getgrist.com/contact", @@ -689,6 +690,9 @@ export interface GristLoadConfig { // Url for support for the browser client to use. helpCenterUrl?: string; + // Url for terms of service for the browser client to use + termsOfServiceUrl?: string; + // Url for free coaching call scheduling for the browser client to use. freeCoachingCallUrl?: string; @@ -900,6 +904,15 @@ export function getHelpCenterUrl(): string { } } +export function getTermsOfServiceUrl(): string|undefined { + if(isClient()) { + const gristConfig: GristLoadConfig = (window as any).gristConfig; + return gristConfig && gristConfig.termsOfServiceUrl || undefined; + } else { + return process.env.GRIST_TERMS_OF_SERVICE_URL || undefined; + } +} + export function getFreeCoachingCallUrl(): string { const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call"; if(isClient()) { diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 8c5ebf92..ec53f2be 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -4,6 +4,7 @@ import { getFreeCoachingCallUrl, getHelpCenterUrl, getPageTitleSuffix, + getTermsOfServiceUrl, GristLoadConfig, IFeature } from 'app/common/gristUrls'; @@ -62,6 +63,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi baseDomain, singleOrg: process.env.GRIST_SINGLE_ORG, helpCenterUrl: getHelpCenterUrl(), + termsOfServiceUrl: getTermsOfServiceUrl(), freeCoachingCallUrl: getFreeCoachingCallUrl(), contactSupportUrl: getContactSupportUrl(), pathOnly, diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index cb42b57a..0a08458c 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -495,7 +495,8 @@ "Workspace will be moved to Trash.": "Le dossier va être mis à la corbeille.", "Manage Users": "Gérer les utilisateurs", "Access Details": "Détails d'accès", - "Tutorial": "Tutoriel" + "Tutorial": "Tutoriel", + "Terms of service": "CGU" }, "Importer": { "Update existing records": "Mettre à jour les enregistrements existants", diff --git a/test/nbrowser/TermsOfService.ts b/test/nbrowser/TermsOfService.ts new file mode 100644 index 00000000..21a021f6 --- /dev/null +++ b/test/nbrowser/TermsOfService.ts @@ -0,0 +1,37 @@ +import { assert, driver } from 'mocha-webdriver'; +import * as gu from 'test/nbrowser/gristUtils'; +import { server, setupTestSuite } from 'test/nbrowser/testUtils'; +import { EnvironmentSnapshot } from 'test/server/testUtils'; + +describe('Terms of service link', function () { + this.timeout(20000); + setupTestSuite({samples: true}); + + let session: gu.Session; + let oldEnv: EnvironmentSnapshot; + + before(async function () { + oldEnv = new EnvironmentSnapshot(); + session = await gu.session().teamSite.login(); + }); + + after(async function () { + oldEnv.restore(); + await server.restart(); + }); + + it('is visible in home menu', async function () { + process.env.GRIST_TERMS_OF_SERVICE_URL = 'https://example.com/tos'; + await server.restart(); + await session.loadDocMenu('/'); + assert.isTrue(await driver.find('.test-dm-tos').isDisplayed()); + assert.equal(await driver.find('.test-dm-tos').getAttribute('href'), 'https://example.com/tos'); + }); + + it('is not visible when environment variable is not set', async function () { + delete process.env.GRIST_TERMS_OF_SERVICE_URL; + await server.restart(); + await session.loadDocMenu('/'); + assert.isFalse(await driver.find('.test-dm-tos').isPresent()); + }); +}); From b4acb157f82cc81983b27b39d2a04d4999c0e2fa Mon Sep 17 00:00:00 2001 From: Spoffy <4805393+Spoffy@users.noreply.github.com> Date: Thu, 16 May 2024 18:09:38 +0100 Subject: [PATCH 09/26] Displays the current authentication mechanism in the admin panel (#981) * Adds authentication mechanism to admin panel Adds field to the "Security settings" admin display, showing the currently configured authentication mechanism. * Adds 14px margin to admin panel names --- app/client/ui/AdminPanel.ts | 42 ++++++++++++++++++++++++++++++++++ app/client/ui/AdminPanelCss.ts | 1 + app/common/BootProbe.ts | 3 ++- app/server/lib/BootProbes.ts | 15 ++++++++++++ app/server/lib/FlexServer.ts | 5 ++++ app/server/lib/GristServer.ts | 2 ++ 6 files changed, 67 insertions(+), 1 deletion(-) diff --git a/app/client/ui/AdminPanel.ts b/app/client/ui/AdminPanel.ts index de0efceb..3a229714 100644 --- a/app/client/ui/AdminPanel.ts +++ b/app/client/ui/AdminPanel.ts @@ -105,6 +105,13 @@ export class AdminPanel extends Disposable { value: this._buildSandboxingDisplay(owner), expandedContent: this._buildSandboxingNotice(), }), + dom.create(AdminSectionItem, { + id: 'authentication', + name: t('Authentication'), + description: t('Current authentication method'), + value: this._buildAuthenticationDisplay(owner), + expandedContent: this._buildAuthenticationNotice(owner), + }) ]), dom.create(AdminSection, t('Version'), [ @@ -156,6 +163,37 @@ isolated from other documents and isolated from the network.'), ]; } + private _buildAuthenticationDisplay(owner: IDisposableOwner) { + return dom.domComputed( + use => { + const req = this._checks.requestCheckById(use, 'authentication'); + const result = req ? use(req.result) : undefined; + if (!result) { + return cssValueLabel(cssErrorText('unavailable')); + } + + const { success, details } = result; + const loginSystemId = details?.loginSystemId; + + if (!success || !loginSystemId) { + return cssValueLabel(cssErrorText('auth error')); + } + + if (loginSystemId === 'no-logins') { + return cssValueLabel(cssDangerText('no authentication')); + } + + return cssValueLabel(cssHappyText(loginSystemId)); + } + ); + } + + private _buildAuthenticationNotice(owner: IDisposableOwner) { + return t('Grist allows different types of authentication to be configured, including SAML and OIDC. \ + We recommend enabling one of these if Grist is accessible over the network or being made available \ + to multiple people.'); + } + private _buildUpdates(owner: MultiHolder) { // We can be in those states: enum State { @@ -446,6 +484,10 @@ const cssErrorText = styled('span', ` color: ${theme.errorText}; `); +export const cssDangerText = styled('div', ` + color: ${theme.dangerText}; +`); + const cssHappyText = styled('span', ` color: ${theme.controlFg}; `); diff --git a/app/client/ui/AdminPanelCss.ts b/app/client/ui/AdminPanelCss.ts index 122ff395..3732f801 100644 --- a/app/client/ui/AdminPanelCss.ts +++ b/app/client/ui/AdminPanelCss.ts @@ -124,6 +124,7 @@ const cssItemName = styled('div', ` font-weight: bold; display: flex; align-items: center; + margin-right: 14px; font-size: ${vars.largeFontSize}; padding-left: 24px; &-prefixed { diff --git a/app/common/BootProbe.ts b/app/common/BootProbe.ts index 753af1c4..dd867049 100644 --- a/app/common/BootProbe.ts +++ b/app/common/BootProbe.ts @@ -6,7 +6,8 @@ export type BootProbeIds = 'reachable' | 'host-header' | 'sandboxing' | - 'system-user' + 'system-user' | + 'authentication' ; export interface BootProbeResult { diff --git a/app/server/lib/BootProbes.ts b/app/server/lib/BootProbes.ts index 8edc28ae..95429491 100644 --- a/app/server/lib/BootProbes.ts +++ b/app/server/lib/BootProbes.ts @@ -58,6 +58,7 @@ export class BootProbes { this._probes.push(_bootProbe); this._probes.push(_hostHeaderProbe); this._probes.push(_sandboxingProbe); + this._probes.push(_authenticationProbe); this._probeById = new Map(this._probes.map(p => [p.id, p])); } } @@ -202,3 +203,17 @@ const _sandboxingProbe: Probe = { }; }, }; + +const _authenticationProbe: Probe = { + id: 'authentication', + name: 'Authentication system', + apply: async(server, req) => { + const loginSystemId = server.getInfo('loginMiddlewareComment'); + return { + success: loginSystemId != undefined, + details: { + loginSystemId, + } + }; + }, +}; diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 59d8a3e5..0e8b28a6 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -1426,6 +1426,11 @@ export class FlexServer implements GristServer { return this._sandboxInfo; } + public getInfo(key: string): any { + const infoPair = this.info.find(([keyToCheck]) => key === keyToCheck); + return infoPair?.[1]; + } + public disableExternalStorage() { if (this.deps.has('doc')) { throw new Error('disableExternalStorage called too late'); diff --git a/app/server/lib/GristServer.ts b/app/server/lib/GristServer.ts index f40bf109..57e66389 100644 --- a/app/server/lib/GristServer.ts +++ b/app/server/lib/GristServer.ts @@ -67,6 +67,7 @@ export interface GristServer { getBundledWidgets(): ICustomWidget[]; hasBoot(): boolean; getSandboxInfo(): SandboxInfo|undefined; + getInfo(key: string): any; } export interface GristLoginSystem { @@ -159,6 +160,7 @@ export function createDummyGristServer(): GristServer { getBundledWidgets() { return []; }, hasBoot() { return false; }, getSandboxInfo() { return undefined; }, + getInfo(key: string) { return undefined; } }; } From a91d4dcdd3d55889dc52d78878c7fbdc56bb7b8a Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 16 May 2024 03:04:12 +0000 Subject: [PATCH 10/26] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1290 of 1290 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 41 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index cbe93f94..1c3079db 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -308,7 +308,34 @@ "API": "API", "Manage Webhooks": "Gerenciar ganchos web", "Webhooks": "Ganchos Web", - "API Console": "Consola API" + "API Console": "Consola API", + "API documentation.": "Documentação de API.", + "Currency": "Moeda", + "Data Engine": "Motor de dados", + "Find slow formulas": "Encontrar fórmulas lentas", + "For currency columns": "Para colunas de moeda", + "Hard reset of data engine": "Reinicialização total do motor de dados", + "Manage webhooks": "Gerenciar webhooks", + "Python": "Python", + "Python version used": "Versão Python usada", + "Try API calls from the browser": "Experimente chamadas de API do navegador", + "python2 (legacy)": "python2 (legado)", + "API URL copied to clipboard": "URL da API copiado para a área de transferência", + "Coming soon": "Em breve", + "API console": "Consola API", + "Base doc URL: {{docApiUrl}}": "URL do documento base: {{docApiUrl}}", + "Copy to clipboard": "Copiar para a área de transferência", + "Default for DateTime columns": "Padrão para colunas DataHorário", + "Document ID": "ID do documento", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID do documento a ser usado sempre que a API REST fizer uma chamada para {{docId}}. Veja {{apiURL}}", + "For number and date formats": "Para formatos de número e data", + "Formula times": "Tempos de fórmula", + "Locale": "Localização", + "ID for API use": "ID para uso da API", + "Notify other services on doc changes": "Notifique outros serviços em alterações doc", + "Reload": "Recarregar", + "Time Zone": "Fuso horário", + "python3 (recommended)": "python3 (recomendado)" }, "DocumentUsage": { "Attachments Size": "Tamanho dos Anexos", @@ -607,7 +634,8 @@ }, "OnBoardingPopups": { "Finish": "Terminar", - "Next": "Próximo" + "Next": "Próximo", + "Previous": "Anterior" }, "OpenVideoTour": { "Grist Video Tour": "Tour de Vídeo Grist", @@ -981,7 +1009,8 @@ "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" + "Don't show tips": "Não mostrar dicas", + "TIP": "DICA" }, "pages": { "Duplicate Page": "Duplicar a Página", @@ -1607,5 +1636,11 @@ "Error in dropdown condition": "Erro na condição do menu suspenso", "No choices matching condition": "Nenhuma opção que corresponda à condição", "No choices to select": "Não há opções para selecionar" + }, + "FormRenderer": { + "Reset": "Redefinir", + "Search": "Pesquisar", + "Select...": "Selecionar...", + "Submit": "Enviar" } } From a69a1fa843429ceb9d56eb5eed8914d700f9cdf0 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Thu, 16 May 2024 03:10:57 +0000 Subject: [PATCH 11/26] Translated using Weblate (German) Currently translated at 100.0% (1290 of 1290 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index 99331f95..a7c03e2a 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -308,7 +308,34 @@ "Ok": "OK", "Webhooks": "Webhaken", "Manage Webhooks": "Webhaken verwalten", - "API Console": "API-Konsole" + "API Console": "API-Konsole", + "Coming soon": "Demnächst verfügbar", + "For number and date formats": "Für Zahlen- und Datumsformate", + "Formula times": "Formelzeiten", + "Locale": "Lokale", + "Time Zone": "Zeitzone", + "Find slow formulas": "Langsame Formeln finden", + "Manage webhooks": "Webhooks verwalten", + "For currency columns": "Für Währungsspalten", + "Notify other services on doc changes": "Benachrichtigen Sie andere Dienstleistungen bei doc Änderungen", + "Hard reset of data engine": "Hartes Zurücksetzen der Datenmaschine", + "Python": "Python", + "ID for API use": "ID für API-Verwendung", + "Python version used": "Verwendete Python-Version", + "Reload": "Neu laden", + "Try API calls from the browser": "Versuchen Sie API-Aufrufe über den Browser", + "python2 (legacy)": "python2 (veraltet)", + "python3 (recommended)": "python3 (empfohlen)", + "API URL copied to clipboard": "API-URL in die Zwischenablage kopiert", + "Base doc URL: {{docApiUrl}}": "URL des Basisdokuments: {{docApiUrl}}", + "API console": "API-Konsole", + "API documentation.": "API Dokumentation.", + "Copy to clipboard": "In die Zwischenablage kopieren", + "Currency": "Währung", + "Data Engine": "Datenmaschine", + "Default for DateTime columns": "Standard für DateTime-Spalten", + "Document ID": "Dokument-ID", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "Dokument-ID, die bei Aufrufen der REST-API für {{docId}} zu verwenden ist. Siehe {{apiURL}}" }, "DocumentUsage": { "Attachments Size": "Größe der Anhänge", @@ -607,7 +634,8 @@ }, "OnBoardingPopups": { "Finish": "Beenden", - "Next": "Weiter" + "Next": "Weiter", + "Previous": "Vorherige" }, "OpenVideoTour": { "Grist Video Tour": "Grist Video Tour", @@ -981,7 +1009,8 @@ "Dismiss": "Ablehnen", "Don't ask again.": "Frag nicht mehr.", "Don't show again.": "Zeig nicht mehr.", - "Don't show tips": "Keine Tipps anzeigen" + "Don't show tips": "Keine Tipps anzeigen", + "TIP": "TIPP" }, "pages": { "Duplicate Page": "Seite duplizieren", @@ -1607,5 +1636,11 @@ }, "DropdownConditionEditor": { "Enter condition.": "Bedingung eingeben." + }, + "FormRenderer": { + "Reset": "Zurücksetzen", + "Submit": "Einreichen", + "Search": "Suchen", + "Select...": "Wählen Sie..." } } From 8471e8f746fa7c561deea3f6858414863dc392bb Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Thu, 16 May 2024 08:59:16 +0000 Subject: [PATCH 12/26] Translated using Weblate (Bulgarian) Currently translated at 49.1% (634 of 1290 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 757 +++++++++++++++++++++++++++++++++- 1 file changed, 756 insertions(+), 1 deletion(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index 0967ef42..e7a809be 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -1 +1,756 @@ -{} +{ + "ACUserManager": { + "Invite new member": "Покани нов член", + "Enter email address": "Въведете e-mail адрес", + "We'll email an invite to {{email}}": "Ще изпратим покана до {{email}}" + }, + "AccessRules": { + "Add Column Rule": "Добави правило за колона", + "Add User Attributes": "Добави потребителски атрибути", + "Add Default Rule": "Добави правило по подразбиране", + "Attribute name": "Име на атрибут", + "Checking...": "Проверяне…", + "Condition": "Условие", + "Default Rules": "Правила по подразбиране", + "Delete Table Rules": "Изтрий правилата на таблицата", + "Enter Condition": "Въведи условие", + "Everyone": "Всеки", + "Everyone Else": "Всички останали", + "Invalid": "Невалиден", + "Attribute to Look Up": "Атрибут за търсене", + "Allow everyone to view Access Rules.": "Позволи всички да четат правилата за достъп.", + "Lookup Column": "Колона за търсене", + "Permission to access the document in full when needed": "Разрешение за пълен достъп до документа, когато е необходимо", + "Remove column {{- colId }} from {{- tableId }} rules": "Премахни колона {{- colId }} от правилата на {{- tableId }}", + "Remove {{- name }} user attribute": "Премани потребителски атрибут {{- name }}", + "Reset": "Нулирай", + "Save": "Съхрани", + "Type a message...": "Напиши съобщение.…", + "User Attributes": "Потреибтелски атрибути", + "View As": "Разгледай като", + "Permission to edit document structure": "Пълномощия за редакция на документната структура", + "Add Table Rules": "Добави правила за таблица", + "Lookup Table": "Таблица за търсене", + "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Позволи на всички да копират целия документ или да го прегледат изцяло в режим \"fiddle\".\nУдобно за примери и шаблони, но не и за чувствители данни.", + "Permission to view Access Rules": "Разрешение за преглед на правилата за достъп", + "Saved": "Съхранено", + "Permissions": "Правомощия", + "Special Rules": "Специални правила", + "Remove {{- tableId }} rules": "Премахни правилата на {{- tableId }}", + "Seed rules": "Начални правила", + "Rules for table ": "Правила за таблица ", + "When adding table rules, automatically add a rule to grant OWNER full access.": "При добавяне на правила за таблица, автоматично добави правило позволяващо пълен достъп на СОБСТВЕНИК.", + "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.": "Позволете на редакторите да редактират структура (напр. да променят и изтриват таблици, колони, оформления) и да пишат формули, които дават достъп до всички данни, независимо от ограниченията за четене.", + "This default should be changed if editors' access is to be limited. ": "Това по подразбиране трябва да се промени, ако трябва да се ограничи достъпът на редакторите. ", + "Add Table-wide Rule": "Добавете правило за цялата таблица" + }, + "AccountPage": { + "API Key": "API ключ", + "API": "API", + "Account settings": "Потребителски настройки", + "Allow signing in to this account with Google": "Позволи вписване чрез Google за този потребител", + "Change Password": "Промени парола", + "Login Method": "Метод на вписване", + "Name": "Име", + "Names only allow letters, numbers and certain special characters": "Имената съдържат само букви, цифри и специални символи", + "Language": "Език", + "Edit": "Редактирай", + "Email": "E-mail", + "Password & Security": "Пароли и сигурност", + "Save": "Съхрани", + "Theme": "Тема", + "Two-factor authentication": "Двуфакторно удостоверяване", + "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.": "Двуфакторното удостоверяване е допълнителен слой на сигурност за вашия акаунт в Grist, предназначен да гарантира, че вие сте единственият човек, който има достъп до вашия акаунт, дори ако някой знае паролата ви." + }, + "AccountWidget": { + "Access Details": "Данни за достъп", + "Accounts": "Потребители", + "Add Account": "Добави потребител", + "Document Settings": "Документни настройки", + "Manage Team": "Управлявай отбора", + "Pricing": "Ценоразпис", + "Profile Settings": "Настройки на профила", + "Sign Out": "Изход", + "Sign in": "Вход", + "Switch Accounts": "Смени потребител", + "Toggle Mobile Mode": "Превкючи мобилен режим", + "Activation": "Активиране", + "Billing Account": "Потребител за таксуване", + "Support Grist": "Поддъжка на Grist", + "Upgrade Plan": "Надгради план", + "Sign In": "Вход", + "Use This Template": "Ползвай този образец", + "Sign Up": "Регистрация" + }, + "ViewAsDropdown": { + "Users from table": "Потребители от таблица", + "View As": "Разгледай като", + "Example Users": "Примерни потребители" + }, + "ActionLog": { + "Action Log failed to load": "Не можа да зареди регистъра с действията", + "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "Колона {{colId}} е премахната с действие {{action.actionNum}}", + "Table {{tableId}} was subsequently removed in action #{{actionNum}}": "Таблица {{tableId}} е премахната с действие {{actionNum}}", + "This row was subsequently removed in action {{action.actionNum}}": "Този ред е премахнат с действие {{action.actionNum}}", + "All tables": "Всички таблици" + }, + "AddNewButton": { + "Add New": "Добави нов" + }, + "ApiKey": { + "Click to show": "Цъкнете за показване", + "Create": "Създай", + "Remove": "Премахни", + "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?": "На път сте да изтриете API ключ. Това ще блокира всички бъдещи заявки, използващи този API ключ. Потвърждавате ли изтриването?", + "By generating an API key, you will be able to make API calls for your own account.": "С генериране на API ключ ще можете да правите API извиквания чрез вашия собствен акаунт.", + "Remove API Key": "Премахни API ключ", + "This API key can be used to access this account anonymously via the API.": "Този API ключ може да се използва за анонимен достъп до този потребител чрез API-то.", + "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "Този API ключ може да се използва за достъп до вашия потребител чрез API-то. Не споделяйте вашия API ключ с никого." + }, + "App": { + "Description": "Описание", + "Key": "Ключ", + "Memory Error": "Грешка в паметта", + "Translators: please translate this only when your language is ready to be offered to users": "Translators: please translate this only when your language is ready to be offered to users" + }, + "AppHeader": { + "Home Page": "Начална страница", + "Legacy": "Вехто", + "Team Site": "Сайт на отбора", + "Grist Templates": "Grist образци", + "Personal Site": "Личен сайт" + }, + "CellContextMenu": { + "Clear values": "Изчисти стойности", + "Copy anchor link": "Копирай връзка към котва", + "Delete {{count}} columns_one": "Изтрий колона", + "Delete {{count}} columns_other": "Изтрий {{count}} колони", + "Duplicate rows_other": "Дублирай редовете", + "Duplicate rows_one": "Дублирай реда", + "Filter by this value": "Отсяване по тази стойност", + "Insert column to the right": "Вмъкване на колона вдясно", + "Insert row above": "Вмъкване на ред отгоре", + "Insert row below": "Вмъкнете ред отдолу", + "Reset {{count}} entire columns_other": "Нулирайте {{count}} цели колони", + "Copy": "Копирай", + "Comment": "Коментирай", + "Cut": "Изрежи", + "Clear cell": "Изчисти клетка", + "Delete {{count}} rows_one": "Изтрий ред", + "Delete {{count}} rows_other": "Изтрий {{count}} реда", + "Insert column to the left": "Вмъкване на колона отляво", + "Insert row": "Вмъкване на ред", + "Reset {{count}} columns_one": "Нулиране на колона", + "Reset {{count}} columns_other": "Нулирайте {{count}} колони", + "Reset {{count}} entire columns_one": "Нулирайте цялата колона", + "Paste": "Постави" + }, + "ChartView": { + "Each Y series is followed by two series, for top and bottom error bars.": "Всяка Y редица е последвана от две редици, за горната и долната лента за грешки.", + "Pick a column": "Избери колона", + "Toggle chart aggregation": "Превключи обединяването на диаграми", + "Create separate series for each value of the selected column.": "Създай отделна редица за всяка стойност на избраната колона.", + "Each Y series is followed by a series for the length of error bars.": "Всяка Y редица е последвана от редица за дължината на лентите за грешки.", + "selected new group data columns": "избрани нови колони с групови данни" + }, + "CodeEditorPanel": { + "Access denied": "Отказан достъп", + "Code View is available only when you have full document access.": "Изгледът на код е наличен само когато имате пълен достъп до документа." + }, + "ColorSelect": { + "Apply": "Приложи", + "Cancel": "Отказ", + "Default cell style": "Стил на клетка по подразбиране" + }, + "ColumnFilterMenu": { + "All": "Всички", + "All Shown": "Всички показани", + "Filter by Range": "Отсявай по обхват", + "Future Values": "Бъдещи стойности", + "No matching values": "Няма съответстващи стойности", + "None": "Нито един", + "Min": "Мин", + "Max": "Макс", + "End": "Край", + "Other Matching": "Друго съпоставяне", + "Search values": "Търси стойности", + "All Except": "Всички, освен", + "Start": "Начало", + "Other Non-Matching": "Други несъответстващи", + "Other Values": "Други стойности", + "Search": "Търси", + "Others": "Други" + }, + "CustomSectionConfig": { + " (optional)": " (по избор)", + "Add": "Добави", + "Full document access": "Пълен достъп до документи", + "Enter Custom URL": "Въведи собствен URL", + "No document access": "Няма достъп до документа", + "Widget does not require any permissions.": "Джаджата не изисква никакви правомощия.", + "Widget needs {{fullAccess}} to this document.": "Джажата се нуждае от {{fullAccess}} до този документ.", + "Pick a column": "Избери колона", + "Pick a {{columnType}} column": "Избери колона от тип {{columnType}}", + "Learn more about custom widgets": "Научи повече за собствените джаджи", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_one": "не се показва {{wrongTypeCount}} колона, които не е от тип {{columnType}}", + "Clear selection": "Изчисти избирането", + "No {{columnType}} columns in table.": "Няма {{columnType}} колони в таблицата.", + "Open configuration": "Отвори конфигурацията", + "Read selected table": "Прочети избраната таблица", + "Widget needs to {{read}} the current table.": "Джажата трябва да {{read}} текущата таблица.", + "Select Custom Widget": "Избери собствена джаджа", + "{{wrongTypeCount}} non-{{columnType}} columns are not shown_other": "не се показват {{wrongTypeCount}} колони, които не са от тип {{columnType}}" + }, + "DataTables": { + "Raw Data Tables": "Таблици с необработени данни", + "You do not have edit access to this document": "Нямате достъп за редактиране на този документ", + "Edit Record Card": "Редаткриане на карта със запис", + "Record Card Disabled": "Картата със запис е деактивирана", + "{{action}} Record Card": "{{action}} карта със запис", + "Table ID copied to clipboard": "Обозначението на таблицата е копиран в системния буфер", + "Click to copy": "Цъкнете, за да копирате", + "Delete {{formattedTableName}} data, and remove it from all pages?": "Да се изтрият ли данните от {{formattedTableName}} и да се премахнат ли от всички страници?", + "Duplicate Table": "Дублирай таблицата", + "Record Card": "Карта със запис", + "Remove Table": "Премахни таблица", + "Rename Table": "Преименувай таблица" + }, + "DocHistory": { + "Activity": "Дейност", + "Snapshots": "Моментни снимки", + "Compare to Previous": "Сравнете с предишно", + "Beta": "Бета", + "Compare to Current": "Сравнете с текущо", + "Open Snapshot": "Отворете моментната снимка", + "Snapshots are unavailable.": "Моментните снимки не са налични.", + "Only owners have access to snapshots for documents with access rules.": "Само собствениците имат достъп до моментни снимки за документи ограничени от правила за достъп." + }, + "DocMenu": { + "Access Details": "Данни за достъп", + "Delete": "Изтрий", + "Delete Forever": "Изтрий завинаги", + "Delete {{name}}": "Изтрий {{name}}", + "Deleted {{at}}": "Изтрито на {{at}}", + "Document will be permanently deleted.": "Документът ще бъде изтрит за постоянно.", + "Edited {{at}}": "Редактиран на {{at}}", + "Examples & Templates": "Примери и образци", + "Examples and Templates": "Примери и образци", + "Featured": "Подбрани", + "Permanently Delete \"{{name}}\"?": "Изтриване за постоянно на „{{name}}“?", + "Pin Document": "Закачи документ", + "Pinned Documents": "Закачени документи", + "Remove": "Премахни", + "Rename": "Преименувай", + "Trash": "Кошче", + "Trash is empty.": "Кошчето е празно.", + "Workspace not found": "Работното пространство не е намерено", + "You are on the {{siteName}} site. You also have access to the following sites:": "Вие сте на сайта {{siteName}}. Имате достъп и до следните сайтове:", + "(The organization needs a paid plan)": "(Организацията се нуждае от платен абонамент)", + "Documents stay in Trash for 30 days, after which they get deleted permanently.": "Документите остават в кошчето 30 дни, след което се изтриват за постоянно.", + "Manage Users": "Управлявай потребители", + "Move": "Премести", + "More Examples and Templates": "Още примери и образци", + "Move {{name}} to workspace": "Премести {{name}} в работното пространство", + "Other Sites": "Други сайтове", + "Requires edit permissions": "Изисква правомощия за редактиране", + "Restore": "Възстанови", + "This service is not available right now": "Тази услуга не е достъпна в момента", + "To restore this document, restore the workspace first.": "За да възстановите този документ, първо възстановете работното пространство.", + "Unpin Document": "Откачи документ", + "You are on your personal site. You also have access to the following sites:": "Вие сте на вашия личен сайт. Имате достъп и до следните сайтове:", + "You may delete a workspace forever once it has no documents in it.": "Можете да изтриете работно пространство завинаги, след като в него няма документи.", + "All Documents": "Всички документи", + "Discover More Templates": "Открийте още образци", + "By Date Modified": "по дата на промяна", + "By Name": "по име", + "Current workspace": "Текущо работно пространство", + "Document will be moved to Trash.": "Документът ще бъде преместен в кошчето." + }, + "DocPageModel": { + "Add Empty Table": "Добави празна таблица", + "Add Page": "Добави страница", + "Add Widget to Page": "Добави джаджа в страницата", + "Enter recovery mode": "Влезте в режим на възстановяване", + "Sorry, access to this document has been denied. [{{error}}]": "За съжаление достъпът до този документ е отказан. [{{error}}]", + "Document owners can attempt to recover the document. [{{error}}]": "Собствениците на документи могат да опитат да възстановят документа. [{{error}}]", + "You can try reloading the document, or using recovery mode. Recovery mode opens the document to be fully accessible to owners, and inaccessible to others. It also disables formulas. [{{error}}]": "Можете да опитате да презаредите документа или да използвате режим на възстановяване. Режимът за възстановяване отваря документа, за да бъде напълно достъпен за собствениците и недостъпен за други. Режимът също така деактивира и формулите. [{{error}}]", + "Error accessing document": "Грешка при достъпа до документа", + "Reload": "Презареди", + "You do not have edit access to this document": "Нямате достъп за редактиране на този документ" + }, + "DocTour": { + "Cannot construct a document tour from the data in this document. Ensure there is a table named GristDocTour with columns Title, Body, Placement, and Location.": "Не може да изгради наръч от документи от данните в този документ. Уверете се, че има таблица с име GristDocTour с колони Заглавие, Тяло, Разположение и Местоположение.", + "No valid document tour": "Невалиден наръч от документи" + }, + "DocumentSettings": { + "Currency:": "Валута:", + "Document Settings": "Настройки на документа", + "Engine (experimental {{span}} change at own risk):": "Двигател (експериментално, {{span}} променяй на собствена отговорност):", + "Local currency ({{currency}})": "Местна валута ({{currency}})", + "Locale:": "Регионални настройки:", + "Save": "Съхрани", + "Save and Reload": "Съхрани и презареди", + "Ok": "Добре", + "Webhooks": "Webhooks", + "API Console": "API конзола", + "API documentation.": "API документация.", + "Copy to clipboard": "Копирай в системния буфер", + "Data Engine": "Двигател обработващ данните", + "Default for DateTime columns": "Стойност по подразбиране за колоните от тип \"дата и час\"", + "For number and date formats": "За формати на числа и дати", + "Formula times": "Формула пъти", + "ID for API use": "Обозначение за ползване в API", + "Manage webhooks": "Управлявай webhooks", + "Python": "Python", + "Python version used": "Ползвана версия на Python", + "python3 (recommended)": "python3 (препоръчан)", + "API": "API", + "Time Zone:": "Времеви пояс:", + "Document ID copied to clipboard": "Обозначението на документа е копиран в системния буфер", + "Manage Webhooks": "Управлявай webhooks", + "API URL copied to clipboard": "URL адресът на API-то е копиран в системния буфер", + "API console": "API конзола", + "Base doc URL: {{docApiUrl}}": "Основен URL адрес на документа (API): {{docApiUrl}}", + "Currency": "Валута", + "Coming soon": "Очаквайте скоро", + "Document ID": "Обозначение на документ", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "Обозначение на документ, което да се ползва за REST API извиквания към {{docId}}. Вижте {{apiURL}}", + "Find slow formulas": "Намери бавни формули", + "For currency columns": "За колони във валута", + "Hard reset of data engine": "Пълно рестартиране на двигателя обработващ данните", + "This document's ID (for API use):": "Обозначението на документа (за ползване чрез API):", + "Locale": "Регионални настройки", + "Notify other services on doc changes": "Уведомете другите услуги за промени в документа", + "Reload": "Презареди", + "Time Zone": "Времеви пояс", + "Try API calls from the browser": "Опитайте API извиквания от браузъра", + "python2 (legacy)": "python2 (овехтял)" + }, + "DocumentUsage": { + "Attachments Size": "Размер на прикачените файлове", + "Data Size": "Размер на данните", + "For higher limits, ": "За по-високи граници, ", + "Rows": "Редове", + "Usage": "Ползване", + "Usage statistics are only available to users with full access to the document data.": "Статистическите данни за използването са достъпни само за потребители с пълен достъп до данните за документите.", + "Contact the site owner to upgrade the plan to raise limits.": "Свържете се със собственика на сайта, за да надстроите абонамента си и да увеличите ограниченията.", + "start your 30-day free trial of the Pro plan.": "започнете 30-дневната си безплатна пробна версия на абонамента Pro." + }, + "Drafts": { + "Restore last edit": "Възстановяване на последната редакция", + "Undo discard": "Отмяна на изхвърлянето" + }, + "DuplicateTable": { + "Copy all data in addition to the table structure.": "Копирайте и всички данни в допълнение към структурата на таблицата.", + "Name for new table": "Име на новата таблица", + "Instead of duplicating tables, it's usually better to segment data using linked views. {{link}}": "Вместо да дублирате таблици, обикновено е по-добре да сегментирате данните с помощта на свързани изгледи. {{link}}", + "Only the document default access rules will apply to the copy.": "За копието ще се прилагат само правилата за достъп по подразбиране на документа." + }, + "ExampleInfo": { + "Afterschool Program": "Програма за извънкласни занимания", + "Welcome to the Afterschool Program template": "Добре дошли в образеца на програмата за извънкласни занимания", + "Welcome to the Investment Research template": "Добре дошли в образеца за инвестиционни проучвания", + "Welcome to the Lightweight CRM template": "Добре дошли в образец за олекотена CRM система", + "Tutorial: Analyze & Visualize": "Урок: Анализиране и визуализиране", + "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Разгледайте нашия свързан урок за това как да свързвате данни и създавате оформления с висока производителност.", + "Check out our related tutorial for how to model business data, use formulas, and manage complexity.": "Разгледайте нашето свързано ръководство за моделиране на бизнес данни, използване на формули и управление на сложността.", + "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Разгледайте нашия свързан урок, за да научите как да създавате обобщаващи таблици и диаграми и да свързвате диаграми динамично.", + "Investment Research": "Инвестиционни проучвания", + "Lightweight CRM": "Олекотена CRM система", + "Tutorial: Create a CRM": "Урок: Създаване на CRM система", + "Tutorial: Manage Business Data": "Урок: Управление на бизнес данни" + }, + "FieldConfig": { + "COLUMN BEHAVIOR": "ПОВЕДЕНИЕ НА КОЛОНАТА", + "COLUMN LABEL AND ID": "ЕТИКЕТ И ОБОЗНАЧЕНИЕ НА КОЛОНАТА", + "Convert column to data": "Преобразувай колоната в данни", + "Convert to trigger formula": "Преобразувай във формула за задействане", + "Data Columns_other": "Колони с данни", + "Data Columns_one": "Колона с данни", + "Empty Columns_one": "Празна колона", + "Enter formula": "Въведи формула", + "Formula Columns_one": "Колона с формула", + "Make into data column": "Превръщане в колона с данни", + "Mixed Behavior": "Смесено поведение", + "Set formula": "Задай формула", + "Clear and make into formula": "Изчисти и превърни във формула", + "Clear and reset": "Изчисти и нулирай", + "Column options are limited in summary tables.": "Опциите за колоните са ограничени в обобщаващите таблици.", + "Empty Columns_other": "Празни колони", + "Formula Columns_other": "Колони с формули", + "Set trigger formula": "Задай формула за задействане", + "TRIGGER FORMULA": "ФОРМУЛА ЗА ЗАДЕЙСТВАНЕ", + "DESCRIPTION": "ОПИСАНИЕ" + }, + "FieldMenus": { + "Save as common settings": "Запази като общи настройки", + "Use separate settings": "Използвай отделни настройки", + "Using common settings": "Използване на общи настройки", + "Revert to common settings": "Върни към общи настройки", + "Using separate settings": "Използване на отделни настройки" + }, + "FilterConfig": { + "Add Column": "Добавяне на колона" + }, + "FilterBar": { + "SearchColumns": "Колони за търсене", + "Search Columns": "Колони за търсене" + }, + "GridOptions": { + "Grid Options": "Опции на решетката", + "Zebra Stripes": "Зеброви ивици", + "Horizontal Gridlines": "Хоризонтални линии на решетката", + "Vertical Gridlines": "Вертикални линии на решетката" + }, + "GridViewMenus": { + "Add to sort": "Добави към сортирането", + "Clear values": "Изчисти стойностите", + "Column Options": "Настройки на колоната", + "Convert formula to data": "Преобразувай формула в данни", + "Delete {{count}} columns_one": "Изтрий колона", + "Delete {{count}} columns_other": "Изтрий {{count}} колони", + "Filter Data": "Oтсяване на данни", + "Freeze {{count}} columns_one": "Замрази тази колона", + "Freeze {{count}} columns_other": "Замрази {{count}} колони", + "Freeze {{count}} more columns_one": "Замрази една повече колони", + "Insert column to the {{to}}": "Вмъкни колона в{{to}}", + "More sort options ...": "Още опции за сортиране…", + "Rename column": "Преименувай колона", + "Show column {{- label}}": "Покажи колона {{- label}}", + "Sort": "Сортирай", + "Sorted (#{{count}})_other": "Сортирани (#{{count}})", + "Sorted (#{{count}})_one": "Сортирани (#{{count}})", + "Unfreeze all columns": "Размрази всички колони", + "Unfreeze {{count}} columns_one": "Размрази тази колона", + "Unfreeze {{count}} columns_other": "Размрази {{count}} колони", + "Insert column to the left": "Вмъкни колона вляво", + "Apply to new records": "Приложи към новите записи", + "Authorship": "Авторство", + "Last Updated At": "Последно обновено на", + "Last Updated By": "Последно обновено от", + "Lookups": "Прегледи", + "Shortcuts": "Кратки пътища", + "Show hidden columns": "Покажи скрити колони", + "Timestamp": "Клеймо за време", + "Detect Duplicates in...": "Откриване на дубликати в...", + "Search columns": "Колони за търсене", + "UUID": "UUID", + "Add column with type": "Добави колона с тип", + "Add formula column": "Добави колона с формули", + "Created by": "Създадено от", + "Detect duplicates in...": "Откриване на дубликати в...", + "Last updated at": "Последно обновено на", + "Last updated by": "Последно обновено от", + "Numeric": "Числен", + "Text": "Текст", + "Integer": "Целочислен", + "Toggle": "Превключващ", + "DateTime": "Дата и час", + "Choice List": "Списък с избори", + "Add Column": "Добави колона", + "Freeze {{count}} more columns_other": "Замрази {{count}} повече колони", + "Hide {{count}} columns_one": "Скрий колона", + "Hide {{count}} columns_other": "Скрий {{count}} колони", + "Reset {{count}} columns_one": "Нулирай колона", + "Reset {{count}} entire columns_one": "Нулирай цяла колона", + "Reset {{count}} columns_other": "Нулирай {{count}} колона", + "Reset {{count}} entire columns_other": "Нулирай {{count}} цели колони", + "Insert column to the right": "Вмъкни колона вдясно", + "Hidden Columns": "Скрити колони", + "Apply on record changes": "Приложи промените в записа", + "no reference column": "няма референтна колона", + "Adding duplicates column": "Добавяне на колона с дубликати", + "Adding UUID column": "Добавяне на UUID колона", + "Created At": "Създадено на", + "Created By": "Създадено от", + "Duplicate in {{- label}}": "Дублиране в {{- label}}", + "No reference columns.": "Няма референтни колони.", + "Add column": "Добави колона", + "Created at": "Създадено на", + "Any": "Всякакъв", + "Date": "Дата", + "Choice": "Избор", + "Reference": "Препратка", + "Reference List": "Списък с препратки", + "Attachment": "Закачен файл" + }, + "GristDoc": { + "Import from file": "Внасяне от файл", + "Saved linked section {{title}} in view {{name}}": "Запазен свързан раздел {{title}} в изглед {{name}}", + "go to webhook settings": "отидете при настройките на webhook", + "Added new linked section to view {{viewName}}": "Добавена е нова свързана секция за преглед на {{viewName}}" + }, + "HomeIntro": { + "Any documents created in this site will appear here.": "Всички документи, създадени в този сайт, ще се показват тук.", + "Browse Templates": "Преглед на образците", + "Create Empty Document": "Създаване на празен документ", + "Get started by inviting your team and creating your first Grist document.": "Започнете, като поканите своя екип и създадете първия си Grist документ.", + "Help Center": "Център за помощ", + "Import Document": "Внасяне на документ", + "Interested in using Grist outside of your team? Visit your free ": "Интересувате ли се от използването на Grist извън вашия екип? Посетете вашия безплатен ", + "Sprouts Program": "Програма \"Покълване\"", + "This workspace is empty.": "Това работно пространство е празно.", + "Visit our {{link}} to learn more.": "Посетете нашата {{link}}, за да научите повече.", + "Welcome to Grist!": "Добре дошли в Grist!", + "Welcome to Grist, {{name}}!": "Добре дошли в Grist, {{name}}!", + "personal site": "личен сайт", + "{{signUp}} to save your work. ": "{{signUp}}, за да запазите работата си. ", + "Welcome to {{- orgName}}": "Добре дошли в {{- orgName}}", + "Sign in": "Вписване", + "To use Grist, please either sign up or sign in.": "За да използвате Grist, моля, регистрирайте се или се впишете.", + "Visit our {{link}} to learn more about Grist.": "Посетете {{link}}, за да научите повече за Grist.", + "Learn more in our {{helpCenterLink}}, or find an expert via our {{sproutsProgram}}.": "Научете повече в нашия {{helpCenterLink}} или намерете експерт чрез нашата {{sproutsProgram}}.", + "Get started by creating your first Grist document.": "Започнете, като създадете първия си Grist документ.", + "Get started by exploring templates, or creating your first Grist document.": "Започнете, като проучите образците или създадете първия си Grist документ.", + "Invite Team Members": "Поканете членове на екипа", + "Sign up": "Регистрация", + "Welcome to {{orgName}}": "Добре дошли в {{orgName}}", + "You have read-only access to this site. Currently there are no documents.": "Имате достъп само за четене до този сайт. В момента няма документи.", + "Welcome to Grist, {{- name}}!": "Добре дошли в Grist, {{- name}}!" + }, + "HomeLeftPane": { + "Create Workspace": "Създайте работно пространство", + "Delete": "Изтрий", + "Delete {{workspace}} and all included documents?": "Изтрий {{workspace}} и всичките му документи?", + "Examples & Templates": "Образци", + "Import Document": "Внеси документ", + "Manage Users": "Управлявай потребители", + "Access Details": "Данни за достъпа", + "All Documents": "Всички документи", + "Create Empty Document": "Създай празен документ", + "Rename": "Преименувай", + "Trash": "Кошче", + "Workspace will be moved to Trash.": "Работното пространство ще бъде преместено в кошчето.", + "Workspaces": "Работни пространства", + "Tutorial": "Урок" + }, + "Importer": { + "Select fields to match on": "Изберете полета за съответствие", + "Update existing records": "Обнови съществуващи записи", + "{{count}} unmatched field in import_one": "{{count}} полета без съответствие при внасяне", + "{{count}} unmatched field in import_other": "{{count}} несъответстващи полета при внасяне", + "{{count}} unmatched field_one": "{{count}} несъответстващо поле", + "Column Mapping": "Съпоставяне на колони", + "Grist column": "Grist колона", + "Import from file": "Внеси от файл", + "New Table": "Нова таблица", + "Revert": "Върни", + "Skip": "Прескочи", + "Skip Import": "Прескочи внасяне", + "Source column": "Колона източник", + "Destination table": "Целева таблица", + "Merge rows that match these fields:": "Обединете редове, които съответстват на тези полета:", + "{{count}} unmatched field_other": "{{count}} несъответстващи полета", + "Column mapping": "Съпоставяне на колони", + "Skip Table on Import": "Прескочи таблицата при внасяне" + }, + "LeftPanelCommon": { + "Help Center": "Център за помощ" + }, + "MakeCopyMenu": { + "Cancel": "Отказ", + "Enter document name": "Въведете име на документа", + "However, it appears to be already identical.": "Но изглежда, че вече е идентичен.", + "Name": "Име", + "Organization": "Организация", + "Original Has Modifications": "Оригиналът има модификации", + "Original Looks Unrelated": "Оригиналът изглежда несвързан", + "Original Looks Identical": "Оригиналът изглежда идентичен", + "Overwrite": "Презапиши", + "Replacing the original requires editing rights on the original document.": "Подмяната на оригинала изисква права за редактиране на оригиналния документ.", + "Sign up": "Регистрация", + "Update": "Обнови", + "Workspace": "Работно пространство", + "You do not have write access to this site": "Нямате достъп за писане до този сайт", + "Download full document and history": "Изтеглете пълния документ и хронология", + "As Template": "Като образец", + "Include the structure without any of the data.": "Включете структурата без никакви данни.", + "The original version of this document will be updated.": "Оригиналната версия на този документ ще бъде обновена.", + "Be careful, the original has changes not in this document. Those changes will be overwritten.": "Бъдете внимателни, оригиналът има промени, които не са включени в този документ. Тези промени ще бъдат презаписани.", + "It will be overwritten, losing any content not in this document.": "Той ще бъде презаписан, като ще бъде изгубено всяко съдържание, което не е в този документ.", + "No destination workspace": "Няма целево работно пространство", + "You do not have write access to the selected workspace": "Нямате достъп за писане в избраното работно пространство", + "To save your changes, please sign up, then reload this page.": "За да запазите промените си, моля, регистрирайте се, след което презаредете тази страница.", + "Update Original": "Обнови оригинала", + "Remove all data but keep the structure to use as a template": "Премахни всички данни, но запазете структурата, която да използвате като образец", + "Download": "Изтегли", + "Remove document history (can significantly reduce file size)": "Премахни на хронологията на документа (може значително да намали размера на файла)", + "Download document": "Изтегли документ" + }, + "NotifyUI": { + "Ask for help": "Потърси помощ", + "Give feedback": "Дайте обратна връзка", + "Go to your free personal site": "Отиди в безплатния си личен сайт", + "Report a problem": "Докладвай проблем", + "Manage billing": "Управление на таксуването", + "Cannot find personal site, sorry!": "Личният сайт не е намерен, съжалаваме!", + "No notifications": "Няма известия", + "Notifications": "Известия", + "Renew": "Поднови", + "Upgrade Plan": "Надгради абонамент" + }, + "OnBoardingPopups": { + "Finish": "Край", + "Next": "Следващ", + "Previous": "Предишен" + }, + "OpenVideoTour": { + "YouTube video player": "YouTube видеоплеър", + "Grist Video Tour": "Видеообиколка на Grist", + "Video Tour": "Видеообиколка" + }, + "PageWidgetPicker": { + "Building {{- label}} widget": "Изграждане на джаджа {{- label}}", + "Group by": "Групирай по", + "Select Data": "Избери данни", + "Add to Page": "Добавяне към страницата", + "Select Widget": "Избери джаджа" + }, + "Pages": { + "Delete data and this page.": "Изтрий данните и тази страница.", + "The following tables will no longer be visible_one": "Следната таблица вече няма да се вижда", + "The following tables will no longer be visible_other": "Следните таблици вече няма да се виждат", + "Delete": "Изтрий" + }, + "PermissionsWidget": { + "Allow All": "Разреши всички", + "Deny All": "Забрани всички", + "Read Only": "Само за четене" + }, + "PluginScreen": { + "Import failed: ": "Неуспешно внасяне: " + }, + "RecordLayoutEditor": { + "Add Field": "Добави поле", + "Create New Field": "Създай ново поле", + "Show field {{- label}}": "Покажи поле {{- label}}", + "Save Layout": "Съхрани оформлението", + "Cancel": "Отказ" + }, + "RightPanel": { + "CUSTOM": "ПО ИЗБОР", + "Change Widget": "Промени джаджа", + "Columns_one": "Колона", + "Columns_other": "Колони", + "DATA TABLE": "ТАБЛИЦА С ДАННИ", + "DATA TABLE NAME": "ИМЕ НА ТАБЛИЦА С ДАННИ", + "Data": "Данни", + "Detach": "Отдели", + "Edit Data Selection": "Редактиране на избора на данни", + "Fields_one": "Поле", + "Fields_other": "Полета", + "ROW STYLE": "СТИЛ НА РЕДА", + "Row Style": "Стил на реда", + "Sort & Filter": "Сортиране и отсяване", + "TRANSFORM": "ПРЕОБРАЗУВАЙ", + "Theme": "Тема", + "WIDGET TITLE": "ЗАГЛАВИЕ НА ДЖАДЖА", + "Widget": "Джаджа", + "You do not have edit access to this document": "Нямате достъп за редактиране на този документ", + "Add referenced columns": "Добавяне на колони с препратки", + "Reset form": "Нулирай формулярът", + "Configuration": "Конфигурация", + "Default field value": "Стойност на полето по подразбиране", + "Display button": "Бутон за показване", + "Field title": "Заглавие на полето", + "Hidden field": "Скрито поле", + "Redirect automatically after submission": "Автоматично пренасочване след подаване", + "Redirection": "Пренасочване", + "Required field": "Задължително поле", + "Submission": "Подаване", + "Submit another response": "Подай друг отговор", + "Submit button label": "Етикет на бутона за подаване", + "Success text": "Текст при успех", + "Table column name": "Име на колона в таблицата", + "Enter redirect URL": "Въведете URL за пренасочване", + "No field selected": "Няма избрано поле", + "COLUMN TYPE": "ТИП КОЛОНА", + "GROUPED BY": "ГРУПИРАНИ ПО", + "SELECT BY": "ИЗБЕРИ ПО", + "CHART TYPE": "ТИП ГРАФИКА", + "Select Widget": "Избери джаджа", + "SELECTOR FOR": "ИЗБОР ЗА", + "SOURCE DATA": "ИЗХОДНИ ДАННИ", + "Save": "Съхрани", + "Series_one": "Редица", + "Series_other": "Редици", + "Enter text": "Въведи текст", + "Layout": "Оформление", + "Field rules": "Правила на полето", + "Select a field in the form widget to configure.": "Изберете поле в джаджа на формуляра, което да конфигурирате." + }, + "RowContextMenu": { + "Copy anchor link": "Копиране на връзката с котва", + "Insert row": "Вмъкни ред", + "Insert row above": "Вмъкни ред отгоре", + "View as card": "Преглед като карта", + "Use as table headers": "Използвай като заглавки на таблица", + "Delete": "Изтрий", + "Duplicate rows_other": "Дублирай редовете", + "Insert row below": "Вмъкни ред отдолу", + "Duplicate rows_one": "Дубирай реда" + }, + "SelectionSummary": { + "Copied to clipboard": "Копирано в системния буфер" + }, + "ShareMenu": { + "Compare to {{termToUse}}": "Сравнете с {{termToUse}}", + "Current Version": "Текуща версия", + "Download": "Изтегли", + "Duplicate Document": "Дублирай документ", + "Edit without affecting the original": "Редактирай, без да засягаш оригинала", + "Export CSV": "Изведи CSV", + "Export XLSX": "Изведи XLSX", + "Manage Users": "Управление на потребителите", + "Original": "Оригинал", + "Replace {{termToUse}}...": "Замести {{termToUse}}…", + "Return to {{termToUse}}": "Назад към {{termToUse}}", + "Save Copy": "Запазване на копие", + "Save Document": "Съхрани документ", + "Show in folder": "Покажи в папката", + "Unsaved": "Незапазени промени", + "Access Details": "Данни за достъп", + "Back to Current": "Обратно към текущите", + "Send to Google Drive": "Изпрати до Google Drive", + "DOO Separated Values (.dsv)": "DOO разделени стойности (.dsv)", + "Export as...": "Извеждане като...", + "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", + "Tab Separated Values (.tsv)": "Стойности, разделени с табулации (.tsv)", + "Work on a Copy": "Работи върху копие", + "Share": "Сподели", + "Download...": "Изтегли...", + "Comma Separated Values (.csv)": "Стойности, разделени със запетая (.csv)" + }, + "SortFilterConfig": { + "Revert": "Върни", + "Save": "Съхрани", + "Sort": "СОРТИРАЙ", + "Update Sort & Filter settings": "Обнови настройките за соритане и отсяване", + "Filter": "СИТО" + }, + "ThemeConfig": { + "Appearance ": "Външен вид " + }, + "AppModel": { + "This team site is suspended. Documents can be read, but not modified.": "Сайтът на отбора е спрян. Документите могат да бъдат четени, но не и променяни." + }, + "RecordLayout": { + "Updating record layout.": "Обновяване на оформлението на записа." + }, + "RefSelect": { + "Add Column": "Добави колона", + "No columns to add": "Няма колони за добавяне" + }, + "SiteSwitcher": { + "Create new team site": "Създай нов екипен сайт", + "Switch Sites": "Превключване на сайтове" + }, + "SortConfig": { + "Update Data": "Обнови данните", + "Use choice position": "Използвай изборна позиция", + "Add Column": "Добави колона", + "Empty values last": "Празните стойности са последни", + "Natural sort": "Естествено подреждане", + "Search Columns": "Колони за търсене" + } +} From 418ae03475eb5d8f78f93177ad0ba34d38b74aea Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Fri, 17 May 2024 06:43:58 +0000 Subject: [PATCH 13/26] Translated using Weblate (Bulgarian) Currently translated at 59.9% (773 of 1290 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 191 +++++++++++++++++++++++++++++++++- 1 file changed, 189 insertions(+), 2 deletions(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index e7a809be..21d8a173 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -111,7 +111,7 @@ "Description": "Описание", "Key": "Ключ", "Memory Error": "Грешка в паметта", - "Translators: please translate this only when your language is ready to be offered to users": "Translators: please translate this only when your language is ready to be offered to users" + "Translators: please translate this only when your language is ready to be offered to users": "" }, "AppHeader": { "Home Page": "Начална страница", @@ -711,7 +711,7 @@ "Unsaved": "Незапазени промени", "Access Details": "Данни за достъп", "Back to Current": "Обратно към текущите", - "Send to Google Drive": "Изпрати до Google Drive", + "Send to Google Drive": "Изпрати до Google Диск", "DOO Separated Values (.dsv)": "DOO разделени стойности (.dsv)", "Export as...": "Извеждане като...", "Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)", @@ -752,5 +752,192 @@ "Empty values last": "Празните стойности са последни", "Natural sort": "Естествено подреждане", "Search Columns": "Колони за търсене" + }, + "errorPages": { + "Account deleted{{suffix}}": "Потребителят е изтрит{{suffix}}", + "Sign up": "Регистрация", + "Your account has been deleted.": "Вашият потребител е изтрит.", + "An unknown error occurred.": "Възникна неизвестна грешка.", + "Build your own form": "Изградете свой собствен формуляр", + "Form not found": "Формулярът не е намерен", + "Powered by": "Задвижвано от", + "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Вие сте влезли като {{email}}. Можете да влезете с друг потребител или поистайте достъп от администратора.", + "You do not have access to this organization's documents.": "Нямате достъп до документите на тази организация." + }, + "menus": { + "* Workspaces are available on team plans. ": "* Работните пространства са достъпни в абонаментите с екип. ", + "Select fields": "Изберете полета", + "Upgrade now": "Надстройте сега", + "Any": "Всякакъв", + "Numeric": "Числов", + "Text": "Текст", + "Integer": "Целочислен", + "Toggle": "Превключващ", + "Date": "Дата", + "DateTime": "Дата и час", + "Choice": "Избор", + "Choice List": "Списък с избори", + "Reference": "Препратка", + "Reference List": "Списък с препратки", + "Attachment": "Причкачен файл", + "Search columns": "Колони за търсене" + }, + "modals": { + "Undo to restore": "Отмени, за да възстановиш", + "Got it": "Разбрах", + "Don't show again": "Не показвай отново", + "TIP": "СЪВЕТ", + "Save": "Съхрани", + "Cancel": "Отказ", + "Ok": "Добре", + "Are you sure you want to delete these records?": "Сигурни ли сте, че искате да изтриете тези записи?", + "Are you sure you want to delete this record?": "Сигурни ли сте, че искате да изтриете този запис?", + "Delete": "Изтрий", + "Dismiss": "Отхвърли", + "Don't ask again.": "Не питай отново.", + "Don't show again.": "Не показвай отново.", + "Don't show tips": "Не показвай съвети" + }, + "pages": { + "Duplicate Page": "Дублирай страница", + "Remove": "Премахни", + "Rename": "Преименувай", + "You do not have edit access to this document": "Нямате достъп за редактиране на този документ" + }, + "search": { + "Find Previous ": "Намерете предишния ", + "Find Next ": "Намери следващия ", + "No results": "Няма резултати", + "Search in document": "Търсене в документа", + "Search": "Търсене" + }, + "sendToDrive": { + "Sending file to Google Drive": "Файлът се изпраща до Google Диск" + }, + "NTextBox": { + "false": "невярно", + "true": "вярно", + "Field Format": "Формат на полето", + "Lines": "Линии", + "Multi line": "Множество линии", + "Single line": "Една линия" + }, + "ACLUsers": { + "Example Users": "Примерни потребители", + "View As": "Разгледай като", + "Users from table": "Потребители от таблица" + }, + "TypeTransform": { + "Apply": "Приложи", + "Cancel": "Отказ", + "Preview": "Преглед", + "Revise": "Преразглеждане", + "Update formula (Shift+Enter)": "Актуализиране на формула (Shift+Enter)" + }, + "CellStyle": { + "CELL STYLE": "СТИЛ НА КЛЕТКА", + "Cell Style": "Стил на клетка", + "Default cell style": "Стил на клетка по подразбиране", + "Mixed style": "Смесен стил", + "Open row styles": "Отвори стиловете на реда", + "Header Style": "Стил на заглавките", + "HEADER STYLE": "СТИЛ НА ЗАГЛАВКИТЕ", + "Default header style": "Стил на заглавките по подразбиране" + }, + "ColumnEditor": { + "COLUMN DESCRIPTION": "ОПИСАНИЕ НА КОЛОНАТА", + "COLUMN LABEL": "ЕТИКЕТ НА КОЛОНАТА" + }, + "ColumnInfo": { + "COLUMN DESCRIPTION": "ОПИСАНИЕ НА КОЛОНАТА", + "COLUMN ID: ": "ОБОЗНАЧЕНИЕ НА КОЛОНА: ", + "COLUMN LABEL": "ЕТИКЕТ НА КОЛОНАТА", + "Cancel": "Отказ", + "Save": "Съхрани" + }, + "ConditionalStyle": { + "Add another rule": "Добави друго правило", + "Add conditional style": "Добавете условен стил", + "Error in style rule": "Грешка в правилото за стил", + "Row Style": "Стил на ред", + "Rule must return True or False": "Правилото трябва да връща вярно (True) или невярно (False)" + }, + "DiscussionEditor": { + "Cancel": "Отказ", + "Edit": "Редактирай", + "Marked as resolved": "Означи като разрешен", + "Only current page": "Само текущата страница", + "Only my threads": "Само моите нишки", + "Open": "Отвори", + "Remove": "Премахни", + "Reply": "Отговори", + "Reply to a comment": "Отговори на коментар", + "Resolve": "Разреши", + "Save": "Съхрани", + "Show resolved comments": "Показване на разрешените коментари", + "Showing last {{nb}} comments": "Показани са последните {{nb}} коментара", + "Comment": "Коментирай", + "Started discussion": "Започната дискусия", + "Write a comment": "Напиши коментар" + }, + "FieldBuilder": { + "Changing multiple column types": "Промяна на множество типове колони", + "DATA FROM TABLE": "ДАННИ ОТ ТАБЛИЦА", + "Mixed format": "Смесен формат", + "Mixed types": "Смесени типове", + "Revert field settings for {{colId}} to common": "Върнете {{colId}} към общи настройки на полето", + "Save field settings for {{colId}} as common": "Запазете настройките на полето {{colId}} като общи", + "Use separate field settings for {{colId}}": "Използвайте отделни настройки на полето {{colId}}", + "Changing column type": "Промяна на типа колона", + "Apply Formula to Data": "Приложи формула към данни", + "CELL FORMAT": "ФОРМАТ НА КЛЕТКАТА" + }, + "FieldEditor": { + "It should be impossible to save a plain data value into a formula column": "Трябва да е невъзможно да се запише стойност на обикновени данни в колона с формула", + "Unable to finish saving edited cell": "Не може да завърши запазването на редактираната клетка" + }, + "FormulaEditor": { + "Error in the cell": "Грешка в клетката", + "Expand Editor": "Разгънете редактора", + "use AI Assistant": "използвайте ИИ асистент", + "Column or field is required": "Колона или поле е задължително", + "Errors in all {{numErrors}} cells": "Грешки във всички {{numErrors}} клетки", + "Errors in {{numErrors}} of {{numCells}} cells": "Грешки в {{numErrors}} от {{numCells}} клетки", + "editingFormula is required": "editingFormula е задължително", + "Enter formula.": "Въведете формула.", + "Enter formula or {{button}}.": "Въведете формула или {{button}}." + }, + "HyperLinkEditor": { + "[link label] url": "[етикет на връзката] URL" + }, + "NumericTextBox": { + "Currency": "Валута", + "Decimals": "Десетици", + "Default currency ({{defaultCurrency}})": "Валута по подразбиране ({{defaultCurrency}})", + "Number Format": "Числов формат", + "Field Format": "Формат на полето", + "Spinner": "Врътче (при зареждане)", + "Text": "Текст", + "max": "макс", + "min": "мин" + }, + "Reference": { + "CELL FORMAT": "ФОРМАТ НА КЛЕТКАТА", + "Row ID": "Обозначение на ред", + "SHOW COLUMN": "ПОКАЖИ КОЛОНА" + }, + "WelcomeTour": { + "Add New": "Добави нов", + "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Разгледайте нашата {{templateLibrary}} и се вдъхновете, като откриете възможностите.", + "Building up": "Изграждане" + }, + "ChoiceTextBox": { + "CHOICES": "ИЗБОР" + }, + "CurrencyPicker": { + "Invalid currency": "Невалидна валута" + }, + "EditorTooltip": { + "Convert column to formula": "Преобразувай колона във формула" } } From 6443c9f914e52297cea286d3639d4a70cc1ff0a8 Mon Sep 17 00:00:00 2001 From: CamilleLegeron Date: Fri, 17 May 2024 20:55:24 +0200 Subject: [PATCH 14/26] feat: add translation of vue types when added (#946) --- app/client/models/entities/ViewSectionRec.ts | 4 +++- app/client/ui/PageWidgetPicker.ts | 8 +++---- app/client/ui/RightPanel.ts | 6 +++--- app/client/ui/widgetTypesMap.ts | 22 ++++++++++++-------- static/locales/en.client.json | 9 ++++++++ 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 729f79de..418f1b16 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -460,7 +460,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): // - Widget type description (if not grid) // All concatenated separated by space. this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => { - const widgetTypeDesc = this.parentKey() !== 'record' ? `${getWidgetTypes(this.parentKey.peek() as any).label}` : ''; + const widgetTypeDesc = this.parentKey() !== 'record' + ? `${getWidgetTypes(this.parentKey.peek() as any).getLabel()}` + : ''; const table = this.table(); return [ table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null. diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index fed425f4..61552422 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -205,7 +205,7 @@ export function buildPageWidgetPicker( // If savePromise throws an error, before or after timeout, we let the error propagate as it // should be handle by the caller. if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) { - const label = getWidgetTypes(type).label; + const label = getWidgetTypes(type).getLabel(); await spinnerModal(t("Building {{- label}} widget", { label }), savePromise); } } @@ -317,12 +317,12 @@ export class PageWidgetSelect extends Disposable { cssPanel( header(t("Select Widget")), sectionTypes.map((value) => { - const {label, icon: iconName} = getWidgetTypes(value); + const widgetInfo = getWidgetTypes(value); const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid)); return cssEntry( dom.autoDispose(disabled), - cssTypeIcon(iconName), - label, + cssTypeIcon(widgetInfo.icon), + widgetInfo.getLabel(), dom.on('click', () => !disabled.get() && this._selectType(value)), cssEntry.cls('-selected', (use) => use(this._value.type) === value), cssEntry.cls('-disabled', disabled), diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 4ff41a60..a0f90bb5 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -38,7 +38,7 @@ import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSecti import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; -import {getTelemetryWidgetTypeFromVS, widgetTypesMap} from "app/client/ui/widgetTypesMap"; +import {getTelemetryWidgetTypeFromVS, getWidgetTypes} from "app/client/ui/widgetTypesMap"; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {buttonSelect} from 'app/client/ui2018/buttonSelect'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; @@ -220,10 +220,10 @@ export class RightPanel extends Disposable { private _buildStandardHeader() { return dom.maybe(this._pageWidgetType, (type) => { - const widgetInfo = widgetTypesMap.get(type) || {label: 'Table', icon: 'TypeTable'}; + const widgetInfo = getWidgetTypes(type); const fieldInfo = getFieldType(type); return [ - cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label, + cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.getLabel(), cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'), dom.on('click', () => this._topTab.set("pageWidget")), testId('right-tab-pagewidget')), diff --git a/app/client/ui/widgetTypesMap.ts b/app/client/ui/widgetTypesMap.ts index cbd5a48d..7b96c4e8 100644 --- a/app/client/ui/widgetTypesMap.ts +++ b/app/client/ui/widgetTypesMap.ts @@ -3,21 +3,25 @@ import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec"; import {IPageWidget} from "app/client/ui/PageWidgetPicker"; import {IconName} from "app/client/ui2018/IconList"; import {IWidgetType} from "app/common/widgetTypes"; +import {makeT} from 'app/client/lib/localization'; + +const t = makeT('widgetTypesMap'); export const widgetTypesMap = new Map([ - ['record', {label: 'Table', icon: 'TypeTable'}], - ['single', {label: 'Card', icon: 'TypeCard'}], - ['detail', {label: 'Card List', icon: 'TypeCardList'}], - ['chart', {label: 'Chart', icon: 'TypeChart'}], - ['form', {label: 'Form', icon: 'Board'}], - ['custom', {label: 'Custom', icon: 'TypeCustom'}], - ['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}], + ['record', {name: 'Table', icon: 'TypeTable', getLabel: () => t('Table')}], + ['single', {name: 'Card', icon: 'TypeCard', getLabel: () => t('Card')}], + ['detail', {name: 'Card List', icon: 'TypeCardList', getLabel: () => t('Card List')}], + ['chart', {name: 'Chart', icon: 'TypeChart', getLabel: () => t('Chart')}], + ['form', {name: 'Form', icon: 'Board', getLabel: () => t('Form')}], + ['custom', {name: 'Custom', icon: 'TypeCustom', getLabel: () => t('Custom')}], + ['custom.calendar', {name: 'Calendar', icon: 'TypeCalendar', getLabel: () => t('Calendar')}], ]); // Widget type info. export interface IWidgetTypeInfo { - label: string; + name: string; icon: IconName; + getLabel: () => string; } // Returns the widget type info for sectionType, or the one for 'record' if sectionType is null. @@ -46,7 +50,7 @@ export function getTelemetryWidgetTypeFromPageWidget(widget: IPageWidget) { } function getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) { - let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.label; + let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.name; if (!telemetryWidgetType) { return undefined; } if (options.isNewTable) { diff --git a/static/locales/en.client.json b/static/locales/en.client.json index ef888e63..98f05d48 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -1578,5 +1578,14 @@ "Search": "Search", "Select...": "Select...", "Submit": "Submit" + }, + "widgetTypesMap": { + "Calendar": "Calendar", + "Card": "Card", + "Card List": "Card List", + "Chart": "Chart", + "Custom": "Custom", + "Form": "Form", + "Table": "Table" } } From 6ed9648708497b3f88cad6c30c58d4e6844658fa Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Fri, 17 May 2024 08:57:13 +0000 Subject: [PATCH 15/26] Translated using Weblate (Bulgarian) Currently translated at 76.9% (993 of 1290 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 276 +++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 3 deletions(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index 21d8a173..9b41528e 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -729,7 +729,8 @@ "Filter": "СИТО" }, "ThemeConfig": { - "Appearance ": "Външен вид " + "Appearance ": "Външен вид ", + "Switch appearance automatically to match system": "Превключете външния вид автоматично, за да съответства на системните настройки" }, "AppModel": { "This team site is suspended. Documents can be read, but not modified.": "Сайтът на отбора е спрян. Документите могат да бъдат четени, но не и променяни." @@ -762,7 +763,22 @@ "Form not found": "Формулярът не е намерен", "Powered by": "Задвижвано от", "You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Вие сте влезли като {{email}}. Можете да влезете с друг потребител или поистайте достъп от администратора.", - "You do not have access to this organization's documents.": "Нямате достъп до документите на тази организация." + "You do not have access to this organization's documents.": "Нямате достъп до документите на тази организация.", + "Access denied{{suffix}}": "Отказан достъп{{suffix}}", + "Add account": "Добави потребител", + "Contact support": "Свържете се с поддръжката", + "Error{{suffix}}": "Грешка{{suffix}}", + "Go to main page": "Отидете на главната страница", + "Page not found{{suffix}}": "Страницата не е намерена{{suffix}}", + "Sign in": "Вписване", + "Sign in to access this organization's documents.": "Впишете се, за да получите достъп до документите на тази организация.", + "Sign in again": "Впишете се, отново", + "Signed out{{suffix}}": "Отписан{{suffix}}", + "Something went wrong": "Възникна грешка", + "The requested page could not be found.{{separator}}Please check the URL and try again.": "Търсената страница не може да бъде намерена.{{separator}}Моля, проверете URL адреса и опитайте отново.", + "There was an error: {{message}}": "Възникна грешка: {{message}}", + "There was an unknown error.": "Възникнала е неизвестна грешка.", + "You are now signed out.": "Вече сте отписани." }, "menus": { "* Workspaces are available on team plans. ": "* Работните пространства са достъпни в абонаментите с екип. ", @@ -929,7 +945,28 @@ "WelcomeTour": { "Add New": "Добави нов", "Browse our {{templateLibrary}} to discover what's possible and get inspired.": "Разгледайте нашата {{templateLibrary}} и се вдъхновете, като откриете възможностите.", - "Building up": "Изграждане" + "Building up": "Изграждане", + "Configuring your document": "Конфигуриране на вашия документ", + "Customizing columns": "Нагласяне на колони", + "Double-click or hit {{enter}} on a cell to edit it. ": "Цъкнете двукратно или натиснете {{enter}} върху клетка, за да я редактирате. ", + "Make it relational! Use the {{ref}} type to link tables. ": "Направете го с препратка. Използвайте {{ref}} тип да свържете таблици. ", + "Reference": "Препратка", + "Start with {{equal}} to enter a formula.": "Започнете с {{equal}}, за да въведете формула.", + "Toggle the {{creatorPanel}} to format columns, ": "Отворете {{creatorPanel}}, за да форматирате колони, ", + "Use the Share button ({{share}}) to share the document or export data.": "Използвайте бутона Сподели ({{share}}), за да споделите документа или да изведете данни.", + "Use {{addNew}} to add widgets, pages, or import more data. ": "Използвайте {{addNew}}, за да добавите джаджи, страници или да внесете повече данни. ", + "Share": "Сподели", + "Sharing": "Споделяне", + "convert to card view, select data, and more.": "преобразуване в изглед на карта, избор на данни и др.", + "creator panel": "панел на създателя", + "template library": "библиотека с образци", + "Use {{helpCenter}} for documentation or questions.": "Използвайте {{helpCenter}} за документация или въпроси.", + "Welcome to Grist!": "Добре дошли в Grist!", + "Editing Data": "Редактиране на данни", + "Set formatting options, formulas, or column types, such as dates, choices, or attachments. ": "Задайте опции за форматиране, формули или типове колони, като дата, избор или прикачен файл. ", + "Enter": "Въведете", + "Flying higher": "Полет нагоре", + "Help Center": "Център за помощ" }, "ChoiceTextBox": { "CHOICES": "ИЗБОР" @@ -939,5 +976,238 @@ }, "EditorTooltip": { "Convert column to formula": "Преобразувай колона във формула" + }, + "Tools": { + "Access Rules": "Правила за достъп", + "Code View": "Изглед на кода", + "Delete": "Изтрий", + "Delete document tour?": "Да се изтрие ли наръча документи?", + "Document History": "Хронология на документа", + "How-to Tutorial": "Урок с инструкции", + "Raw Data": "Сурови данни", + "Return to viewing as yourself": "Спрете да гледате като друго лице", + "TOOLS": "ИНСТРУМЕНТИ", + "Tour of this Document": "Наръч на този документ", + "Validate Data": "Валидирайте данните", + "Settings": "Настройки", + "API Console": "API конзола" + }, + "TopBar": { + "Manage Team": "Управление на екипа" + }, + "TriggerFormulas": { + "Any field": "Всяко поле", + "Apply on changes to:": "Прилагане на промени в:", + "Apply on record changes": "Прилагане на промените в записа", + "Apply to new records": "Приложи към новите записи", + "Cancel": "Отказ", + "Close": "Затвори", + "Current field ": "Текущо поле ", + "OK": "Добре" + }, + "TypeTransformation": { + "Apply": "Приложи", + "Cancel": "Отказ", + "Preview": "Преглед", + "Revise": "Преразглеждане", + "Update formula (Shift+Enter)": "Обнови формулата (Shift+Enter)" + }, + "UserManagerModel": { + "Editor": "Редактор", + "In Full": "В цялост", + "No Default Access": "Без достъп по подразбиране", + "None": "Никой", + "Owner": "Собственик", + "View & Edit": "Преглед и редактиране", + "View Only": "Само преглед", + "Viewer": "Наблюдател" + }, + "ValidationPanel": { + "Rule {{length}}": "Правило {{length}}", + "Update formula (Shift+Enter)": "Обнови формулата (Shift+Enter)" + }, + "ViewAsBanner": { + "UnknownUser": "Неизвестен потребител" + }, + "ViewConfigTab": { + "Advanced settings": "Разширени настройки", + "Blocks": "Блокове", + "Form": "Формуляр", + "Make On-Demand": "Направи при поискване", + "Plugin: ": "Приставка: ", + "Unmark On-Demand": "Премахване на означение \"при поискване\"", + "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Големите таблици могат да бъдат отбелязани като „при поискване“, за да се избегне зареждането им в двигателя за данни.", + "Compact": "Компактен", + "Edit Card Layout": "Редактиране на оформлението на картата", + "Section: ": "Раздел: " + }, + "ViewLayoutMenu": { + "Advanced Sort & Filter": "Разширено сортиране и отсяване", + "Copy anchor link": "Copy anchor link", + "Data selection": "Избор на данни", + "Delete record": "Изтрий запис", + "Delete widget": "Изтрий джаджата", + "Download as CSV": "Изтегли като CSV", + "Download as XLSX": "Изтегли като XLSX", + "Edit Card Layout": "Редактирай оформлението на картата", + "Open configuration": "Отвори конфигурацията", + "Print widget": "Джаджа за печат", + "Show raw data": "Показване на сурови данни", + "Widget options": "Опции за джаджи", + "Add to page": "Добави към страницата", + "Collapse widget": "Свивий джаджата", + "Create a form": "Създай на формуляр" + }, + "ViewSectionMenu": { + "(customized)": "(персонализиран)", + "(empty)": "(празен)", + "(modified)": "(променен)", + "Custom options": "Потребителски опции", + "FILTER": "СИТО", + "Revert": "Върни", + "SORT": "СОРТИРАЙ", + "Save": "Съхрани", + "Update Sort&Filter settings": "Обнови настройките за соритане и отсяване" + }, + "VisibleFieldsConfig": { + "Cannot drop items into Hidden Fields": "Не можете да пускате елементи в скрити полета", + "Clear": "Изчисти", + "Hidden Fields cannot be reordered": "Скритите полета не могат да се пренареждат", + "Select All": "Избери всички", + "Visible {{label}}": "Видим {{label}}", + "Hide {{label}}": "Скрий {{label}}", + "Hidden {{label}}": "Скрит {{label}}", + "Show {{label}}": "Покажи {{label}}" + }, + "WelcomeQuestions": { + "Education": "Образование", + "Finance & Accounting": "Финанси и счетоводство", + "HR & Management": "ТРЗ и управление", + "IT & Technology": "ИТ и технологии", + "Marketing": "Маркетинг", + "Media Production": "Медийно производство", + "Other": "Други", + "Product Development": "Разработване на продукти", + "Research": "Изследвания", + "Sales": "Продажби", + "Type here": "Въведете тук", + "Welcome to Grist!": "Добре дошли в Grist!", + "What brings you to Grist? Please help us serve you better.": "Какво ви доведе в Grist? Моля, помогнете ни да ви обслужаваме по-добре." + }, + "WidgetTitle": { + "Cancel": "Отказ", + "Override widget title": "Замяна на заглавието на джаджа", + "Provide a table name": "Предоставете име на таблица", + "Save": "Съхрани", + "WIDGET TITLE": "ЗАГЛАВИЕ НА ДЖАДЖА", + "WIDGET DESCRIPTION": "ОПИСАНИЕ НА ДЖАДЖАТА", + "DATA TABLE NAME": "ИМЕ НА ТАБЛИЦА С ДАННИ" + }, + "breadcrumbs": { + "You may make edits, but they will create a new copy and will\nnot affect the original document.": "Можете да правите редакции, но те ще създадат ново копие и няма\nда засегнат оригиналния документ.", + "fiddle": "подправям", + "override": "отмени", + "recovery mode": "режим на възстановяване", + "snapshot": "моментна снимка", + "unsaved": "незапазени промени" + }, + "duplicatePage": { + "Duplicate page {{pageName}}": "Дублирай страница {{pageName}}", + "Note that this does not copy data, but creates another view of the same data.": "Имайте предвид, че това не копира данни, а създава друг изглед на същите данни." + }, + "LanguageMenu": { + "Language": "Език" + }, + "GristTooltips": { + "Apply conditional formatting to rows based on formulas.": "Прилагане на условно форматиране на редове въз основа на формули.", + "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}} запис в тази таблица, но вие можете да изберете коя колона от този запис да се покаже.", + "Click on “Open row styles” to apply conditional formatting to rows.": "Цъкнете върху „Стилове на отворени редове“, за да приложите условно форматиране към редове.", + "Clicking {{EyeHideIcon}} in each cell hides the field from this view without deleting it.": "Цъкването върху {{EyeHideIcon}} на всяка клетка скрива полето от този изглед, без да го изтрива.", + "Formulas that trigger in certain cases, and store the calculated value as data.": "Формули, които се задействат в определени случаи и съхраняват изчислената стойност като данни.", + "Nested Filtering": "Вложено отсяване", + "Only those rows will appear which match all of the filters.": "Ще се появят само онези редове, които отговарят на всички филтри.", + "Pinning Filters": "Закачени филтри", + "Raw Data page": "Страница със сурови данни", + "Rearrange the fields in your card by dragging and resizing cells.": "Пренареждайте полетата в картата, като плъзгате и променяте размера на клетките.", + "Reference Columns": "Колони с препратки", + "Reference columns are the key to {{relational}} data in Grist.": "Колоните с препратка са ключът към {{relational}} данни в Grist.", + "Select the table containing the data to show.": "Изберете таблицата, съдържаща данните, които искате да покажете.", + "The total size of all data in this document, excluding attachments.": "Общият размер на всички данни в този документ, с изключение на прикачените файлове.", + "They allow for one record to point (or refer) to another.": "Те позволяват един запис да сочи (или препраща) към друг.", + "This is the secret to Grist's dynamic and productive layouts.": "Това е тайната на динамичните и продуктивни оформления на Grist.", + "Try out changes in a copy, then decide whether to replace the original with your edits.": "Изпробвайте промените в копие, след което решете дали да замените оригинала с редакциите си.", + "Unpin to hide the the button while keeping the filter.": "Откачете бутона, за да го скриете, като запазите филтъра.", + "Updates every 5 minutes.": "Актуализира се на всеки 5 минути.", + "Useful for storing the timestamp or author of a new record, data cleaning, and more.": "Полезни за съхраненяване на дата и час, или създаване на нов запис, почистване на данни и др.", + "You can filter by more than one column.": "Можете да филтрирате по повече от една колона.", + "entire": "цялата", + "relational": "релационна", + "Access Rules": "Правила за достъп", + "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Правилата за достъп ви дават възможност да създавате детаилни правила, с които да определяте кой може да вижда или редактира кои части от документа ви.", + "Anchor Links": "Анкерни връзки", + "Custom Widgets": "Собствени джаджи", + "To make an anchor link that takes the user to a specific cell, click on a row and press {{shortcut}}.": "За да направите връзка за котва, която отвежда потребителя до конкретна клетка, цъкнете върху ред и натиснете {{shortcut}}.", + "Apply conditional formatting to cells in this column when formula conditions are met.": "Прилагане на условно форматиране към клетките в тази колона, когато са изпълнени условията на формулата.", + "Learn more.": "Научете повече.", + "Pinned filters are displayed as buttons above the widget.": "Прикачените филтри се показват като бутони над джаджата.", + "Click the Add New button to create new documents or workspaces, or import data.": "Цъкнете върху бутона \"Добави нов\", за да създадете нови документи или работни пространства, или да внесете данни.", + "Link your new widget to an existing widget on this page.": "Свържете новата си джаджа със съществуваща джаджа на тази страница.", + "Linking Widgets": "Свързване на джаджи", + "Editing Card Layout": "Редактиране на оформлението на картата", + "Select the table to link to.": "Изберете таблицата, към която искате да се свържете.", + "Selecting Data": "Избор на данни", + "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "Страницата \"Сурови данни\" съдържа списък на всички таблици с данни във вашия документ, включително обобщаващи таблици и таблици, които не са включени в оформлението на страницата.", + "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Използвайте иконата \\u{1D6BA}, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми.", + "Add New": "Добави нов", + "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Използвайте иконата 𝚺, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми." + }, + "FormulaAssistant": { + "Code View": "Изглед на кода", + "Hi, I'm the Grist Formula AI Assistant.": "Здравейте, аз съм ИИ асистентът за формули на Grist.", + "I can only help with formulas. I cannot build tables, columns, and views, or write access rules.": "Мога да помогна само с формули. Не мога да създавам таблици, колони и изгледи или да пиша правила за достъп.", + "Learn more": "Научете повече", + "Sign up for a free Grist account to start using the Formula AI Assistant.": "Регистрирайте се като безплатен потребител в Grist, за да започнете да използвате Formula AI Assistant.", + "What do you need help with?": "С какво имате нужда от помощ?", + "Formula AI Assistant is only available for logged in users.": "Formula AI Assistant е достъпен само за вписали се в системата потребители.", + "For higher limits, contact the site owner.": "За по-високи лимити се свържете със собственика на сайта.", + "You have used all available credits.": "Използвали сте всички налични кредити.", + "You have {{numCredits}} remaining credits.": "Имате оставащи кредити на {{numCredits}}.", + "upgrade to the Pro Team plan": "надграждане до абонамент Pro Team", + "upgrade your plan": "надграждане на абонамента ви", + "Press Enter to apply suggested formula.": "Натиснете Enter, за да приложите предложената формула.", + "Sign Up for Free": "Регистрирайте се безплатно", + "There are some things you should know when working with me:": "Има някои неща, които трябва да знаете, когато работите с мен:", + "For higher limits, {{upgradeNudge}}.": "За по-високи лимити: {{upgradeNudge}}." + }, + "GridView": { + "Click to insert": "Цъкнете, за да вмъкнете" + }, + "WelcomeSitePicker": { + "You can always switch sites using the account menu.": "Винаги можете да превключвате сайтовете чрез менюто на профила.", + "You have access to the following Grist sites.": "Имате достъп до следните сайтове на Grist.", + "Welcome back": "Добре дошъл отново" + }, + "DescriptionTextArea": { + "DESCRIPTION": "ОПИСАНИЕ" + }, + "UserManager": { + "Add {{member}} to your team": "Добавете {{member}} към екипа си", + "Allow anyone with the link to open.": "Позволете на всеки, който има връзка, да я отвори.", + "Anyone with link ": "Всеки с връзка ", + "Confirm": "Потвърди", + "Create a team to share with more people": "Създайте екип, за да споделите с повече хора", + "Grist support": "Поддръжка от Grist", + "Guest": "Гост", + "Invite multiple": "Поканете няколко", + "Invite people to {{resourceType}}": "Поканете хора в {{resourceType}}", + "Manage members of team site": "Управление на членовете на сайта на екипа", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Липсата на достъп по подразбиране позволява предоставянето на достъп до отделни документи или работни пространства, а не до целия екипен сайт.", + "Off": "Изключено", + "On": "Включено", + "Cancel": "Отказ", + "Close": "Затвори", + "Collaborator": "Сътрудник", + "Copy Link": "Копирай на връзката", + "Link copied to clipboard": "Връзка, копирана в системния буфер" } } From a6ceecbbf686226f6b28e149a991e9d6314aefb7 Mon Sep 17 00:00:00 2001 From: Riccardo Polignieri Date: Sat, 18 May 2024 15:13:09 +0000 Subject: [PATCH 16/26] Translated using Weblate (Italian) Currently translated at 100.0% (1297 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/it/ --- static/locales/it.client.json | 98 +++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 5 deletions(-) diff --git a/static/locales/it.client.json b/static/locales/it.client.json index 3af9cb55..c252e752 100644 --- a/static/locales/it.client.json +++ b/static/locales/it.client.json @@ -699,7 +699,34 @@ "Ok": "OK", "Manage Webhooks": "Gestisci web hook", "Webhooks": "Web hook", - "API Console": "Tavola delle API" + "API Console": "Tavola delle API", + "API console": "Console Api", + "Coming soon": "In arrivo", + "Copy to clipboard": "Copia negli appunti", + "Data Engine": "Motore dati", + "Default for DateTime columns": "Default per le colonne Data/Ora", + "Document ID": "ID documento", + "Find slow formulas": "Trova le formule lente", + "For currency columns": "Per le colonne di valuta", + "For number and date formats": "Per i formati di numeri e date", + "Formula times": "Tempi delle formule", + "Hard reset of data engine": "Reset forzato del motore dati", + "ID for API use": "ID per l'uso con le Api", + "Locale": "Locale", + "Manage webhooks": "Gestisci i webhooks", + "Notify other services on doc changes": "Notifica altri servizi dei cambiamenti nel documento", + "Python": "Python", + "Reload": "Ricarica", + "Time Zone": "Fuso orario", + "python2 (legacy)": "Python 2 (superato)", + "python3 (recommended)": "Python 3 (raccomandato)", + "API URL copied to clipboard": "Url della Api copiata negli appunti", + "API documentation.": "Documentazione Api.", + "Base doc URL: {{docApiUrl}}": "Url documento base: {{docApiUrl}}", + "Currency": "Valuta", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID Documento da usare quando le Api REST chiedono {{docId}}. Vedi {{apiURL}}", + "Python version used": "Versione di Python in uso", + "Try API calls from the browser": "Prova le chiamate Api nel browser" }, "DocumentUsage": { "Data Size": "Dimensione dei dati", @@ -860,7 +887,8 @@ }, "OnBoardingPopups": { "Finish": "Termina", - "Next": "Prossimo" + "Next": "Prossimo", + "Previous": "Precedente" }, "OpenVideoTour": { "Grist Video Tour": "Video tour di Grist", @@ -1041,7 +1069,8 @@ "Dismiss": "Ignora", "Don't ask again.": "Non chiedere più.", "Don't show again.": "Non mostrare più.", - "Don't show tips": "Non mostrare i suggerimenti" + "Don't show tips": "Non mostrare i suggerimenti", + "TIP": "TIP" }, "pages": { "Duplicate Page": "Duplica pagina", @@ -1145,7 +1174,9 @@ "These rules are applied after all column rules have been processed, if applicable.": "Queste regole sono applicate dopo che tutte le regole delle colonne sono state applicate, se possibile.", "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Costruisci del semplici moduli e condividili rapidamente con il nostro nuovo widget. {{learnMoreButton}}", "Forms are here!": "Sono arrivati i moduli!", - "Learn more": "Approfondisci" + "Learn more": "Approfondisci", + "Example: {{example}}": "Esempio: {{example}}", + "Filter displayed dropdown values with a condition.": "Il filtro mostrava i valori nella tendina con una condizione." }, "DescriptionConfig": { "DESCRIPTION": "DESCRIZIONE" @@ -1416,7 +1447,26 @@ "Sponsor": "Sponsor", "Current": "Attuale", "Telemetry": "Telemetria", - "Version": "Versione" + "Version": "Versione", + "Auto-check when this page loads": "Controlla automaticamente quando questa pagina è caricata", + "Grist is up to date": "Grist è aggiornato", + "Error checking for updates": "Errore nel controllo degli aggiornamenti", + "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Grist supporta formule molto potenti, grazie a Python. Raccomandiamo di impostare la variabile d'ambiente GRIST_SANDBOX_FLAVOR a gvisor se il vostro hardware lo permette (in genere sì), così che le formule di un documento agiscano in una sandbox isolata dagli altri documenti e dalla rete.", + "Last checked {{time}}": "Ultimo controllo {{time}}", + "Learn more.": "Per saperne di più.", + "Newer version available": "Disponibile una versione più recente", + "No information available": "Nessuna informazione disponibile", + "OK": "OK", + "Sandboxing": "Uso della sandbox", + "Security Settings": "Impostazioni di sicurezza", + "Updates": "Aggiornamenti", + "unconfigured": "non configurato", + "unknown": "sconosciuto", + "Error": "Errore", + "Check now": "Controlla adesso", + "Checking for updates...": "Controllo gli aggiornamenti...", + "Grist releases are at ": "Le release di Grist sono a ", + "Sandbox settings for data engine": "Impostazione della sandbox per il motore dati" }, "WelcomeCoachingCall": { "Maybe Later": "Forse più tardi", @@ -1499,5 +1549,43 @@ }, "FormErrorPage": { "Error": "Errore" + }, + "ChoiceEditor": { + "Error in dropdown condition": "Errore nella condizione della tendina", + "No choices matching condition": "Nessuna scelta soddisfa la condizione", + "No choices to select": "Nessuna scelta da selezionare" + }, + "ReferenceUtils": { + "No choices to select": "Nessuna scelta da selezionare", + "Error in dropdown condition": "Errore nella condizione della tendina", + "No choices matching condition": "Nessuna scelta soddisfa la condizione" + }, + "DropdownConditionConfig": { + "Set dropdown condition": "Imposta la condizione della tendina", + "Dropdown Condition": "Condizione della tendina", + "Invalid columns: {{colIds}}": "Colonne non valide: {{colIds}}" + }, + "DropdownConditionEditor": { + "Enter condition.": "Inserisci la condizione." + }, + "ChoiceListEditor": { + "Error in dropdown condition": "Errore nella condizione della tendina", + "No choices matching condition": "Nessuna scelta soddisfa la condizione", + "No choices to select": "Nessuna scelta da selezionare" + }, + "FormRenderer": { + "Reset": "Reset", + "Search": "Cerca", + "Select...": "Seleziona...", + "Submit": "Invia" + }, + "widgetTypesMap": { + "Card": "Scheda", + "Table": "Tabella", + "Calendar": "Calendario", + "Card List": "Lista di schede", + "Chart": "Grafico", + "Custom": "Personalizzato", + "Form": "Modulo" } } From 69ce9beb9ce07116bfc44c5e5d7a093501a9f674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C4=8Dek=20Prijatelj?= Date: Sun, 19 May 2024 03:49:37 +0000 Subject: [PATCH 17/26] Translated using Weblate (Slovenian) Currently translated at 98.0% (1272 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/sl/ --- static/locales/sl.client.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json index 03ced8e8..cce12b8b 100644 --- a/static/locales/sl.client.json +++ b/static/locales/sl.client.json @@ -497,7 +497,22 @@ "Manage Webhooks": "Upravljanje spletnih kljuk", "Webhooks": "Spletne kljuke", "Engine (experimental {{span}} change at own risk):": "Pogon (eksperimentalno {{span}} spreminjanje na lastno odgovornost):", - "API Console": "API Konzola" + "API Console": "API Konzola", + "API console": "API konzola", + "API documentation.": "API dokumentacija.", + "Base doc URL: {{docApiUrl}}": "URL osnovnega dokumenta: {{docApiUrl}}", + "Coming soon": "Prihaja kmalu", + "Copy to clipboard": "Kopiraj v odložišče", + "Currency": "Valuta", + "Data Engine": "Podatkovna mašina", + "Document ID": "ID dokumenta", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID dokumenta, ki se uporabi vsakič, ko REST API pokliče {{docId}}. Oglej si {{apiURL}}", + "Find slow formulas": "Poišči počasne formule", + "For currency columns": "Za valutne stolpce", + "For number and date formats": "Za format števila in datuma", + "Formula times": "Časi formule", + "API URL copied to clipboard": "URL API-ja kopiran v odložišče", + "Default for DateTime columns": "Privzeto za stolpce DateTime" }, "GridOptions": { "Horizontal Gridlines": "Vodoravne linije", From 991e43f07fd43288ad26029e3c4bf70b21f9a965 Mon Sep 17 00:00:00 2001 From: Spoffy <4805393+Spoffy@users.noreply.github.com> Date: Mon, 20 May 2024 14:56:05 +0100 Subject: [PATCH 18/26] fixes removed module in Python 3.12 (#984) --- sandbox/grist/gencode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sandbox/grist/gencode.py b/sandbox/grist/gencode.py index 3042f076..14a1faa8 100644 --- a/sandbox/grist/gencode.py +++ b/sandbox/grist/gencode.py @@ -17,7 +17,7 @@ The schema for grist data is: """ import logging import re -import imp +import types from collections import OrderedDict import six @@ -207,7 +207,7 @@ def _is_special_table(table_id): def exec_module_text(module_text): - mod = imp.new_module(codebuilder.code_filename) + mod = types.ModuleType(codebuilder.code_filename) codebuilder.save_to_linecache(module_text) code_obj = compile(module_text, codebuilder.code_filename, "exec") # pylint: disable=exec-used From fc2f4578c09ba377c345b1974d71ef82c9757b9b Mon Sep 17 00:00:00 2001 From: Camille L Date: Mon, 20 May 2024 15:47:10 +0000 Subject: [PATCH 19/26] Translated using Weblate (French) Currently translated at 97.2% (1261 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/fr/ --- static/locales/fr.client.json | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 0a08458c..453f4fca 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -296,7 +296,9 @@ "Ok": "OK", "Manage Webhooks": "Gérer les points d’ancrage Web", "Webhooks": "Points d’ancrage Web", - "API Console": "Console de l'API" + "API Console": "Console de l'API", + "Reload": "Recharger", + "Python": "Python" }, "DocumentUsage": { "Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.", @@ -565,7 +567,8 @@ }, "OnBoardingPopups": { "Finish": "Terminer", - "Next": "Suivant" + "Next": "Suivant", + "Previous": "Précédent" }, "OpenVideoTour": { "YouTube video player": "Lecteur vidéo YouTube", @@ -1543,5 +1546,20 @@ "Error in dropdown condition": "Erreur dans la condition de la liste déroulant", "No choices matching condition": "Aucun choix correspondant à la condition", "No choices to select": "Aucun choix à sélectionner" + }, + "FormRenderer": { + "Submit": "Soumettre", + "Select...": "Sélectionner...", + "Search": "Rechercher", + "Reset": "Réinitialiser" + }, + "widgetTypesMap": { + "Table": "Table", + "Form": "Formulaire", + "Custom": "Personnalisée", + "Chart": "Graphique", + "Card List": "Liste de fiches", + "Card": "Fiche", + "Calendar": "Calendrier" } } From 5f2b01a9eee5475ce55847ed2c641184ef4a8920 Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Mon, 20 May 2024 01:10:44 +0000 Subject: [PATCH 20/26] Translated using Weblate (Bulgarian) Currently translated at 88.6% (1150 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 199 +++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 3 deletions(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index 9b41528e..3c50df0d 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -1159,7 +1159,24 @@ "The Raw Data page lists all data tables in your document, including summary tables and tables not included in page layouts.": "Страницата \"Сурови данни\" съдържа списък на всички таблици с данни във вашия документ, включително обобщаващи таблици и таблици, които не са включени в оформлението на страницата.", "Use the \\u{1D6BA} icon to create summary (or pivot) tables, for totals or subtotals.": "Използвайте иконата \\u{1D6BA}, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми.", "Add New": "Добави нов", - "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Използвайте иконата 𝚺, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми." + "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Използвайте иконата 𝚺, за да създадете обобщаващи (или отправни) таблици за общи суми или междинни суми.", + "You can choose one of our pre-made widgets or embed your own by providing its full URL.": "Можете да изберете някоя от нашите готови джаджи или да вградите своя собствена, като предоставите пълния ѝ URL адрес.", + "Calendar": "Календар", + "Can't find the right columns? Click 'Change Widget' to select the table with events data.": "Не можете да намерите правилните колони? Цъкнете върху \"Промяна на джаджата\", за да изберете таблицата с данни за събитията.", + "Lookups return data from related tables.": "Търсенето връща данни от свързани таблици.", + "Use reference columns to relate data in different tables.": "Използвайте референтни колони за свързване на данни в различни таблици.", + "Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Изграждайте прости формуляри директно в Grist и споделяйте с едно кликване с новата ни джаджа. {{learnMoreButton}}", + "Filter displayed dropdown values with a condition.": "Отсяване на показаните стойности в падащото меню с условие.", + "To configure your calendar, select columns for start": { + "end dates and event titles. Note each column's type.": "За да конфигурирате календара си, изберете колони за начални/крайни дати и заглавия на събития. Обърнете внимание на типа на всяка колона." + }, + "A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUID е произволно генериран низ, който е полезен за уникални обозначения и ключове за връзки.", + "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 Assistant.", + "Forms are here!": "Формулярите са тук!", + "Learn more": "Научете повече", + "These rules are applied after all column rules have been processed, if applicable.": "Тези правила се прилагат след обработката на всички правила за колони, ако е приложимо.", + "Example: {{example}}": "Пример: {{example}}" }, "FormulaAssistant": { "Code View": "Изглед на кода", @@ -1177,7 +1194,27 @@ "Press Enter to apply suggested formula.": "Натиснете Enter, за да приложите предложената формула.", "Sign Up for Free": "Регистрирайте се безплатно", "There are some things you should know when working with me:": "Има някои неща, които трябва да знаете, когато работите с мен:", - "For higher limits, {{upgradeNudge}}.": "За по-високи лимити: {{upgradeNudge}}." + "For higher limits, {{upgradeNudge}}.": "За по-високи лимити: {{upgradeNudge}}.", + "Ask the bot.": "Попитайте бота.", + "Capabilities": "Възможности", + "Data": "Данни", + "Formula Cheat Sheet": "Справочник с формули", + "Formula Help. ": "Помощ с формулите. ", + "Function List": "Списък на функциите", + "Grist's AI Assistance": "Grist ИИ помощник", + "Grist's AI Formula Assistance. ": "ИИ помощник за формулите на Grist. ", + "Regenerate": "Регенерирай", + "Save": "Съхрани", + "See our {{helpFunction}} and {{formulaCheat}}, or visit our {{community}} for more help.": "Вижте нашите {{helpFunction}} и {{formulaCheat}}, или посетете нашия {{community}} за повече помощ.", + "Tips": "Съвети", + "AI Assistant": "ИИ помощник", + "Apply": "Приложи", + "Cancel": "Отказ", + "Clear Conversation": "Изчисти разговора", + "Community": "Общност", + "Need help? Our AI assistant can help.": "Имате нужда от помощ? Нашият асистент с изкуствен интелект може да ви помогне.", + "Preview": "Преглед", + "New Chat": "Нов чат" }, "GridView": { "Click to insert": "Цъкнете, за да вмъкнете" @@ -1208,6 +1245,162 @@ "Close": "Затвори", "Collaborator": "Сътрудник", "Copy Link": "Копирай на връзката", - "Link copied to clipboard": "Връзка, копирана в системния буфер" + "Link copied to clipboard": "Връзка, копирана в системния буфер", + "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{name}}.": "След като премахнете собствения си достъп, няма да можете да го възстановите без помощта на друг човек с достатъчен достъп до {{name}}.", + "Open Access Rules": "Отвори правилата за достъп", + "Public access: ": "Публичен достъп: ", + "Remove my access": "Премахване на достъпа ми", + "Save & ": "Запази & ", + "Team member": "Член на екипа", + "User may not modify their own access.": "Потребителят няма право да променя собствения си достъп.", + "Your role for this team site": "Вашата роля в този екип", + "free collaborator": "безплатен сътрудник", + "guest": "гост", + "member": "член", + "team site": "сайт на екипа", + "{{collaborator}} limit exceeded": "{{collaborator}} превишени ограничения", + "{{limitAt}} of {{limitTop}} {{collaborator}}s": "{{limitAt}} на {{limitTop}} {{collaborator}}и", + "No default access allows access to be granted to individual documents or workspaces, rather than the full team site.": "Липсата на достъп по подразбиране позволява предоставянето на достъп до отделни документи или работни пространства, а не до целия екипен сайт.", + "User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Потребителят наследява разрешенията от {{parent})}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.", + "You are about to remove your own access to this {{resourceType}}": "Ще премахнете собствения си достъп до този {{resourceType}}", + "Outside collaborator": "Външен сътрудник", + "Public Access": "Публичен достъп", + "Public access": "Публичен достъп", + "User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "Потребителят наследява разрешенията от {{parent})}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.", + "Your role for this {{resourceType}}": "Ролята ви в този {{resourceType}}", + "Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "Публичен достъп, наследен от {{parent}}. За да го премахнете, задайте опцията 'Наследяване на достъпа' на 'Няма'.", + "Once you have removed your own access, you will not be able to get it back without assistance from someone else with sufficient access to the {{resourceType}}.": "След като премахнете собствения си достъп, няма да можете да го възстановите без помощта на друг потребител с достатъчен достъп до {{resourceType}}.", + "User has view access to {{resource}} resulting from manually-set access to resources inside. If removed here, this user will lose access to resources inside.": "Потребителят има достъп за преглед до {{resource}} в резултат на ръчно зададен достъп до ресурсите в него. Ако бъде премахнат тук, този потребител ще загуби достъпа до вътрешните ресурси." + }, + "DescriptionConfig": { + "DESCRIPTION": "ОПИСАНИЕ" + }, + "PagePanels": { + "Close Creator Panel": "Затвори панела за създаване", + "Open Creator Panel": "Отвори панела за създаване" + }, + "ColumnTitle": { + "Add description": "Добави описание", + "COLUMN ID: ": "ОБОЗНАЧЕНИЕ НА КОЛОНА: ", + "Cancel": "Отказ", + "Column label": "Етикет на колоната", + "Provide a column label": "Предоставете етикет на колоната", + "Save": "Съхрани", + "Column ID copied to clipboard": "Обозначението на колоната е копирано в системния буфер", + "Column description": "Описание на колоната", + "Close": "Затвори" + }, + "Clipboard": { + "Got it": "Разбрах", + "Unavailable Command": "Недостъпна команда" + }, + "FieldContextMenu": { + "Clear field": "Изчисти полето", + "Copy anchor link": "Копирай връзка към котва", + "Cut": "Изрежи", + "Hide field": "Скрий полето", + "Paste": "Постави", + "Copy": "Копирай" + }, + "WebhookPage": { + "Clear Queue": "Изчисти опашката", + "Webhook Settings": "Настройки на Webhook", + "Cleared webhook queue.": "Изчистена webhook опашка.", + "Enabled": "Разрешено", + "Event Types": "Видове събития", + "Memo": "Бележка", + "Name": "Име", + "Ready Column": "Колона за готовност", + "Removed webhook.": "Премахнат webhook.", + "Sorry, not all fields can be edited.": "Съжаляваме, но не всички полета могат да бъдат редактирани.", + "Status": "Състояние", + "URL": "URL", + "Webhook Id": "Обозначение на webhook", + "Table": "Таблица", + "Filter for changes in these columns (semicolon-separated ids)": "Отсяване за промени в тези колони (обозначения, разделени с точка и запетая)", + "Columns to check when update (separated by ;)": "Колони, които да се проверяват при актуализация (разделени с ;)" + }, + "SearchModel": { + "Search all pages": "Търси във всички страници", + "Search all tables": "Търси във всички таблици" + }, + "searchDropdown": { + "Search": "Търси" + }, + "SupportGristNudge": { + "Close": "Затвори", + "Support Grist": "Подкрепете Grist", + "Support Grist page": "Подкрепете страницата на Grist", + "Thank you! Your trust and support is greatly appreciated. Opt out any time from the {{link}} in the user menu.": "Благодаря ви! Високо ценим вашето доверие и подкрепа. Можете да се откажете по всяко време от {{link}} в менюто на потребителя.", + "Admin Panel": "Административен панел", + "Contribute": "Допринеси", + "Help Center": "Център за помощ", + "Opt in to Telemetry": "Включете се в телеметрията", + "Opted In": "Включи се" + }, + "SupportGristPage": { + "GitHub": "GitHub", + "GitHub Sponsors page": "Страница на спонсорите във GitHub", + "Help Center": "Център за помощ", + "Home": "Начало", + "Manage Sponsorship": "Управление на спонсорството", + "Opt in to Telemetry": "Включете се в телеметрията", + "Opt out of Telemetry": "Отказ от телеметрия", + "Sponsor Grist Labs on GitHub": "Спонсорирай Grist Labs в GitHub", + "Telemetry": "Телеметрия", + "This instance is opted in to telemetry. Only the site administrator has permission to change this.": "Телеметрията е включена. Само администраторът на сайта има право да променя това.", + "Support Grist": "Подкрепете Grist", + "This instance is opted out of telemetry. Only the site administrator has permission to change this.": "Телеметрия е изключена. Само администраторът на сайта има право да променя това.", + "You have opted out of telemetry.": "Отказали сте се от телеметрията.", + "Sponsor": "Спонсор", + "You can opt out of telemetry at any time from this page.": "Можете да се откажете от телеметрията по всяко време от тази страница.", + "We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Събираме само статистически данни за използването, както е описано подробно в нашия {{link}}, никога не събираме съдържанието на документите.", + "You have opted in to telemetry. Thank you!": "Включилите сте телеметрията. Благодарим ви!" + }, + "buildViewSectionDom": { + "No data": "Няма данни", + "No row selected in {{title}}": "Няма избран ред в {{title}}", + "Not all data is shown": "Не всички данни са показани" + }, + "FloatingEditor": { + "Collapse Editor": "Прибери редактора" + }, + "FloatingPopup": { + "Minimize": "Минимизирайте", + "Maximize": "Максимизирайте" + }, + "CardContextMenu": { + "Copy anchor link": "Копирай връзка към котва", + "Insert card": "Вкарай карта", + "Insert card above": "Вкарай картата по-горе", + "Insert card below": "Вкарай картата по-долу", + "Delete card": "Изтрий карта", + "Duplicate card": "Дублирай карта" + }, + "WelcomeCoachingCall": { + "Maybe Later": "Може би по-късно", + "Schedule Call": "Насрочете разговор", + "Schedule your {{freeCoachingCall}} with a member of our team.": "Насрочете {{freeCoachingCall}} с член на нашия екип.", + "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.": "По време на разговора ще отделим време, за да разберем нуждите ви и да адаптираме към вас. Можем да ви покажем основите на Grist или да започнем работа с вашите данни веднага, за да изградим таблата за управление, от които се нуждаете." + }, + "FormView": { + "Link copied to clipboard": "Връзката е копирана в системния буфер", + "Preview": "Преглед", + "Reset": "Нулирай", + "Reset form": "Нулирай формуляра", + "Publish": "Публикувайте", + "Publish your form?": "Ще публикувайте ли формуляра си?", + "Unpublish": "Отмени на публикуването", + "Unpublish your form?": "Отмени публикуването на формуляра?", + "Anyone with the link below can see the empty form and submit a response.": "Всеки, който използва връзката по-долу, може да види празния формуляр и да изпрати отговор.", + "Are you sure you want to reset your form?": "Сигурни ли сте, че искате да нулирате формуляра си?", + "Code copied to clipboard": "Кодът е копиран в системния буфер", + "Copy code": "Копирай кода", + "Copy link": "Копирай връзката", + "Embed this form": "Вграждане на този формуляр", + "Save your document to publish this form.": "Запазете документа си, за да публикувате този формуляр." + }, + "HiddenQuestionConfig": { + "Hidden fields": "Скрити полета" } } From 8a67d9f686332c42b833144c4f76fa9f49f82fee Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Tue, 21 May 2024 07:59:43 +0000 Subject: [PATCH 21/26] Translated using Weblate (Bulgarian) Currently translated at 99.1% (1286 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 191 +++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 3 deletions(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index 3c50df0d..6475052b 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -111,7 +111,7 @@ "Description": "Описание", "Key": "Ключ", "Memory Error": "Грешка в паметта", - "Translators: please translate this only when your language is ready to be offered to users": "" + "Translators: please translate this only when your language is ready to be offered to users": "Преводачи: моля, превеждайте това само когато вашият език е готов да бъде предложен на потребителите" }, "AppHeader": { "Home Page": "Начална страница", @@ -1381,7 +1381,8 @@ "Maybe Later": "Може би по-късно", "Schedule Call": "Насрочете разговор", "Schedule your {{freeCoachingCall}} with a member of our team.": "Насрочете {{freeCoachingCall}} с член на нашия екип.", - "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.": "По време на разговора ще отделим време, за да разберем нуждите ви и да адаптираме към вас. Можем да ви покажем основите на Grist или да започнем работа с вашите данни веднага, за да изградим таблата за управление, от които се нуждаете." + "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.": "По време на разговора ще отделим време, за да разберем нуждите ви и да адаптираме към вас. Можем да ви покажем основите на Grist или да започнем работа с вашите данни веднага, за да изградим таблата за управление, от които се нуждаете.", + "free coaching call": "безплатен разговор за напътстване" }, "FormView": { "Link copied to clipboard": "Връзката е копирана в системния буфер", @@ -1398,9 +1399,193 @@ "Copy code": "Копирай кода", "Copy link": "Копирай връзката", "Embed this form": "Вграждане на този формуляр", - "Save your document to publish this form.": "Запазете документа си, за да публикувате този формуляр." + "Save your document to publish this form.": "Запазете документа си, за да публикувате този формуляр.", + "Share": "Сподели", + "Share this form": "Споделu този формуляр", + "View": "Виж" }, "HiddenQuestionConfig": { "Hidden fields": "Скрити полета" + }, + "Editor": { + "Delete": "Изтрий" + }, + "Menu": { + "Building blocks": "Изграждащи блокчета", + "Columns": "Колони", + "Copy": "Копирай", + "Cut": "Изрежи", + "Insert question above": "Вмъкнете въпроса по-горе", + "Insert question below": "Въведете въпроса по-долу", + "Paragraph": "Параграф", + "Paste": "Постави", + "Separator": "Разделител", + "Unmapped fields": "Несъпоставени полета", + "Header": "Заглавие" + }, + "UnmappedFieldsConfig": { + "Clear": "Изчисти", + "Map fields": "Съпостави полета", + "Mapped": "Съпоставени", + "Select All": "Избери всички", + "Unmap fields": "Премахни съпоставияния на полета", + "Unmapped": "Несъпоставени" + }, + "FormConfig": { + "Required field": "Задължително поле", + "Ascending": "Възходящ", + "Default": "По подразбиране", + "Descending": "Низходящ", + "Field Format": "Формат на полето", + "Field Rules": "Правила на полето", + "Horizontal": "Хоризонтален", + "Options Alignment": "Опции за подравняване", + "Options Sort Order": "Опции за посока на сортиране", + "Radio": "Радио бутон", + "Select": "Избери", + "Vertical": "Вертикален", + "Field rules": "Правила на полето" + }, + "CustomView": { + "To use this widget, please map all non-optional columns from the creator panel on the right.": "За да използвате тази джаджа, съпоставете всички незадължителни колони от панела на създателя вдясно.", + "Some required columns aren't mapped": "Някои задължителни колони не са съпоставени" + }, + "FormContainer": { + "Powered by": "Задвижвано от", + "Build your own form": "Изградете свой собствен формуляр" + }, + "FormErrorPage": { + "Error": "Грешка" + }, + "FormModel": { + "Oops! The form you're looking for doesn't exist.": "Опа! Търсеният от вас формуляр не съществува.", + "Oops! This form is no longer published.": "Опа! Този формуляр вече не е публикуван.", + "There was a problem loading the form.": "Има проблем със зареждането на формуляра.", + "You don't have access to this form.": "Нямате достъп до този формуляр." + }, + "FormPage": { + "There was an error submitting your form. Please try again.": "Има грешка при подаването на формуляра. Моля, опитайте отново." + }, + "FormSuccessPage": { + "Form Submitted": "Подаден формуляр", + "Thank you! Your response has been recorded.": "Благодаря ви! Вашият отговор е записан.", + "Submit new response": "Подаване на нов отговор" + }, + "DateRangeOptions": { + "Last 30 days": "Последните 30 дни", + "Last 7 days": "Последните 7 дни", + "Last Week": "Миналата седмица", + "Next 7 days": "Следващите 7 дни", + "This week": "Тази седмица", + "This year": "Тази година", + "This month": "Този месец", + "Today": "Днес" + }, + "MappedFieldsConfig": { + "Clear": "Изчисти", + "Mapped": "Съпоставени", + "Unmapped": "Несъпоставени", + "Map fields": "Съпостави полета", + "Select All": "Избери всички", + "Unmap fields": "Премахни съпоставияния на полета" + }, + "Section": { + "Insert section above": "Вмъкнете раздел по-горе", + "Insert section below": "Вмъкнете раздел по-долу" + }, + "CreateTeamModal": { + "Cancel": "Отказ", + "Choose a name and url for your team site": "Изберете име и URL адрес за сайта на вашия екип", + "Domain name is invalid": "Името на домейна е невалидно", + "Go to your site": "Отидете на вашия сайт", + "Team name": "Име на екипа", + "Team name is required": "Името на екипа е задължително", + "Work as a Team": "Работете в екип", + "Billing is not supported in grist-core": "Фактурирането не се поддържа в grist-core", + "Create site": "Създай сайт", + "Domain name is required": "Името на домейна е задължително", + "Team site created": "Създаване на сайт на екипа", + "Team url": "URL адрес на екипа" + }, + "AdminPanel": { + "Admin Panel": "Административен панел", + "Current": "Текущ", + "Current version of Grist": "Текуща версия на Grist", + "Help us make Grist better": "Помогнете ни да направим Grist по-добър", + "Home": "Начало", + "Sponsor": "Спомоществовател", + "Support Grist": "Подкрепете Grist", + "Support Grist Labs on GitHub": "Подкрепете Grist Labs в GitHub", + "Telemetry": "Телеметрия", + "Auto-check when this page loads": "Автоматична проверка при зареждане на тази страница", + "Check now": "Проверете сега", + "Checking for updates...": "Проверяване за актуализации...", + "Error": "Грешка", + "Error checking for updates": "Грешка при проверка за актуализации", + "Grist is up to date": "Grist е актуален", + "Grist releases are at ": "Версиите на Grist са в ", + "Last checked {{time}}": "Последна проверка {{time}}", + "Learn more.": "Научете повече.", + "Newer version available": "Налична е по-нова версия", + "No information available": "Няма налична информация", + "OK": "Добре", + "Sandbox settings for data engine": "Настройки на пясъчника за двигателя за данни", + "Sandboxing": "Изолация (пясъчник)", + "Security Settings": "Настройки на сигурността", + "Updates": "Актуализации", + "unconfigured": "неконфигуриран", + "unknown": "неизвестен", + "Version": "Версия", + "Grist allows for very powerful formulas, using Python. We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor if your hardware supports it (most will), to run formulas in each document within a sandbox isolated from other documents and isolated from the network.": "Grist поддържа много мощни формули, изпълнявайки ги с Python. Препоръчваме да зададете променливата на средата GRIST_SANDBOX_FLAVOR на gvisor, ако вашият хардуер го поддържа (повечето го), за да изпълнявате формули във всеки документ във пясъчник, изолирана от други документи и изолирана от мрежата." + }, + "Columns": { + "Remove Column": "Премахни колона" + }, + "Field": { + "No choices configured": "Няма конфигуриран избор", + "No values in show column of referenced table": "Няма стойности в колоната за показване на сочената таблица" + }, + "Toggle": { + "Checkbox": "Поле за отметка", + "Field Format": "Формат на полето", + "Switch": "Превключвател" + }, + "ChoiceEditor": { + "No choices matching condition": "Няма избор, който да отговаря на условието", + "No choices to select": "Няма възможности за избор", + "Error in dropdown condition": "Грешка в падащото условие" + }, + "ChoiceListEditor": { + "No choices matching condition": "Няма избор, който да отговаря на условието", + "No choices to select": "Няма възможности за избор", + "Error in dropdown condition": "Грешка в падащото условие" + }, + "DropdownConditionConfig": { + "Dropdown Condition": "Условие за падащо меню", + "Invalid columns: {{colIds}}": "Невалидни колони: {{colIds}}", + "Set dropdown condition": "Задаване на условие за падащо меню" + }, + "DropdownConditionEditor": { + "Enter condition.": "Въведете условие." + }, + "ReferenceUtils": { + "Error in dropdown condition": "Грешка в падащото условие", + "No choices matching condition": "Няма избор, който да отговаря на условието", + "No choices to select": "Няма възможности за избор" + }, + "FormRenderer": { + "Reset": "Нулирай", + "Search": "Търсене", + "Submit": "Подай", + "Select...": "Изберете..." + }, + "widgetTypesMap": { + "Calendar": "Календар", + "Card": "Карта", + "Card List": "Списък на картите", + "Custom": "Собствен", + "Form": "Формуляр", + "Chart": "Графика", + "Table": "Таблица" } } From 27c8a321dfeb998252f08413aa1fcbdd4215237d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Guti=C3=A9rrez=20Hermoso?= Date: Fri, 17 May 2024 15:25:07 -0400 Subject: [PATCH 22/26] workflows: add a workflow to add issues to the self-hosted project Based on instructions here: https://github.com/actions/add-to-project The idea is that anyone can tag an issue as self-hosting and if that's so, it'll show up in the corresponding kanban. --- .github/workflows/self-hosted.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/self-hosted.yml diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml new file mode 100644 index 00000000..ac2fc8c0 --- /dev/null +++ b/.github/workflows/self-hosted.yml @@ -0,0 +1,18 @@ +name: Add self-hosting issues to the self-hosting project + +on: + issues: + types: + - opened + - labeled + +jobs: + add-to-project: + name: Add issue to project + runs-on: ubuntu-latest + steps: + - uses: actions/add-to-project@v1.0.1 + with: + project-url: https://github.com/orgs/gristlabs/projects/2 + github-token: ${{ secrets.SELF_HOSTED_PROJECT }} + labeled: self-hosting From 634635de097ca38b643635261bf4102ee107cb7a Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Tue, 21 May 2024 18:56:39 +0000 Subject: [PATCH 23/26] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (1297 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt_BR/ --- static/locales/pt_BR.client.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/locales/pt_BR.client.json b/static/locales/pt_BR.client.json index 1c3079db..6c7f2223 100644 --- a/static/locales/pt_BR.client.json +++ b/static/locales/pt_BR.client.json @@ -1642,5 +1642,14 @@ "Search": "Pesquisar", "Select...": "Selecionar...", "Submit": "Enviar" + }, + "widgetTypesMap": { + "Calendar": "Calendário", + "Card": "Cartão", + "Card List": "Lista de cartões", + "Chart": "Gráfico", + "Custom": "Personalizado", + "Table": "Tabela", + "Form": "Formulário" } } From a8ca5ffd8580ee89edb626bc18a304aadf627a6b Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Wed, 22 May 2024 02:02:28 +0000 Subject: [PATCH 24/26] Translated using Weblate (Spanish) Currently translated at 100.0% (1297 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/es/ --- static/locales/es.client.json | 50 ++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/static/locales/es.client.json b/static/locales/es.client.json index 67376d4c..f3807e53 100644 --- a/static/locales/es.client.json +++ b/static/locales/es.client.json @@ -250,7 +250,34 @@ "API": "API", "Webhooks": "Ganchos Web", "Manage Webhooks": "Administrar los ganchos web", - "API Console": "Consola de la API" + "API Console": "Consola de la API", + "API URL copied to clipboard": "URL de API copiada en el portapapeles", + "API documentation.": "Documentación de la API.", + "Base doc URL: {{docApiUrl}}": "URL del doc base: {{docApiUrl}}", + "Currency": "Moneda", + "Data Engine": "Motor de datos", + "Formula times": "Tiempo de fórmula", + "Hard reset of data engine": "Reinicio completo del motor de datos", + "ID for API use": "ID para uso de API", + "Locale": "Configuración regional", + "API console": "Consola de la API", + "Coming soon": "Próximamente", + "Copy to clipboard": "Copiar al portapapeles", + "Default for DateTime columns": "Predeterminado para las columnas DateTime", + "Document ID": "ID del documento", + "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID de documento para usar cuando la API REST solicite {{docId}}. Véase {{apiURL}}", + "Find slow formulas": "Encontrar fórmulas lentas", + "For currency columns": "Para columnas de moneda", + "For number and date formats": "Para formatos de número y fecha", + "Manage webhooks": "Administrar los ganchos web", + "Python version used": "Versión Python utilizada", + "Time Zone": "Huso horario", + "Try API calls from the browser": "Pruebe las llamadas API desde el navegador", + "Reload": "Recargar", + "python2 (legacy)": "python2 (legado)", + "Notify other services on doc changes": "Notificar a otros servicios los cambios de documentos", + "Python": "Python", + "python3 (recommended)": "python3 (recomendado)" }, "DuplicateTable": { "Copy all data in addition to the table structure.": "Copiar todos los datos además de la estructura de la tabla.", @@ -503,7 +530,8 @@ }, "OnBoardingPopups": { "Finish": "Finalizar", - "Next": "Siguiente" + "Next": "Siguiente", + "Previous": "Anterior" }, "OpenVideoTour": { "Grist Video Tour": "Recorrido en video de Grist", @@ -986,7 +1014,8 @@ "Don't ask again.": "No preguntes de nuevo.", "Don't show again.": "No vuelvas a mostrarlo.", "Don't show again": "No volver a mostrar", - "Are you sure you want to delete these records?": "¿Seguro que quieres borrar estos registros?" + "Are you sure you want to delete these records?": "¿Seguro que quieres borrar estos registros?", + "TIP": "TIP" }, "pages": { "Duplicate Page": "Duplicar página", @@ -1597,5 +1626,20 @@ }, "DropdownConditionEditor": { "Enter condition.": "Introduzca la condición." + }, + "FormRenderer": { + "Select...": "Seleccione...", + "Reset": "Restablecer", + "Search": "Búsqueda", + "Submit": "Enviar" + }, + "widgetTypesMap": { + "Calendar": "Calendario", + "Card": "Tarjeta", + "Card List": "Lista de tarjetas", + "Chart": "Gráfico", + "Custom": "Personalizado", + "Form": "Formulario", + "Table": "Tabla" } } From 45b51262754e1cd2e71410dc8e71ad64a1030d64 Mon Sep 17 00:00:00 2001 From: Paul Janzen Date: Tue, 21 May 2024 18:57:19 +0000 Subject: [PATCH 25/26] Translated using Weblate (German) Currently translated at 100.0% (1297 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/de/ --- static/locales/de.client.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/locales/de.client.json b/static/locales/de.client.json index a7c03e2a..67fb9665 100644 --- a/static/locales/de.client.json +++ b/static/locales/de.client.json @@ -1642,5 +1642,14 @@ "Submit": "Einreichen", "Search": "Suchen", "Select...": "Wählen Sie..." + }, + "widgetTypesMap": { + "Calendar": "Kalender", + "Card": "Karte", + "Card List": "Kartenliste", + "Form": "Formular", + "Table": "Tabelle", + "Chart": "Diagramm", + "Custom": "Benutzerdefiniert" } } From 53cfc6148dae3421a2f960ac751e5d2d8c22b07b Mon Sep 17 00:00:00 2001 From: Marin Ivanov Date: Tue, 21 May 2024 08:46:43 +0000 Subject: [PATCH 26/26] Translated using Weblate (Bulgarian) Currently translated at 99.1% (1286 of 1297 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/bg/ --- static/locales/bg.client.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/locales/bg.client.json b/static/locales/bg.client.json index 6475052b..7976ab00 100644 --- a/static/locales/bg.client.json +++ b/static/locales/bg.client.json @@ -67,7 +67,7 @@ "Accounts": "Потребители", "Add Account": "Добави потребител", "Document Settings": "Документни настройки", - "Manage Team": "Управлявай отбора", + "Manage Team": "Управлявай екипа", "Pricing": "Ценоразпис", "Profile Settings": "Настройки на профила", "Sign Out": "Изход", @@ -116,7 +116,7 @@ "AppHeader": { "Home Page": "Начална страница", "Legacy": "Вехто", - "Team Site": "Сайт на отбора", + "Team Site": "Сайт на екипа", "Grist Templates": "Grist образци", "Personal Site": "Личен сайт" }, @@ -733,7 +733,7 @@ "Switch appearance automatically to match system": "Превключете външния вид автоматично, за да съответства на системните настройки" }, "AppModel": { - "This team site is suspended. Documents can be read, but not modified.": "Сайтът на отбора е спрян. Документите могат да бъдат четени, но не и променяни." + "This team site is suspended. Documents can be read, but not modified.": "Сайтът на екипа е спрян. Документите могат да бъдат четени, но не и променяни." }, "RecordLayout": { "Updating record layout.": "Обновяване на оформлението на записа."