(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick 2023-03-13 09:27:21 -04:00
commit 186263b4fb
19 changed files with 886 additions and 59 deletions

View File

@ -13,3 +13,4 @@
!plugins !plugins
!test !test
!ext !ext
**/_build

2
.gitignore vendored
View File

@ -73,3 +73,5 @@ jspm_packages/
# dotenv environment variables file # dotenv environment variables file
.env .env
**/_build

View File

@ -246,6 +246,7 @@ GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown b
GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way. GRIST_SUPPORT_EMAIL | if set, give a user with the specified email support powers. The main extra power is the ability to share sites, workspaces, and docs with all users in a listed way.
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_USER_ROOT | an extra path to look for plugins in. GRIST_USER_ROOT | an extra path to look for plugins in.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie
HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port. HOME_PORT | port number to listen on for REST API server; if set to "share", add API endpoints to regular grist port.
PORT | port number to listen on for Grist server PORT | port number to listen on for Grist server

View File

@ -15,7 +15,7 @@ import {
} from 'app/server/lib/SandboxControl'; } from 'app/server/lib/SandboxControl';
import * as sandboxUtil from 'app/server/lib/sandboxUtil'; import * as sandboxUtil from 'app/server/lib/sandboxUtil';
import * as shutdown from 'app/server/lib/shutdown'; import * as shutdown from 'app/server/lib/shutdown';
import {ChildProcess, spawn} from 'child_process'; import {ChildProcess, fork, spawn} from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as path from 'path'; import * as path from 'path';
@ -73,6 +73,8 @@ export interface ISandboxOptions {
interface SandboxProcess { interface SandboxProcess {
child: ChildProcess; child: ChildProcess;
control: ISandboxControl; control: ISandboxControl;
dataToSandboxDescriptor?: number; // override sandbox's 'stdin' for data
dataFromSandboxDescriptor?: number; // override sandbox's 'stdout' for data
} }
type ResolveRejectPair = [(value?: any) => void, (reason?: unknown) => void]; type ResolveRejectPair = [(value?: any) => void, (reason?: unknown) => void];
@ -131,10 +133,23 @@ export class NSandbox implements ISandbox {
if (options.minimalPipeMode) { if (options.minimalPipeMode) {
log.rawDebug("3-pipe Sandbox started", this._logMeta); log.rawDebug("3-pipe Sandbox started", this._logMeta);
this._streamToSandbox = this.childProc.stdin!; if (sandboxProcess.dataToSandboxDescriptor) {
this._streamFromSandbox = this.childProc.stdout!; this._streamToSandbox =
(this.childProc.stdio as Stream[])[sandboxProcess.dataToSandboxDescriptor] as Writable;
} else {
this._streamToSandbox = this.childProc.stdin!;
}
if (sandboxProcess.dataFromSandboxDescriptor) {
this._streamFromSandbox =
(this.childProc.stdio as Stream[])[sandboxProcess.dataFromSandboxDescriptor];
} else {
this._streamFromSandbox = this.childProc.stdout!;
}
} else { } else {
log.rawDebug("5-pipe Sandbox started", this._logMeta); log.rawDebug("5-pipe Sandbox started", this._logMeta);
if (sandboxProcess.dataFromSandboxDescriptor || sandboxProcess.dataToSandboxDescriptor) {
throw new Error('cannot override file descriptors in 5 pipe mode');
}
this._streamToSandbox = (this.childProc.stdio as Stream[])[3] as Writable; this._streamToSandbox = (this.childProc.stdio as Stream[])[3] as Writable;
this._streamFromSandbox = (this.childProc.stdio as Stream[])[4]; this._streamFromSandbox = (this.childProc.stdio as Stream[])[4];
this.childProc.stdout!.on('data', sandboxUtil.makeLinePrefixer('Sandbox stdout: ', this._logMeta)); this.childProc.stdout!.on('data', sandboxUtil.makeLinePrefixer('Sandbox stdout: ', this._logMeta));
@ -206,10 +221,17 @@ export class NSandbox implements ISandbox {
* @param args Arguments to pass to the given function. * @param args Arguments to pass to the given function.
* @returns A promise for the return value from the Python function. * @returns A promise for the return value from the Python function.
*/ */
public pyCall(funcName: string, ...varArgs: unknown[]): Promise<any> { public async pyCall(funcName: string, ...varArgs: unknown[]): Promise<any> {
const startTime = Date.now(); const startTime = Date.now();
this._sendData(sandboxUtil.CALL, Array.from(arguments)); this._sendData(sandboxUtil.CALL, Array.from(arguments));
return this._pyCallWait(funcName, startTime); const slowCallCheck = setTimeout(() => {
// Log calls that take some time, can be a useful symptom of misconfiguration
// (or just benign if the doc is big).
log.rawWarn('Slow pyCall', {...this._logMeta, funcName});
}, 10000);
const result = await this._pyCallWait(funcName, startTime);
clearTimeout(slowCallCheck);
return result;
} }
/** /**
@ -371,6 +393,7 @@ const spawners = {
docker, // Run sandboxes in distinct docker containers. docker, // Run sandboxes in distinct docker containers.
gvisor, // Gvisor's runsc sandbox. gvisor, // Gvisor's runsc sandbox.
macSandboxExec, // Use "sandbox-exec" on Mac. macSandboxExec, // Use "sandbox-exec" on Mac.
pyodide, // Run data engine using pyodide.
}; };
function isFlavor(flavor: string): flavor is keyof typeof spawners { function isFlavor(flavor: string): flavor is keyof typeof spawners {
@ -528,6 +551,44 @@ function unsandboxed(options: ISandboxOptions): SandboxProcess {
return {child, control: new DirectProcessControl(child, options.logMeta)}; return {child, control: new DirectProcessControl(child, options.logMeta)};
} }
function pyodide(options: ISandboxOptions): SandboxProcess {
const paths = getAbsolutePaths(options);
// We will fork with three regular pipes (stdin, stdout, stderr), then
// ipc (mandatory for calling fork), and a replacement pipe for stdin
// and for stdout.
// The regular stdin always opens non-blocking in node, which is a pain
// in this case, so we just use a different pipe. There's a different
// problem with stdout, with the same solution.
const spawnOptions = {
stdio: ['ignore', 'ignore', 'pipe', 'ipc', 'pipe', 'pipe'] as Array<'pipe'|'ipc'>,
env: {
PYTHONPATH: paths.engine,
IMPORTDIR: options.importDir,
...getInsertedEnv(options),
...getWrappingEnv(options),
}
};
const base = getUnpackedAppRoot();
const child = fork(path.join(base, 'sandbox', 'pyodide', 'pipe.js'),
{cwd: path.join(process.cwd(), 'sandbox'), ...spawnOptions});
return {
child,
control: new DirectProcessControl(child, options.logMeta),
dataToSandboxDescriptor: 4, // Cannot use normal descriptor, node
// makes it non-blocking. Can be worked around in linux and osx, but
// for windows just using a different file descriptor seems simplest.
// In the sandbox, calling async methods from emscripten code is
// possible but would require more changes to the data engine code
// than seems reasonable at this time. The top level sandbox.run
// can be tweaked to step operations, which actually works for a
// lot of things, but not for cases where the sandbox calls back
// into node (e.g. for column type guessing). TLDR: just switching
// to FD 4 and reading synchronously is more practical solution.
dataFromSandboxDescriptor: 5, // There's an equally long but different
// story about why stdout is a bit messed up under pyodide right now.
};
}
/** /**
* Helper function to run python in gvisor's runsc, with multiple * Helper function to run python in gvisor's runsc, with multiple
* sandboxes run within the same container. GRIST_SANDBOX should * sandboxes run within the same container. GRIST_SANDBOX should

View File

@ -14,6 +14,32 @@ import marshal
import sys import sys
import traceback import traceback
class CarefulReader(object):
"""
Wrap a pipe when reading from Pyodide, to work around marshaling
panicking if fewer bytes are read in a block than it was expecting.
Just wait for more.
"""
def __init__(self, file_):
self._file = file_
def write(self, data):
return self._file.write(data)
def read(self, size):
return self._file.read(size)
def readinto(self, b):
result = self._file.readinto(b)
while result is not None and result < len(b):
bview = memoryview(b)
result += self._file.readinto(bview[result:])
return result
def __getattr__(self, attr):
return getattr(self._file, attr)
def log(msg): def log(msg):
sys.stderr.write(str(msg) + "\n") sys.stderr.write(str(msg) + "\n")
sys.stderr.flush() sys.stderr.flush()
@ -23,22 +49,25 @@ class Sandbox(object):
This class works in conjunction with Sandbox.js to allow function calls This class works in conjunction with Sandbox.js to allow function calls
between the Node process and this sandbox. between the Node process and this sandbox.
The sandbox provides two pipes (on fds 3 and 4) to send data to and from the sandboxed The sandbox provides two pipes to send data to and from the sandboxed
process. Data on these is serialized using `marshal` module. All messages are comprised of a process. Data on these is serialized using `marshal` module. All messages are comprised of a
msgCode followed immediatedly by msgBody, with the following msgCodes: msgCode followed immediatedly by msgBody, with the following msgCodes:
CALL = call to the other side. The data must be an array of [func_name, arguments...] CALL = call to the other side. The data must be an array of [func_name, arguments...]
DATA = data must be a value to return to a call from the other side DATA = data must be a value to return to a call from the other side
EXC = data must be an exception to return to a call from the other side EXC = data must be an exception to return to a call from the other side
Optionally, a callback can be supplied instead of an output pipe.
""" """
CALL = None CALL = None
DATA = True DATA = True
EXC = False EXC = False
def __init__(self, external_input, external_output): def __init__(self, external_input, external_output, external_output_method=None):
self._functions = {} self._functions = {}
self._external_input = external_input self._external_input = external_input
self._external_output = external_output self._external_output = external_output
self._external_output_method = external_output_method
@classmethod @classmethod
def connected_to_js_pipes(cls): def connected_to_js_pipes(cls):
@ -62,6 +91,14 @@ class Sandbox(object):
sys.stdout = sys.stderr sys.stdout = sys.stderr
return Sandbox.connected_to_js_pipes() return Sandbox.connected_to_js_pipes()
@classmethod
def use_pyodide(cls):
import js # Get pyodide object.
external_input = CarefulReader(sys.stdin.buffer)
external_output_method = lambda data: js.sendFromSandbox(data)
sys.stdout = sys.stderr
return cls(external_input, None, external_output_method)
def _send_to_js(self, msgCode, msgBody): def _send_to_js(self, msgCode, msgBody):
# (Note that marshal version 2 is the default; we specify it explicitly for clarity. The # (Note that marshal version 2 is the default; we specify it explicitly for clarity. The
# difference with version 0 is that version 2 uses a faster binary format for floats.) # difference with version 0 is that version 2 uses a faster binary format for floats.)
@ -70,8 +107,14 @@ class Sandbox(object):
# It's much better to ensure the whole blob is sent as one write. We marshal the resulting # It's much better to ensure the whole blob is sent as one write. We marshal the resulting
# buffer again so that the reader can quickly tell how many bytes to expect. # buffer again so that the reader can quickly tell how many bytes to expect.
buf = marshal.dumps((msgCode, msgBody), 2) buf = marshal.dumps((msgCode, msgBody), 2)
marshal.dump(buf, self._external_output, 2) if self._external_output:
self._external_output.flush() marshal.dump(buf, self._external_output, 2)
self._external_output.flush()
elif self._external_output_method:
buf = marshal.dumps(buf, 2)
self._external_output_method(buf)
else:
raise Exception('no data output method')
def call_external(self, name, *args): def call_external(self, name, *args):
self._send_to_js(Sandbox.CALL, (name,) + args) self._send_to_js(Sandbox.CALL, (name,) + args)
@ -115,6 +158,8 @@ def get_default_sandbox():
if default_sandbox is None: if default_sandbox is None:
if os.environ.get('PIPE_MODE') == 'minimal': if os.environ.get('PIPE_MODE') == 'minimal':
default_sandbox = Sandbox.use_common_pipes() default_sandbox = Sandbox.use_common_pipes()
elif os.environ.get('PIPE_MODE') == 'pyodide':
default_sandbox = Sandbox.use_pyodide()
else: else:
default_sandbox = Sandbox.connected_to_js_pipes() default_sandbox = Sandbox.connected_to_js_pipes()
return default_sandbox return default_sandbox

View File

@ -189,23 +189,21 @@ class TestRenames2(test_engine.EngineTestCase):
def test_renames_b(self): def test_renames_b(self):
# Rename Games.name: affects People.Games_Won, Games.win4_game_name # Rename Games.name: affects People.Games_Won, Games.win4_game_name
# TODO: win4_game_name isn't updated due to astroid avoidance of looking up the same attr on
# the same class during inference.
out_actions = self.apply_user_action(["RenameColumn", "Games", "name", "nombre"]) out_actions = self.apply_user_action(["RenameColumn", "Games", "name", "nombre"])
self.assertPartialOutActions(out_actions, { "stored": [ self.assertPartialOutActions(out_actions, { "stored": [
["RenameColumn", "Games", "name", "nombre"], ["RenameColumn", "Games", "name", "nombre"],
["ModifyColumn", "People", "Games_Won", { ["ModifyColumn", "People", "Games_Won", {
"formula": "' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))" "formula": "' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))"
}], }],
["BulkUpdateRecord", "_grist_Tables_column", [4, 12], { ["ModifyColumn", "Games", "win4_game_name", {"formula": "$win.win.win.win.nombre"}],
"colId": ["nombre", "Games_Won"], ["BulkUpdateRecord", "_grist_Tables_column", [4, 12, 19], {
"colId": ["nombre", "Games_Won", "win4_game_name"],
"formula": [ "formula": [
"", "' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))"] "",
}], "' '.join(e.game.nombre for e in Entries.lookupRecords(person=$id, rank=1))",
["BulkUpdateRecord", "Games", [1, 2, 3, 4], { "$win.win.win.win.nombre"
"win4_game_name": [["E", "AttributeError"], ["E", "AttributeError"], ]
["E", "AttributeError"], ["E", "AttributeError"]] }]
}],
]}) ]})
# Fix up things missed due to the TODOs above. # Fix up things missed due to the TODOs above.
@ -264,22 +262,16 @@ class TestRenames2(test_engine.EngineTestCase):
def test_renames_d(self): def test_renames_d(self):
# Rename People.name: affects People.N, People.ParnerNames # Rename People.name: affects People.N, People.ParnerNames
# TODO: win3_person_name ($win.win.win.name) does NOT get updated correctly with astroid
# because of a limitation in astroid inference: it refuses to look up the same attr on the
# same class during inference (in order to protect against too much recursion).
# TODO: PartnerNames does NOT get updated correctly because astroid doesn't infer meanings of # TODO: PartnerNames does NOT get updated correctly because astroid doesn't infer meanings of
# lists very well. # lists very well.
out_actions = self.apply_user_action(["RenameColumn", "People", "name", "nombre"]) out_actions = self.apply_user_action(["RenameColumn", "People", "name", "nombre"])
self.assertPartialOutActions(out_actions, { "stored": [ self.assertPartialOutActions(out_actions, { "stored": [
["RenameColumn", "People", "name", "nombre"], ["RenameColumn", "People", "name", "nombre"],
["ModifyColumn", "People", "N", {"formula": "$nombre.upper()"}], ["ModifyColumn", "People", "N", {"formula": "$nombre.upper()"}],
["BulkUpdateRecord", "_grist_Tables_column", [2, 11], { ["ModifyColumn", "Games", "win3_person_name", {"formula": "$win.win.win.nombre"}],
"colId": ["nombre", "N"], ["BulkUpdateRecord", "_grist_Tables_column", [2, 11, 18], {
"formula": ["", "$nombre.upper()"] "colId": ["nombre", "N", "win3_person_name"],
}], "formula": ["", "$nombre.upper()", "$win.win.win.nombre"]
["BulkUpdateRecord", "Games", [1, 2, 3, 4], {
"win3_person_name": [["E", "AttributeError"], ["E", "AttributeError"],
["E", "AttributeError"], ["E", "AttributeError"]]
}], }],
["BulkUpdateRecord", "People", [1, 2, 3, 4, 5], { ["BulkUpdateRecord", "People", [1, 2, 3, 4, 5], {
"PartnerNames": [["E", "AttributeError"], ["E", "AttributeError"], "PartnerNames": [["E", "AttributeError"], ["E", "AttributeError"],
@ -287,8 +279,7 @@ class TestRenames2(test_engine.EngineTestCase):
}], }],
]}) ]})
# Fix up things missed due to the TODOs above. # Fix up things missed due to the TODO above.
self.modify_column("Games", "win3_person_name", formula="$win.win.win.nombre")
self.modify_column("People", "PartnerNames", self.modify_column("People", "PartnerNames",
formula=self.partner_names.replace("name", "nombre")) formula=self.partner_names.replace("name", "nombre"))
@ -305,21 +296,14 @@ class TestRenames2(test_engine.EngineTestCase):
self.assertPartialOutActions(out_actions, { "stored": [ self.assertPartialOutActions(out_actions, { "stored": [
["RenameColumn", "People", "partner", "companero"], ["RenameColumn", "People", "partner", "companero"],
["ModifyColumn", "People", "partner4", { ["ModifyColumn", "People", "partner4", {
"formula": "$companero.companero.partner.partner" "formula": "$companero.companero.companero.companero"
}], }],
["BulkUpdateRecord", "_grist_Tables_column", [14, 15], { ["BulkUpdateRecord", "_grist_Tables_column", [14, 15], {
"colId": ["companero", "partner4"], "colId": ["companero", "partner4"],
"formula": [self.partner, "$companero.companero.partner.partner"] "formula": [self.partner, "$companero.companero.companero.companero"]
}], }]
["BulkUpdateRecord", "People", [1, 2, 3, 4, 5], {
"partner4": [["E", "AttributeError"], ["E", "AttributeError"],
["E", "AttributeError"], ["E", "AttributeError"], ["E", "AttributeError"]]
}],
]}) ]})
# Fix up things missed due to the TODOs above.
self.modify_column("People", "partner4", formula="$companero.companero.companero.companero")
_replace_col_name(self.people_data, "partner", "companero") _replace_col_name(self.people_data, "partner", "companero")
self.assertTableData("People", cols="subset", data=self.people_data) self.assertTableData("People", cols="subset", data=self.people_data)
self.assertTableData("Games", cols="subset", data=self.games_data) self.assertTableData("Games", cols="subset", data=self.games_data)
@ -331,21 +315,13 @@ class TestRenames2(test_engine.EngineTestCase):
self.assertPartialOutActions(out_actions, { "stored": [ self.assertPartialOutActions(out_actions, { "stored": [
["RenameColumn", "People", "win", "pwin"], ["RenameColumn", "People", "win", "pwin"],
["ModifyColumn", "Games", "win3_person_name", {"formula": "$win.pwin.win.name"}], ["ModifyColumn", "Games", "win3_person_name", {"formula": "$win.pwin.win.name"}],
# TODO: the omission of the 4th win's update is due to the same astroid bug mentioned above. ["ModifyColumn", "Games", "win4_game_name", {"formula": "$win.pwin.win.pwin.name"}],
["ModifyColumn", "Games", "win4_game_name", {"formula": "$win.pwin.win.win.name"}],
["BulkUpdateRecord", "_grist_Tables_column", [16, 18, 19], { ["BulkUpdateRecord", "_grist_Tables_column", [16, 18, 19], {
"colId": ["pwin", "win3_person_name", "win4_game_name"], "colId": ["pwin", "win3_person_name", "win4_game_name"],
"formula": ["Entries.lookupOne(person=$id, rank=1).game", "formula": ["Entries.lookupOne(person=$id, rank=1).game",
"$win.pwin.win.name", "$win.pwin.win.win.name"]}], "$win.pwin.win.name", "$win.pwin.win.pwin.name"]}],
["BulkUpdateRecord", "Games", [1, 2, 3, 4], {
"win4_game_name": [["E", "AttributeError"], ["E", "AttributeError"],
["E", "AttributeError"], ["E", "AttributeError"]]
}],
]}) ]})
# Fix up things missed due to the TODOs above.
self.modify_column("Games", "win4_game_name", formula="$win.pwin.win.pwin.name")
_replace_col_name(self.people_data, "win", "pwin") _replace_col_name(self.people_data, "win", "pwin")
self.assertTableData("People", cols="subset", data=self.people_data) self.assertTableData("People", cols="subset", data=self.people_data)
self.assertTableData("Games", cols="subset", data=self.games_data) self.assertTableData("Games", cols="subset", data=self.games_data)

19
sandbox/pyodide/Makefile Normal file
View File

@ -0,0 +1,19 @@
default:
echo "Welcome to the pyodide sandbox"
echo "make fetch_packages # gets python packages prepared earlier"
echo "make build_packages # build python packages from scratch"
echo "make save_packages # upload python packages to fetch later"
echo "setup # get pyodide node package"
fetch_packages:
node ./packages.js https://s3.amazonaws.com/grist-pynbox/pyodide/packages/ _build/packages/
build_packages:
./build_packages.sh
save_packages:
aws s3 sync _build/packages s3://grist-pynbox/pyodide/packages/
setup:
./setup.sh
make fetch_packages

28
sandbox/pyodide/README.md Normal file
View File

@ -0,0 +1,28 @@
This is a collection of scripts for running a pyodide-based "sandbox" for
Grist.
I put "sandbox" in quotes since pyodide isn't built with sandboxing
in mind. It was written to run in a browser, where the browser does
sandboxing. I don't know how much of node's API ends up being exposed
to the "sandbox" - in previous versions of pyodide it seems the answer is
"a lot". See the back-and-forth between dalcde and hoodmane in:
https://github.com/pyodide/pyodide/issues/960
See specifically:
https://github.com/pyodide/pyodide/issues/960#issuecomment-752305257
I looked at hiwire and its treatment of js globals has changed a
lot. On the surface it looks like there is good control of what is
exposed, but there may be other routes.
Still, some wasm-based solution is likely to be helpful, whether from
pyodide or elsewhere, and this is good practice for that.
***
To run, we need specific versions of the Python packages that Grist uses
to be prepared. It should suffice to do:
```
make setup
```
In this directory. See the `Makefile` for other options.

View File

@ -0,0 +1,30 @@
#!/bin/bash
set -e
echo ""
echo "###############################################################"
echo "## Get pyodide repository, for transpiling python packages"
if [[ ! -e _build/pyodide ]]; then
cd _build
git clone https://github.com/pyodide/pyodide
cd ..
fi
echo ""
echo "###############################################################"
echo "## Prepare python packages"
cd _build/pyodide
./run_docker make
cp ../../../requirements3.txt .
./run_docker pyodide build -r requirements3.txt --output-lockfile result.txt
cat result.txt
cd ../..
echo ""
echo "###############################################################"
echo "## Copy out python packages"
node ./packages.js _build/pyodide/dist/ _build/packages/

117
sandbox/pyodide/packages.js Normal file
View File

@ -0,0 +1,117 @@
const path = require('path');
const fs = require('fs');
const fetch = require('node-fetch');
async function listLibs(src) {
const txt = fs.readFileSync(path.join(__dirname, '..', 'requirements3.txt'), 'utf8');
const libs = {};
for (const line of txt.split(/\r?\n/)) {
const raw = line.split('#')[0];
if (!raw.includes('==')) { continue; }
const [name, version] = line.split('==');
libs[name] = version;
}
const hits = [];
const misses = [];
const toLoad = [];
const material = fs.readdirSync(src);
for (const [lib, version] of Object.entries(libs)) {
const nlib = lib.replace(/-/g, '_');
const info = {
name: lib,
standardName: nlib,
version: version,
}
try {
const found = material.filter(m => m.startsWith(`${nlib}-${version}-`));
if (found.length !== 1) {
throw new Error('did not find 1');
}
const fname = found[0];
info.fullName = path.join(src, fname);
info.fileName = fname;
toLoad.push(info);
hits.push(lib);
} catch (e) {
misses.push(info);
}
}
return {
available: toLoad,
misses,
};
}
exports.listLibs = listLibs;
async function findOnDisk(src, dest) {
console.log(`Organizing packages on disk`, {src, dest});
fs.mkdirSync(dest, {recursive: true});
let libs = (await listLibs(src));
for (const lib of libs.available) {
fs.copyFileSync(lib.fullName, path.join(dest, lib.fileName));
fs.writeFileSync(path.join(dest, `${lib.name}-${lib.version}.json`),
JSON.stringify({
name: lib.name,
version: lib.version,
fileName: lib.fileName,
}, null, 2));
console.log("Copied", {
content: path.join(dest, lib.fileName),
meta: path.join(dest, `${lib.name}-${lib.version}.json`),
});
}
libs = await listLibs(dest);
console.log(`Cached`, {libs: libs.available.map(lib => lib.name)});
console.log(`Missing`, {libs: libs.misses.map(lib => lib.name)});
}
async function findOnNet(src, dest) {
console.log(`Caching packages on disk`, {src, dest});
fs.mkdirSync(dest, {recursive: true});
let libs = await listLibs(dest);
console.log(`Cached`, {libs: libs.available.map(lib => lib.name)});
for (const lib of libs.misses) {
console.log('Fetching', lib);
const url = new URL(src);
url.pathname = url.pathname + lib.name + '-' + lib.version + '.json';
const result = await fetch(url.href);
if (result.status === 200) {
const data = await result.json();
const url2 = new URL(src);
url2.pathname = url2.pathname + data.fileName;
const result2 = await fetch(url2.href);
if (result2.status === 200) {
fs.writeFileSync(path.join(dest, `${lib.name}-${lib.version}.json`),
JSON.stringify(data, null, 2));
fs.writeFileSync(path.join(dest, data.fileName),
await result2.buffer());
} else {
console.error("No payload available", {lib});
}
} else {
console.error("No metadata available", {lib});
}
}
libs = await listLibs(dest);
console.log(`Missing`, {libs: libs.misses.map(lib => lib.name)});
}
async function main(src, dest) {
if (!src) {
console.error('please supply a source');
process.exit(1);
}
if (!dest) {
console.error('please supply a destination');
process.exit(1);
}
if (src.startsWith('http:') || src.startsWith('https:')) {
await findOnNet(src, dest);
return;
}
await findOnDisk(src, dest);
}
if (require.main === module) {
main(...process.argv.slice(2)).catch(e => console.error(e));
}

147
sandbox/pyodide/pipe.js Normal file
View File

@ -0,0 +1,147 @@
const path = require('path');
const fs = require('fs');
const { loadPyodide } = require('./_build/worker/node_modules/pyodide');
const { listLibs } = require('./packages');
const INCOMING_FD = 4;
const OUTGOING_FD = 5;
class GristPipe {
constructor() {
this.pyodide = null;
this.incomingBuffer = Buffer.alloc(65536);
this.addedBlob = false;
this.adminMode = false;
}
async init() {
const self = this;
this.setAdminMode(true);
this.pyodide = await loadPyodide({
jsglobals: {
Object: {},
setTimeout: function(code, delay) {
if (self.adminMode) {
setTimeout(code, delay);
// Seems to be OK not to return anything, so we don't.
} else {
throw new Error('setTimeout not available');
}
},
sendFromSandbox: (data) => {
return fs.writeSync(OUTGOING_FD, Buffer.from(data.toJs()));
}
},
});
this.setAdminMode(false);
this.pyodide.setStdin({
stdin: () => {
const result = fs.readSync(INCOMING_FD, this.incomingBuffer, 0,
this.incomingBuffer.byteLength);
if (result > 0) {
const buf = Buffer.allocUnsafe(result, 0, 0, result);
this.incomingBuffer.copy(buf);
return buf;
}
return null;
},
});
this.pyodide.setStderr({
batched: (data) => {
this.log("[py]", data);
}
});
}
async loadCode() {
// Load python packages.
const src = path.join(__dirname, '_build', 'packages');
const lsty = (await listLibs(src)).available.map(item => item.fullName);
await this.pyodide.loadPackage(lsty, {
messageCallback: (msg) => this.log('[package]', msg),
});
// Load Grist data engine code.
// We mount it as /grist_src, copy to /grist, then unmount.
// Note that path to source must be a realpath.
const root = fs.realpathSync(path.join(__dirname, '../grist'));
await this.pyodide.FS.mkdir("/grist_src");
// careful, needs to be a realpath
await this.pyodide.FS.mount(this.pyodide.FS.filesystems.NODEFS, { root }, "/grist_src");
// Now want to copy /grist_src to /grist.
// For some reason shutil.copytree doesn't work on Windows in this situation, so
// we reimplement it crudely.
await this.pyodide.runPython(`
import os, shutil
def copytree(src, dst):
os.makedirs(dst, exist_ok=True)
for item in os.listdir(src):
s = os.path.join(src, item)
d = os.path.join(dst, item)
if os.path.isdir(s):
copytree(s, d)
else:
shutil.copy2(s, d)
copytree('/grist_src', '/grist')`);
await this.pyodide.FS.unmount("/grist_src");
await this.pyodide.FS.rmdir("/grist_src");
}
async mountImportDirIfNeeded() {
if (process.env.IMPORTDIR) {
this.log("Setting up import from", process.env.IMPORTDIR);
// Ideally would be read-only; don't see a way to do that,
// other than copying like for Grist code.
await this.pyodide.FS.mkdir("/import");
await this.pyodide.FS.mount(this.pyodide.FS.filesystems.NODEFS, {
root: process.env.IMPORTDIR,
}, "/import");
}
}
async runCode() {
await this.pyodide.runPython(`
import sys
sys.path.append('/')
sys.path.append('/grist')
import grist
import main
import os
os.environ['PIPE_MODE'] = 'pyodide'
os.environ['IMPORTDIR'] = '/import'
main.main()
`);
}
setAdminMode(active) {
this.adminMode = active;
// Lack of Blob may result in a message on console.log that hurts us.
if (active && !globalThis.Blob) {
globalThis.Blob = String;
this.addedBlob = true;
}
if (!active && this.addedBlob) {
delete globalThis.Blob;
this.addedBlob = false;
}
}
log(...args) {
console.error("[pyodide sandbox]", ...args);
}
}
async function main() {
try {
const pipe = new GristPipe();
await pipe.init();
await pipe.loadCode();
await pipe.mountImportDirIfNeeded();
await pipe.runCode();
} finally {
process.stdin.removeAllListeners();
}
}
main().catch(err => console.error("[pyodide error]", err));

15
sandbox/pyodide/setup.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
set -e
echo ""
echo "###############################################################"
echo "## Get pyodide node package"
if [[ ! -e _build/worker ]]; then
mkdir -p _build/worker
cd _build/worker
yarn init --yes
yarn add pyodide@0.22.1
cd ../..
fi

View File

@ -12,7 +12,11 @@ jdcal==1.4.1
et-xmlfile==1.0.1 et-xmlfile==1.0.1
# Different astroid version for python 3 # Different astroid version for python 3
astroid==2.5.7 astroid==2.14.2
typing_extensions==4.4.0
# Different roman version for python 3
roman==3.3
# Everything after this is the same for python 2 and 3 # Everything after this is the same for python 2 and 3
asttokens==2.0.5 asttokens==2.0.5
@ -23,7 +27,6 @@ iso8601==0.1.12
lazy_object_proxy==1.6.0 lazy_object_proxy==1.6.0
phonenumberslite==8.12.57 phonenumberslite==8.12.57
python_dateutil==2.8.2 python_dateutil==2.8.2
roman==2.0.0
singledispatch==3.6.2 singledispatch==3.6.2
six==1.16.0 six==1.16.0
sortedcontainers==2.4.0 sortedcontainers==2.4.0

View File

@ -723,7 +723,9 @@
"Open configuration": "Konfiguration öffnen", "Open configuration": "Konfiguration öffnen",
"Print widget": "Widget drucken", "Print widget": "Widget drucken",
"Show raw data": "Rohdaten anzeigen", "Show raw data": "Rohdaten anzeigen",
"Widget options": "Widget Optionen" "Widget options": "Widget Optionen",
"Add to page": "Zur Seite hinzufügen",
"Collapse widget": "Widget einklappen"
}, },
"ViewSectionMenu": { "ViewSectionMenu": {
"(customized)": "(angepasst)", "(customized)": "(angepasst)",
@ -1021,5 +1023,8 @@
"entire": "gesamte", "entire": "gesamte",
"Access Rules": "Zugriffsregeln", "Access Rules": "Zugriffsregeln",
"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Zugriffsregeln geben Ihnen die Möglichkeit, nuancierte Regeln zu erstellen, um festzulegen, wer welche Teile Ihres Dokuments sehen oder bearbeiten kann." "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Zugriffsregeln geben Ihnen die Möglichkeit, nuancierte Regeln zu erstellen, um festzulegen, wer welche Teile Ihres Dokuments sehen oder bearbeiten kann."
},
"DescriptionConfig": {
"DESCRIPTION": "BESCHREIBUNG"
} }
} }

View File

@ -671,7 +671,9 @@
"Open configuration": "Open configuration", "Open configuration": "Open configuration",
"Print widget": "Print widget", "Print widget": "Print widget",
"Show raw data": "Show raw data", "Show raw data": "Show raw data",
"Widget options": "Widget options" "Widget options": "Widget options",
"Add to page": "Add to page",
"Collapse widget": "Collapse widget"
}, },
"ViewSectionMenu": { "ViewSectionMenu": {
"(customized)": "(customized)", "(customized)": "(customized)",
@ -957,5 +959,8 @@
"Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.", "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.": "Access rules give you the power to create nuanced rules to determine who can see or edit which parts of your document.",
"Add New": "Add New", "Add New": "Add New",
"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals." "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."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPTION"
} }
} }

View File

@ -580,7 +580,9 @@
"Open configuration": "Abrir configuración", "Open configuration": "Abrir configuración",
"Print widget": "Imprimir widget", "Print widget": "Imprimir widget",
"Show raw data": "Mostrar datos brutos", "Show raw data": "Mostrar datos brutos",
"Widget options": "Opciones de Widget" "Widget options": "Opciones de Widget",
"Collapse widget": "Ocultar el widget",
"Add to page": "Añadir a la página"
}, },
"ViewSectionMenu": { "ViewSectionMenu": {
"(customized)": "(personalizado)", "(customized)": "(personalizado)",
@ -1011,5 +1013,8 @@
"Access Rules": "Reglas de acceso", "Access Rules": "Reglas de acceso",
"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Utilice el icono 𝚺 para crear tablas resumen (o dinámicas), para totales o subtotales.", "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Utilice el icono 𝚺 para crear tablas resumen (o dinámicas), para totales o subtotales.",
"Unpin to hide the the button while keeping the filter.": "Desancla para ocultar el botón mientras mantienes el filtro." "Unpin to hide the the button while keeping the filter.": "Desancla para ocultar el botón mientras mantienes el filtro."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIPCIÓN"
} }
} }

View File

@ -457,9 +457,371 @@
"Ask for help": "Zapytaj o pomoc", "Ask for help": "Zapytaj o pomoc",
"Cannot find personal site, sorry!": "Nie mogę znaleźć osobistej strony, przepraszam!", "Cannot find personal site, sorry!": "Nie mogę znaleźć osobistej strony, przepraszam!",
"Give feedback": "Przekaż opinię", "Give feedback": "Przekaż opinię",
"Notifications": "Powiadomienia" "Notifications": "Powiadomienia",
"Upgrade Plan": "Plan aktualizacji",
"Renew": "Odnów",
"Report a problem": "Zgłoś problem"
}, },
"LeftPanelCommon": { "LeftPanelCommon": {
"Help Center": "Centrum pomocy" "Help Center": "Centrum pomocy"
},
"PageWidgetPicker": {
"Add to Page": "Dodaj do strony",
"Select Widget": "Wybierz widżet",
"Group by": "Grupuj według",
"Building {{- label}} widget": "Budowa widżetu {{- label}}",
"Select Data": "Wybierz Dane"
},
"OpenVideoTour": {
"Grist Video Tour": "Wideo Przewodnik Grist",
"YouTube video player": "Odtwarzacz wideo YouTube",
"Video Tour": "Prezentacja wideo"
},
"Pages": {
"Delete": "Usunąć",
"Delete data and this page.": "Usuń dane i tę stronę.",
"The following tables will no longer be visible_one": "Poniższa tabela nie będzie już widoczna",
"The following tables will no longer be visible_other": "Poniższa tabela nie będzie już widoczna"
},
"PermissionsWidget": {
"Allow All": "Pozwól wszystkim",
"Deny All": "Odmówić wszystkim",
"Read Only": "Tylko do odczytu"
},
"RecordLayoutEditor": {
"Cancel": "Anuluj",
"Add Field": "Dodaj pole",
"Show field {{- label}}": "Pokaż pole {{- label}}",
"Save Layout": "Zapisz układ",
"Create New Field": "Utwórz nowe pole"
},
"RefSelect": {
"Add Column": "Dodaj kolumnę",
"No columns to add": "Brak kolumn do dodania"
},
"RightPanel": {
"Data": "Dane",
"Detach": "Odłączyć",
"Fields_other": "Pola",
"SOURCE DATA": "DANE ŹRÓDŁOWE",
"Select Widget": "Wybierz widżet",
"CUSTOM": "NIESTANDARDOWE",
"Save": "Zapisz",
"Widget": "Widżet",
"Columns_other": "Kolumny",
"Columns_one": "Kolumna",
"DATA TABLE": "TABELA DANYCH",
"DATA TABLE NAME": "NAZWA TABELI DANYCH",
"GROUPED BY": "POGRUPOWANE WEDŁUG",
"ROW STYLE": "STYL WIERSZA",
"Row Style": "Styl wiersza",
"SELECT BY": "WYBIERZ WEDŁUG",
"TRANSFORM": "PRZEKSZTAŁCAĆ",
"Theme": "Motyw",
"WIDGET TITLE": "TYTUŁ WIDŻETU",
"Series_one": "Seria",
"CHART TYPE": "TYP WYKRESU",
"COLUMN TYPE": "TYP KOLUMNY",
"Change Widget": "Zmień widżet",
"Edit Data Selection": "Edytuj zaznaczenie danych",
"Fields_one": "Pole",
"SELECTOR FOR": "WYBÓR DLA",
"Sort & Filter": "Sortowanie i filtrowanie",
"Series_other": "Seria",
"You do not have edit access to this document": "nie masz uprawnień do edytowania tego dokumentu"
},
"RowContextMenu": {
"Delete": "Usuń",
"Insert row": "Wstawić wiersz",
"Insert row above": "Wstawić wiersz powyżej",
"Insert row below": "Wstawić wiersz poniżej",
"Copy anchor link": "Kopiowanie łącza zakotwiczenia",
"Duplicate rows_one": "Duplikuj wiersz",
"Duplicate rows_other": "Duplikuj wiersze"
},
"ShareMenu": {
"Back to Current": "Powrót do aktualnego",
"Download": "Pobierz",
"Send to Google Drive": "Wyślij na Dysk Google",
"Work on a Copy": "Praca nad kopią",
"Edit without affecting the original": "Edycja bez wpływu na oryginał",
"Duplicate Document": "Duplikat dokumentu",
"Show in folder": "Pokaż w folderze",
"Unsaved": "Niezapisane",
"Access Details": "Szczegóły dostępu",
"Original": "Oryginał",
"Replace {{termToUse}}...": "Zastąp {{termToUse}}…",
"Return to {{termToUse}}": "Wróć do {{termToUse}}",
"Save Copy": "Zapisz kopię",
"Save Document": "Zapisz dokument",
"Compare to {{termToUse}}": "Porównaj z {{termToUse}}",
"Export CSV": "Eksportuj plik CSV",
"Current Version": "Obecna wersja",
"Export XLSX": "Eksportuj XLSX",
"Manage Users": "Zarządzanie użytkownikami"
},
"SiteSwitcher": {
"Switch Sites": "Przełączanie witryn",
"Create new team site": "Utwórz nową witrynę zespołu"
},
"SortConfig": {
"Add Column": "Dodaj kolumnę",
"Empty values last": "Puste wartości jako ostatnie",
"Natural sort": "Naturalne sortowanie",
"Update Data": "Aktualizowanie danych",
"Search Columns": "Wyszukaj kolumny",
"Use choice position": "Użyj pozycji wyboru"
},
"Tools": {
"Document History": "Historia dokumentu",
"Return to viewing as yourself": "Powrót do oglądania jako Ty",
"Access Rules": "Zasady dostępu",
"Code View": "Widok kodu",
"Delete": "Usuń",
"Delete document tour?": "Usunąć wycieczkę po dokumencie?",
"Raw Data": "Surowe dane",
"Validate Data": "Sprawdzanie poprawności danych",
"TOOLS": "NARZĘDZIA",
"Settings": "Ustawienia",
"How-to Tutorial": "Samouczek",
"Tour of this Document": "Przewodnik po tym dokumencie"
},
"TriggerFormulas": {
"Apply on changes to:": "Zastosuj w przypadku zmian do:",
"Apply on record changes": "Zastosuj zmiany w rekordzie",
"Cancel": "Anuluj",
"Any field": "Dowolne pole",
"Close": "Zamknij",
"OK": "OK",
"Current field ": "Bieżące pole ",
"Apply to new records": "Zastosuj do nowych rekordów"
},
"TypeTransformation": {
"Apply": "Zastosuj",
"Cancel": "Anuluj",
"Preview": "Podgląd",
"Revise": "Zrewidować",
"Update formula (Shift+Enter)": "Aktualizacja formuły (Shift+Enter)"
},
"UserManagerModel": {
"Editor": "Edytor",
"View & Edit": "Wyświetlanie i edytowanie",
"None": "Żaden",
"In Full": "W pełni",
"No Default Access": "Brak dostępu domyślnego",
"Owner": "Właściciel",
"View Only": "Tylko widok",
"Viewer": "Przeglądarka"
},
"ValidationPanel": {
"Update formula (Shift+Enter)": "Aktualizuj formułę (Shift+Enter)",
"Rule {{length}}": "Zasada {{length}}"
},
"ViewConfigTab": {
"Make On-Demand": "Twórz na żądanie",
"Plugin: ": "Wtyczka: ",
"Advanced settings": "Ustawienia zaawansowane",
"Blocks": "Bloki",
"Compact": "Kompaktowy",
"Form": "Formularz",
"Edit Card Layout": "Edytuj układ karty",
"Section: ": "Sekcja: ",
"Unmark On-Demand": "Odznacz opcję Na żądanie",
"Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.": "Duże tabele mogą być oznaczone jako \"na żądanie\", aby uniknąć ładowania ich do silnika danych."
},
"ViewLayoutMenu": {
"Copy anchor link": "Kopiowanie łącza zakotwiczenia",
"Data selection": "Wybór danych",
"Delete record": "Usuń rekord",
"Open configuration": "Otwórz konfiguracje",
"Print widget": "Drukuj widżet",
"Add to page": "Dodaj do strony",
"Collapse widget": "Zwiń widżet",
"Download as XLSX": "Pobierz jako XLSX",
"Edit Card Layout": "Edytuj układ karty",
"Delete widget": "Usuń widżet",
"Download as CSV": "Pobierz jako CSV",
"Show raw data": "Pokaż surowe dane",
"Widget options": "Opcje widżetów",
"Advanced Sort & Filter": "Zaawansowane sortowanie i filtrowanie"
},
"ViewSectionMenu": {
"Custom options": "Opcje niestandardowe",
"Revert": "Przywrócić",
"SORT": "SORTOWAĆ",
"Save": "Zapisz",
"(customized)": "(dostosowane)",
"(empty)": "(pusty)",
"(modified)": "(zmodyfikowany)",
"FILTER": "FILTR",
"Update Sort&Filter settings": "Zaktualizuj ustawienia sortowania i filtrowania"
},
"VisibleFieldsConfig": {
"Clear": "Wyczyść",
"Select All": "Wybierz wszystko",
"Visible {{label}}": "Widoczny {{label}}",
"Hide {{label}}": "Ukryj {{label}}",
"Hidden {{label}}": "Ukryty {{label}}",
"Show {{label}}": "Pokaż {{label}}",
"Cannot drop items into Hidden Fields": "Nie można upuszczać elementów do ukrytych pól",
"Hidden Fields cannot be reordered": "Pola ukryte nie mogą być ponownie uporządkowane"
},
"WelcomeQuestions": {
"Education": "Edukacja",
"Research": "Badania",
"Type here": "Wpisz tutaj",
"Welcome to Grist!": "Witamy w Grist!",
"What brings you to Grist? Please help us serve you better.": "Co sprowadza cię do Grist? Pomóż nam lepiej Ci służyć.",
"Product Development": "Rozwój produktu",
"IT & Technology": "IT i technologia",
"Marketing": "Marketing",
"Media Production": "Produkcja medialna",
"Other": "Inne",
"Sales": "Sprzedaż",
"Finance & Accounting": "Finanse i Księgowość",
"HR & Management": "HR i zarządzanie"
},
"WidgetTitle": {
"DATA TABLE NAME": "NAZWA TABELI DANYCH",
"Override widget title": "Zastąp tytuł widżetu",
"WIDGET TITLE": "TYTUŁ WIDŻETU",
"Provide a table name": "Podaj nazwę tabeli",
"Cancel": "Anulować",
"Save": "Zapisz"
},
"breadcrumbs": {
"fiddle": "skrzypce",
"override": "nadpisać",
"snapshot": "migawka",
"unsaved": "niezapisany",
"recovery mode": "tryb odzyskiwania",
"You may make edits, but they will create a new copy and will\nnot affect the original document.": "Możesz wprowadzać zmiany, ale one utworzą nową kopię i\nnie mają wpływu na oryginalny dokument."
},
"duplicatePage": {
"Note that this does not copy data, but creates another view of the same data.": "Zauważ, że to nie kopiuje danych, ale tworzy inny widok tych samych danych.",
"Duplicate page {{pageName}}": "Duplikat strony {{pageName}}"
},
"errorPages": {
"Access denied{{suffix}}": "Odmowa dostępu{{suffix}}",
"Go to main page": "Przejdź do strony głównej",
"Page not found{{suffix}}": "Nie znaleziono strony{{suffix}}",
"Sign in again": "Zaloguj się ponownie",
"Signed out{{suffix}}": "Wylogowano{{suffix}}",
"Something went wrong": "Coś poszło nie tak",
"There was an error: {{message}}": "Wystąpił błąd: {{message}}",
"There was an unknown error.": "Wystąpił nieznany błąd.",
"You are now signed out.": "Jesteś teraz wylogowany.",
"You do not have access to this organization's documents.": "Nie ma dostępu do dokumentów tej organizacji.",
"Error{{suffix}}": "Błąd{{suffix}}",
"Sign in": "Zaloguj się",
"Add account": "Dodaj konto",
"Contact support": "Skontaktuj się z pomocą techniczną",
"Sign in to access this organization's documents.": "Zaloguj się, aby uzyskać dostęp do dokumentów tej organizacji.",
"The requested page could not be found.{{separator}}Please check the URL and try again.": "Nie można odnaleźć żądanej strony. {{separator}} Sprawdź adres URL i spróbuj ponownie.",
"You are signed in as {{email}}. You can sign in with a different account, or ask an administrator for access.": "Jesteś zalogowany jako {{email}}. Możesz zalogować się na inne konto lub poprosić administratora o dostęp."
},
"menus": {
"* Workspaces are available on team plans. ": "* Obszary robocze są dostępne w planach zespołowych. ",
"Upgrade now": "Uaktualnij teraz",
"Any": "Dowolny",
"Integer": "Liczba całkowita",
"Toggle": "Przełączać",
"Date": "Data",
"Choice": "Wybór",
"Choice List": "Lista wyboru",
"Reference": "Odnośnik",
"Reference List": "Lista referencyjna",
"Select fields": "Wybierz pola",
"Numeric": "Numeryczny",
"Text": "Tekst",
"DateTime": "DataGodzina",
"Attachment": "Załącznik"
},
"modals": {
"Cancel": "Anuluj",
"Save": "Zapisz",
"Ok": "OK"
},
"pages": {
"You do not have edit access to this document": "Nie masz dostępu do edycji tego dokumentu",
"Duplicate Page": "Duplikat strony",
"Remove": "Usuń",
"Rename": "Zmień nazwę"
},
"search": {
"Search in document": "Szukaj w dokumencie",
"Find Next ": "Znajdź następny ",
"Find Previous ": "Znajdź poprzedni ",
"No results": "Brak wyników"
},
"NTextBox": {
"true": "prawdziwy",
"false": "fałszywy"
},
"ACLUsers": {
"Users from table": "Użytkownicy z tabeli",
"View As": "Wyświetl jako",
"Example Users": "Przykładowi Użytkownicy"
},
"TypeTransform": {
"Cancel": "Anulować",
"Preview": "Podgląd",
"Revise": "Zrewidować",
"Apply": "Zastosuj",
"Update formula (Shift+Enter)": "Aktualizuj formułę (Shift+Enter)"
},
"CellStyle": {
"Cell Style": "Styl komórki",
"Default cell style": "Domyślny styl komórki",
"Mixed style": "Styl mieszany",
"Open row styles": "Otwórz style wierszy",
"CELL STYLE": "STYL KOMÓRKI"
},
"ColumnEditor": {
"COLUMN DESCRIPTION": "OPIS KOLUMNY",
"COLUMN LABEL": "ETYKIETA KOLUMNY"
},
"ColumnInfo": {
"COLUMN ID: ": "ID KOLUMNY: ",
"COLUMN LABEL": "ETYKIETA KOLUMNY",
"Cancel": "Anuluj",
"Save": "Zapisz",
"COLUMN DESCRIPTION": "OPIS KOLUMNY"
},
"OnBoardingPopups": {
"Next": "Następny",
"Finish": "Skończyć"
},
"SortFilterConfig": {
"Revert": "Przywrócić",
"Filter": "FILTR",
"Save": "Zapisz",
"Sort": "SORTOWAĆ",
"Update Sort & Filter settings": "Zaktualizuj ustawienia sortowania i filtrowania"
},
"ViewAsBanner": {
"UnknownUser": "Nieznany użytkownik"
},
"SelectionSummary": {
"Copied to clipboard": "Skopiowane do schowka"
},
"PluginScreen": {
"Import failed: ": "Importowanie nie powiodło się: "
},
"RecordLayout": {
"Updating record layout.": "Aktualizowanie układu rekordu."
},
"ThemeConfig": {
"Appearance ": "Wygląd ",
"Switch appearance automatically to match system": "Automatyczne przełączanie wyglądu w celu dopasowania do systemu"
},
"TopBar": {
"Manage Team": "Zarządzaj zespołem"
},
"sendToDrive": {
"Sending file to Google Drive": "Wysyłanie pliku do Google Drive"
},
"ChoiceTextBox": {
"CHOICES": "WYBORY"
} }
} }

View File

@ -723,7 +723,9 @@
"Open configuration": "Abrir configuração", "Open configuration": "Abrir configuração",
"Print widget": "Imprimir Widget", "Print widget": "Imprimir Widget",
"Show raw data": "Mostrar dados primários", "Show raw data": "Mostrar dados primários",
"Widget options": "Opções do Widget" "Widget options": "Opções do Widget",
"Collapse widget": "Colapsar widget",
"Add to page": "Adicionar à página"
}, },
"ViewSectionMenu": { "ViewSectionMenu": {
"(customized)": "(personalizado)", "(customized)": "(personalizado)",
@ -1021,5 +1023,8 @@
"Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use o ícone 𝚺 para criar tabelas resumidas (ou dinâmicas), para totais ou subtotais.", "Use the 𝚺 icon to create summary (or pivot) tables, for totals or subtotals.": "Use o ícone 𝚺 para criar tabelas resumidas (ou dinâmicas), para totais ou subtotais.",
"relational": "relacionais", "relational": "relacionais",
"Unpin to hide the the button while keeping the filter.": "Desfixe para ocultar o botão enquanto mantém o filtro." "Unpin to hide the the button while keeping the filter.": "Desfixe para ocultar o botão enquanto mantém o filtro."
},
"DescriptionConfig": {
"DESCRIPTION": "DESCRIÇÃO"
} }
} }

View File

@ -820,7 +820,7 @@
"IT & Technology": "IT & Технология" "IT & Technology": "IT & Технология"
}, },
"breadcrumbs": { "breadcrumbs": {
"fiddle": "ответвление", "fiddle": "ветка",
"override": "переопределение", "override": "переопределение",
"recovery mode": "режим восстановления", "recovery mode": "режим восстановления",
"snapshot": "снимок", "snapshot": "снимок",