Merge branch 'main' into header-from-row

pull/848/head
CamilleLegeron 3 months ago
commit 0a5e657cc7

@ -130,6 +130,23 @@ You can find a lot more about configuring Grist, setting up authentication,
and running it on a public server in our
[Self-Managed Grist](https://support.getgrist.com/self-managed/) handbook.
## Activating the boot page for diagnosing problems
You can turn on a special "boot page" to inspect the status of your
installation. Just visit `/boot` on your Grist server for instructions.
Since it is useful for the boot page to be available even when authentication
isn't set up, you can give it a special access key by setting `GRIST_BOOT_KEY`.
```
docker run -p 8484:8484 -e GRIST_BOOT_KEY=secret -it gristlabs/grist
```
The boot page should then be available at `/boot/<GRIST_BOOT_KEY>`. We are
starting to collect probes for common problems there. If you hit a problem that
isn't covered, it would be great if you could add a probe for it in
[BootProbes](https://github.com/gristlabs/grist-core/blob/main/app/server/lib/BootProbes.ts).
Or file an issue so someone else can add it, we're just getting start with this.
## Building from source
To build Grist from source, follow these steps:
@ -239,9 +256,9 @@ 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.
GRIST_ADAPT_DOMAIN | set to "true" to support multiple base domains (careful, host header should be trustworthy)
GRIST_ALLOWED_HOSTS | comma-separated list of permitted domains origin for requests (e.g. my.site,another.com)
GRIST_APP_ROOT | directory containing Grist sandbox and assets (specifically the sandbox and static subdirectories).
GRIST_BACKUP_DELAY_SECS | wait this long after a doc change before making a backup
GRIST_BOOT_KEY | if set, offer diagnostics at /boot/GRIST_BOOT_KEY
GRIST_DATA_DIR | directory in which to store document caches.
GRIST_DEFAULT_EMAIL | if set, login as this user if no other credentials presented
GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of new sites. See names of PRODUCTS in Product.ts.
@ -276,7 +293,8 @@ GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymo
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
FREE_COACHING_CALL_URL | set the link to the human help (example: email or meeting scheduling tool)
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)
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_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `limited`, or `full`.
@ -290,6 +308,7 @@ COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to
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
REDIS_URL | optional redis server for browser sessions and db query caching
GRIST_SKIP_REDIS_CHECKSUM_MISMATCH | Experimental. If set, only warn if the checksum in Redis differs with the one in your S3 backend storage. You may turn it on if your backend storage implements the [read-after-write consistency](https://aws.amazon.com/fr/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/). Defaults to false.
GRIST_SNAPSHOT_TIME_CAP | optional. Define the caps for tracking buckets. Usage: {"hour": 25, "day": 32, "isoWeek": 12, "month": 96, "year": 1000}
GRIST_SNAPSHOT_KEEP | optional. Number of recent snapshots to retain unconditionally for a document, regardless of when they were made
GRIST_PROMCLIENT_PORT | optional. If set, serve the Prometheus metrics on the specified port number. ⚠️ Be sure to use a port which is not publicly exposed ⚠️.

@ -6,7 +6,7 @@ import {aclFormulaEditor} from 'app/client/aclui/ACLFormulaEditor';
import {aclMemoEditor} from 'app/client/aclui/ACLMemoEditor';
import {aclSelect} from 'app/client/aclui/ACLSelect';
import {ACLUsersPopup} from 'app/client/aclui/ACLUsers';
import {PermissionKey, permissionsWidget} from 'app/client/aclui/PermissionsWidget';
import {permissionsWidget} from 'app/client/aclui/PermissionsWidget';
import {GristDoc} from 'app/client/components/GristDoc';
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {reportError, UserError} from 'app/client/models/errors';
@ -20,13 +20,17 @@ import {textInput} from 'app/client/ui2018/editableLabel';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuItemAsync} from 'app/client/ui2018/menus';
import {
AVAILABLE_BITS_COLUMNS,
AVAILABLE_BITS_TABLES,
emptyPermissionSet,
MixedPermissionValue,
parsePermissions,
PartialPermissionSet,
PermissionKey,
permissionSetToText,
summarizePermissions,
summarizePermissionSet
summarizePermissionSet,
trimPermissions
} from 'app/common/ACLPermissions';
import {ACLRuleCollection, isSchemaEditResource, SPECIAL_RULES_TABLE_ID} from 'app/common/ACLRuleCollection';
import {AclRuleProblem, AclTableDescription, getTableTitle} from 'app/common/ActiveDocAPI';
@ -990,12 +994,19 @@ abstract class ObsRuleSet extends Disposable {
// Should not happen.
continue;
}
// Include only the permissions for the bits that this RuleSet supports. E.g. this matters
// for seed rules, which may include create/delete bits which shouldn't apply to columns.
const origPermissions = parsePermissions(permissionsText);
const trimmedPermissions = trimPermissions(origPermissions, this.getAvailableBits());
const trimmedPermissionsText = permissionSetToText(trimmedPermissions);
this.addRulePart(
this.getFirst() || null,
{
aclFormula,
permissionsText,
permissions: parsePermissions(permissionsText),
permissionsText: trimmedPermissionsText,
permissions: trimmedPermissions,
memo,
},
true,
@ -1048,7 +1059,7 @@ abstract class ObsRuleSet extends Disposable {
* Which permission bits to allow the user to set.
*/
public getAvailableBits(): PermissionKey[] {
return ['read', 'update', 'create', 'delete'];
return AVAILABLE_BITS_TABLES;
}
/**
@ -1117,8 +1128,7 @@ class ColumnObsRuleSet extends ObsRuleSet {
}
public getAvailableBits(): PermissionKey[] {
// Create/Delete bits can't be set on a column-specific rule.
return ['read', 'update'];
return AVAILABLE_BITS_COLUMNS;
}
public hasColumns() {

@ -6,15 +6,12 @@ import {colors, testId, theme} from 'app/client/ui2018/cssVars';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {menu, menuIcon, menuItem} from 'app/client/ui2018/menus';
import {PartialPermissionSet, PartialPermissionValue} from 'app/common/ACLPermissions';
import {ALL_PERMISSION_PROPS, emptyPermissionSet} from 'app/common/ACLPermissions';
import {ALL_PERMISSION_PROPS, emptyPermissionSet, PermissionKey} from 'app/common/ACLPermissions';
import {capitalize} from 'app/common/gutil';
import {dom, DomElementArg, Observable, styled} from 'grainjs';
import isEqual = require('lodash/isEqual');
import {makeT} from 'app/client/lib/localization';
// One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PartialPermissionSet;
// Canonical order of permission bits when rendered in a permissionsWidget.
const PERMISSION_BIT_ORDER = 'RUCDS';

@ -16,7 +16,12 @@ import type SwaggerUI from 'swagger-ui';
* We load dynamically only to avoid maintaining a separate html file ust for these tags.
*/
function loadExternal() {
return Promise.all([loadScript('swagger-ui-bundle.js'), loadCssFile('swagger-ui.css')]);
return Promise.all([
loadScript('swagger-ui-bundle.js'),
loadCssFile('swagger-ui.css'),
// Stylesheet that's only applied when prefers-color-scheme is dark.
loadCssFile('swagger-ui-dark.css'),
]);
}
// Start loading scripts early (before waiting for AppModel to get initialized).

@ -24,6 +24,7 @@ import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import {confirmModal} from 'app/client/ui2018/modals';
import {INITIAL_FIELDS_COUNT} from 'app/common/Forms';
import {isOwner} from 'app/common/roles';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
@ -58,6 +59,7 @@ export class FormView extends Disposable {
private _remoteShare: AsyncComputed<{key: string}|null>;
private _published: Computed<boolean>;
private _showPublishedMessage: Observable<boolean>;
private _isOwner: boolean;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
@ -380,6 +382,8 @@ export class FormView extends Disposable {
true
));
this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get());
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
@ -640,7 +644,7 @@ export class FormView extends Disposable {
testId('link'),
dom('div', 'Copy Link'),
dom.prop('disabled', this._copyingLink),
dom.show(use => this.gristDoc.appModel.isOwner() && use(this._published)),
dom.show(use => this._isOwner && use(this._published)),
dom.on('click', async (_event, element) => {
try {
this._copyingLink.set(true);
@ -662,14 +666,14 @@ export class FormView extends Disposable {
return published
? style.cssIconButton(
dom('div', 'Unpublish'),
dom.show(this.gristDoc.appModel.isOwner()),
dom.show(this._isOwner),
style.cssIconButton.cls('-warning'),
dom.on('click', () => this._handleClickUnpublish()),
testId('unpublish'),
)
: style.cssIconButton(
dom('div', 'Publish'),
dom.show(this.gristDoc.appModel.isOwner()),
dom.show(this._isOwner),
cssButton.cls('-primary'),
dom.on('click', () => this._handleClickPublish()),
testId('publish'),
@ -714,7 +718,7 @@ export class FormView extends Disposable {
this._showPublishedMessage.set(false);
}),
),
dom.show(this.gristDoc.appModel.isOwner()),
dom.show(this._isOwner),
);
});
}

@ -358,9 +358,9 @@ export class GristDoc extends DisposableWithEvents {
urlState().state,
isTourActiveObs(),
fromKo(this.docModel.isTutorial),
(_use, state, hasActiveTour, isTutorial) => {
async (_use, state, hasActiveTour, isTutorial) => {
// Tours and tutorials can interfere with in-product tips and announcements.
const hasPendingDocTour = state.docTour || this._shouldAutoStartDocTour();
const hasPendingDocTour = state.docTour || await this._shouldAutoStartDocTour();
const hasPendingWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();
const isPopupManagerDisabled = this.behavioralPromptsManager.isDisabled();
if (
@ -401,7 +401,7 @@ export class GristDoc extends DisposableWithEvents {
}
const shouldStartTutorial = isTutorial;
const shouldStartDocTour = state.docTour || this._shouldAutoStartDocTour();
const shouldStartDocTour = state.docTour || await this._shouldAutoStartDocTour();
const shouldStartWelcomeTour = state.welcomeTour || this._shouldAutoStartWelcomeTour();
if (shouldStartTutorial || shouldStartDocTour || shouldStartWelcomeTour) {
isStartingTourOrTutorial = true;
@ -1823,15 +1823,22 @@ export class GristDoc extends DisposableWithEvents {
/**
* Returns whether a doc tour should automatically be started.
*
* Currently, tours are started if a GristDocTour table exists and the user hasn't
* seen the tour before.
* Currently, tours are started if a non-empty GristDocTour table exists and the
* user hasn't seen the tour before.
*/
private _shouldAutoStartDocTour(): boolean {
if (this._disableAutoStartingTours || this.docModel.isTutorial()) {
private async _shouldAutoStartDocTour(): Promise<boolean> {
if (
this._disableAutoStartingTours ||
this.docModel.isTutorial() ||
!this.docModel.hasDocTour() ||
this._seenDocTours.get()?.includes(this.docId())
) {
return false;
}
return this.docModel.hasDocTour() && !this._seenDocTours.get()?.includes(this.docId());
const tableData = this.docData.getTable('GristDocTour')!;
await this.docData.fetchTable('GristDocTour');
return tableData.numRecords() > 0;
}
/**

@ -17,7 +17,7 @@ import {TableData} from 'app/client/models/TableData';
import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView';
import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils';
import {cssInput} from 'app/client/ui/cssInput';
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
import {getDateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
import {cssPinButton} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {cssLabel as cssCheckboxLabel, cssCheckboxSquare,
@ -176,16 +176,16 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
cssLinkRow(
testId('presets-links'),
cssLink(
DateRangeOptions[0].label,
dom.on('click', () => action(DateRangeOptions[0]))
getDateRangeOptions()[0].label,
dom.on('click', () => action(getDateRangeOptions()[0]))
),
cssLink(
DateRangeOptions[1].label,
dom.on('click', () => action(DateRangeOptions[1]))
getDateRangeOptions()[1].label,
dom.on('click', () => action(getDateRangeOptions()[1]))
),
cssLink(
'More ', icon('Dropdown'),
menu(() => DateRangeOptions.map(
menu(() => getDateRangeOptions().map(
(option) => menuItem(() => action(option), option.label)
), {attach: '.' + cssMenu.className})
),

@ -1,41 +1,55 @@
import {makeT} from 'app/client/lib/localization';
import { CURRENT_DATE, IRelativeDateSpec } from "app/common/RelativeDates";
const t = makeT('DateRangeOptions');
export interface IDateRangeOption {
label: string;
min: IRelativeDateSpec;
max: IRelativeDateSpec;
}
export const DateRangeOptions: IDateRangeOption[] = [{
label: 'Today',
min: CURRENT_DATE,
max: CURRENT_DATE,
}, {
label: 'Last 7 days',
min: [{quantity: -7, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
}, {
label: 'Next 7 days',
min: [{quantity: 1, unit: 'day'}],
max: [{quantity: 7, unit: 'day'}],
}, {
label: 'Last Week',
min: [{quantity: -1, unit: 'week'}],
max: [{quantity: -1, unit: 'week', endOf: true}],
}, {
label: 'Last 30 days',
min: [{quantity: -30, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
}, {
label: 'This week',
min: [{quantity: 0, unit: 'week'}],
max: [{quantity: 0, unit: 'week', endOf: true}],
}, {
label: 'This month',
min: [{quantity: 0, unit: 'month'}],
max: [{quantity: 0, unit: 'month', endOf: true}],
}, {
label: 'This year',
min: [{quantity: 0, unit: 'year'}],
max: [{quantity: 0, unit: 'year', endOf: true}],
}];
export function getDateRangeOptions(): IDateRangeOption[] {
return [
{
label: t('Today'),
min: CURRENT_DATE,
max: CURRENT_DATE,
},
{
label: t('Last 7 days'),
min: [{quantity: -7, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
},
{
label: t('Next 7 days'),
min: [{quantity: 1, unit: 'day'}],
max: [{quantity: 7, unit: 'day'}],
},
{
label: t('Last Week'),
min: [{quantity: -1, unit: 'week'}],
max: [{quantity: -1, unit: 'week', endOf: true}],
},
{
label: t('Last 30 days'),
min: [{quantity: -30, unit: 'day'}],
max: [{quantity: -1, unit: 'day'}],
},
{
label: t('This week'),
min: [{quantity: 0, unit: 'week'}],
max: [{quantity: 0, unit: 'week', endOf: true}],
},
{
label: t('This month'),
min: [{quantity: 0, unit: 'month'}],
max: [{quantity: 0, unit: 'month', endOf: true}],
},
{
label: t('This year'),
min: [{quantity: 0, unit: 'year'}],
max: [{quantity: 0, unit: 'year', endOf: true}],
},
];
}

@ -426,13 +426,17 @@ function buildLookupSection(gridView: GridView, index?: number){
function formula() {
switch(fun) {
case 'list': return `${referenceToSource}.${col.colId()}`;
case 'average': return `AVERAGE(${referenceToSource}.${col.colId()})`;
case 'min': return `MIN(${referenceToSource}.${col.colId()})`;
case 'max': return `MAX(${referenceToSource}.${col.colId()})`;
case 'average': return `ref = ${referenceToSource}\n` +
`AVERAGE(ref.${col.colId()}) if ref else None`;
case 'min': return `ref = ${referenceToSource}\n` +
`MIN(ref.${col.colId()}) if ref else None`;
case 'max': return `ref = ${referenceToSource}\n` +
`MAX(ref.${col.colId()}) if ref else None`;
case 'count':
case 'sum': return `SUM(${referenceToSource}.${col.colId()})`;
case 'percent':
return `AVERAGE(map(int, ${referenceToSource}.${col.colId()})) if ${referenceToSource} else None`;
return `ref = ${referenceToSource}\n` +
`AVERAGE(map(int, ref.${col.colId()})) if ref else None`;
default: return `${referenceToSource}`;
}
}

@ -85,7 +85,7 @@ export const GristTooltips: Record<Tooltip, TooltipContentFunc> = {
t('Try out changes in a copy, then decide whether to replace the original with your edits.')
),
dom('div',
cssLink({href: commonUrls.helpTryingOutChanges, target: '_blank'}, 'Learn more.'),
cssLink({href: commonUrls.helpTryingOutChanges, target: '_blank'}, t('Learn more.')),
),
...args,
),

@ -303,7 +303,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
const selected = Observable.create<DownloadOption>(owner, 'full');
return [
cssModalTitle(`Download document`),
cssModalTitle(t(`Download document`)),
cssRadioCheckboxOptions(
radioCheckboxOption(selected, 'full', t("Download full document and history")),
radioCheckboxOption(selected, 'nohistory', t("Remove document history (can significantly reduce file size)")),
@ -311,7 +311,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
),
cssModalButtons(
dom.domComputed(use =>
bigPrimaryButtonLink(`Download`, hooks.maybeModifyLinkAttrs({
bigPrimaryButtonLink(t(`Download`), hooks.maybeModifyLinkAttrs({
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({
template: use(selected) === "template",
removeHistory: use(selected) === "nohistory" || use(selected) === "template",
@ -325,7 +325,7 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
testId('download-button-link'),
),
),
bigBasicButton('Cancel', dom.on('click', () => {
bigBasicButton(t('Cancel'), dom.on('click', () => {
ctl.close();
}))
)

@ -8,6 +8,7 @@ import {cardPopup, cssPopupBody, cssPopupButtons, cssPopupCloseButton,
import {icon} from 'app/client/ui2018/icons';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, styled} from 'grainjs';
import { commonUrls } from 'app/common/gristUrls';
const t = makeT('WelcomeCoachingCall');
@ -103,7 +104,7 @@ We can show you the Grist basics, or start working with your data right away to
logTelemetryEvent('clickedScheduleCoachingCall');
}),
{
href: getGristConfig().freeCoachingCallUrl,
href: commonUrls.freeCoachingCall,
target: '_blank',
},
testId('popup-primary-button'),

@ -7,7 +7,7 @@ import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {bigBasicButtonLink, bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {getPageTitleSuffix} from 'app/common/gristUrls';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {dom, DomElementArg, makeTestId, observable, styled} from 'grainjs';
@ -94,7 +94,7 @@ export function createNotFoundPage(appModel: AppModel, message?: string) {
})),
cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'),
urlState().setLinkUrl({}))),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
]);
}
@ -109,7 +109,7 @@ export function createOtherErrorPage(appModel: AppModel, message?: string) {
t('There was an unknown error.')),
cssButtonWrap(bigPrimaryButtonLink(t("Go to main page"), testId('error-primary-btn'),
urlState().setLinkUrl({}))),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: 'https://getgrist.com/contact'})),
cssButtonWrap(bigBasicButtonLink(t("Contact support"), {href: commonUrls.contactSupport})),
]);
}

@ -41,7 +41,10 @@ export type PartialPermissionSet = PermissionSet<PartialPermissionValue>;
export type MixedPermissionSet = PermissionSet<MixedPermissionValue>;
export type TablePermissionSet = PermissionSet<TablePermissionValue>;
const PERMISSION_BITS: {[letter: string]: keyof PermissionSet} = {
// One of the strings 'read', 'update', etc.
export type PermissionKey = keyof PermissionSet;
const PERMISSION_BITS: {[letter: string]: PermissionKey} = {
R: 'read',
C: 'create',
U: 'update',
@ -60,6 +63,9 @@ const ALIASES: {[key: string]: string} = {
};
const REVERSE_ALIASES = fromPairs(Object.entries(ALIASES).map(([alias, value]) => [value, alias]));
export const AVAILABLE_BITS_TABLES: PermissionKey[] = ['read', 'update', 'create', 'delete'];
export const AVAILABLE_BITS_COLUMNS: PermissionKey[] = ['read', 'update'];
// Comes in useful for initializing unset PermissionSets.
export function emptyPermissionSet(): PartialPermissionSet {
return {read: "", create: "", update: "", delete: "", schemaEdit: ""};
@ -141,6 +147,20 @@ export function mergePartialPermissions(a: PartialPermissionSet, b: PartialPermi
return mergePermissions([a, b], ([_a, _b]) => combinePartialPermission(_a, _b));
}
/**
* Returns permissions trimmed to include only the available bits, and empty for any other bits.
*/
export function trimPermissions(
permissions: PartialPermissionSet, availableBits: PermissionKey[]
): PartialPermissionSet {
const trimmed = emptyPermissionSet();
for (const bit of availableBits) {
trimmed[bit] = permissions[bit];
}
return trimmed;
}
/**
* Merge a list of PermissionSets by combining individual bits.
*/

@ -1,4 +1,5 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {AVAILABLE_BITS_COLUMNS, AVAILABLE_BITS_TABLES, trimPermissions} from 'app/common/ACLPermissions';
import {ACLShareRules, TableWithOverlay} from 'app/common/ACLShareRules';
import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {DocData} from 'app/common/DocData';
@ -537,13 +538,18 @@ function readAclRules(docData: DocData, {log, compile, enrichRulesForImplementat
if (hasShares && rule.id >= 0) {
aclFormulaParsed = shareRules.transformNonShareRules({rule, aclFormulaParsed});
}
let permissions = parsePermissions(String(rule.permissionsText));
if (tableId !== '*' && tableId !== SPECIAL_RULES_TABLE_ID) {
const availableBits = (colIds === '*') ? AVAILABLE_BITS_TABLES : AVAILABLE_BITS_COLUMNS;
permissions = trimPermissions(permissions, availableBits);
}
body.push({
origRecord: rule,
aclFormula: String(rule.aclFormula),
matchFunc: rule.aclFormula ? compile?.(aclFormulaParsed) : defaultMatchFunc,
memo: rule.memo,
permissions: parsePermissions(String(rule.permissionsText)),
permissionsText: String(rule.permissionsText),
permissions,
permissionsText: permissionSetToText(permissions)
});
}
}

@ -86,6 +86,8 @@ export const commonUrls = {
helpTelemetryLimited: "https://support.getgrist.com/telemetry-limited",
helpCalendarWidget: "https://support.getgrist.com/widget-calendar",
helpLinkKeys: "https://support.getgrist.com/examples/2021-04-link-keys",
freeCoachingCall: getFreeCoachingCallUrl(),
contactSupport: getContactSupportUrl(),
plans: "https://www.getgrist.com/pricing",
sproutsProgram: "https://www.getgrist.com/sprouts-program",
contact: "https://www.getgrist.com/contact",
@ -670,6 +672,9 @@ export interface GristLoadConfig {
// Url for free coaching call scheduling for the browser client to use.
freeCoachingCallUrl?: string;
// Url for "contact support" button on Grist's "not found" error page
contactSupportUrl?: string;
// When set, this directs the client to encode org information in path, not in domain.
pathOnly?: boolean;
@ -865,21 +870,33 @@ export function getKnownOrg(): string|null {
}
}
export function getHelpCenterUrl(): string|null {
export function getHelpCenterUrl(): string {
const defaultUrl = "https://support.getgrist.com";
if(isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.helpCenterUrl || defaultUrl;
} else {
return process.env.GRIST_HELP_CENTER || defaultUrl;
}
}
export function getFreeCoachingCallUrl(): string {
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
if(isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.helpCenterUrl || null;
return gristConfig && gristConfig.freeCoachingCallUrl || defaultUrl;
} else {
return process.env.GRIST_HELP_CENTER || null;
return process.env.FREE_COACHING_CALL_URL || defaultUrl;
}
}
export function getFreeCoachingCallUrl(): string|null {
export function getContactSupportUrl(): string {
const defaultUrl = "https://www.getgrist.com/contact/";
if(isClient()) {
const gristConfig: GristLoadConfig = (window as any).gristConfig;
return gristConfig && gristConfig.freeCoachingCallUrl || null;
return gristConfig && gristConfig.contactSupportUrl || defaultUrl;
} else {
return process.env.FREE_COACHING_CALL_URL || null;
return process.env.GRIST_CONTACT_SUPPORT_URL || defaultUrl;
}
}

@ -18,7 +18,7 @@ import {makeId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log';
import {IPermitStore, Permit} from 'app/server/lib/Permit';
import {AccessTokenInfo} from 'app/server/lib/AccessTokens';
import {allowHost, getOriginUrl, isEnvironmentAllowedHost, optStringParam} from 'app/server/lib/requestUtils';
import {allowHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils';
import * as cookie from 'cookie';
import {NextFunction, Request, RequestHandler, Response} from 'express';
import {IncomingMessage} from 'http';
@ -271,7 +271,7 @@ export async function addRequestUser(
// custom-domain owner could hijack such sessions.
const allowedOrg = getAllowedOrgForSessionID(mreq.sessionID);
if (allowedOrg) {
if (allowHost(req, allowedOrg.host) || isEnvironmentAllowedHost(allowedOrg.host)) {
if (allowHost(req, allowedOrg.host)) {
customHostSession = ` custom-host-match ${allowedOrg.host}`;
} else {
// We need an exception for internal forwarding from home server to doc-workers. These use

@ -93,6 +93,7 @@ import * as path from 'path';
import * as t from "ts-interface-checker";
import {Checker} from "ts-interface-checker";
import uuidv4 from "uuid/v4";
import { Document } from "app/gen-server/entity/Document";
// Cap on the number of requests that can be outstanding on a single document via the
// rest doc api. When this limit is exceeded, incoming requests receive an immediate
@ -646,6 +647,9 @@ export class DocWorkerApi {
// full document.
const dryRun = isAffirmative(req.query.dryrun || req.query.dryRun);
const dryRunSuccess = () => res.status(200).json({dryRun: 'allowed'});
const filename = await this._getDownloadFilename(req);
// We want to be have a way download broken docs that ActiveDoc may not be able
// to load. So, if the user owns the document, we unconditionally let them
// download.
@ -655,13 +659,13 @@ export class DocWorkerApi {
// We carefully avoid creating an ActiveDoc for the document being downloaded,
// in case it is broken in some way. It is convenient to be able to download
// broken files for diagnosis/recovery.
return await this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
return await this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
} catch (e) {
if (e.message && e.message.match(/does not exist yet/)) {
// The document has never been seen on file system / s3. It may be new, so
// we try again after having created an ActiveDoc for the document.
await this._getActiveDoc(req);
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
} else {
throw e;
}
@ -674,7 +678,7 @@ export class DocWorkerApi {
throw new ApiError('not authorized to download this document', 403);
}
if (dryRun) { dryRunSuccess(); return; }
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager);
return this._docWorker.downloadDoc(req, res, this._docManager.storageManager, filename);
}
}));
@ -1222,7 +1226,7 @@ export class DocWorkerApi {
this._app.get('/api/docs/:docId/download/table-schema', canView, withDoc(async (activeDoc, req, res) => {
const doc = await this._dbManager.getDoc(req);
const options = this._getDownloadOptions(req, doc.name);
const options = await this._getDownloadOptions(req, doc);
const tableSchema = await collectTableSchemaInFrictionlessFormat(activeDoc, req, options);
const apiPath = await this._grist.getResourceUrl(doc, 'api');
const query = new URLSearchParams(req.query as {[key: string]: string});
@ -1241,18 +1245,16 @@ export class DocWorkerApi {
}));
this._app.get('/api/docs/:docId/download/csv', canView, withDoc(async (activeDoc, req, res) => {
// Query DB for doc metadata to get the doc title.
const {name: docTitle} = await this._dbManager.getDoc(req);
const options = this._getDownloadOptions(req, docTitle);
const options = await this._getDownloadOptions(req);
await downloadCSV(activeDoc, req, res, options);
}));
this._app.get('/api/docs/:docId/download/xlsx', canView, withDoc(async (activeDoc, req, res) => {
// Query DB for doc metadata to get the doc title (to use as the filename).
const {name: docTitle} = await this._dbManager.getDoc(req);
const options: DownloadOptions = !_.isEmpty(req.query) ? this._getDownloadOptions(req, docTitle) : {
filename: docTitle,
const options: DownloadOptions = (!_.isEmpty(req.query) && !_.isEqual(Object.keys(req.query), ["title"]))
? await this._getDownloadOptions(req)
: {
filename: await this._getDownloadFilename(req),
tableId: '',
viewSectionId: undefined,
filters: [],
@ -1734,11 +1736,23 @@ export class DocWorkerApi {
return docAuth.docId!;
}
private _getDownloadOptions(req: Request, name: string): DownloadOptions {
private async _getDownloadFilename(req: Request, tableId?: string, optDoc?: Document): Promise<string> {
let filename = optStringParam(req.query.title, 'title');
if (!filename) {
// Query DB for doc metadata to get the doc data.
const doc = optDoc || await this._dbManager.getDoc(req);
const docTitle = doc.name;
const suffix = tableId ? (tableId === docTitle ? '' : `-${tableId}`) : '';
filename = docTitle + suffix || 'document';
}
return filename;
}
private async _getDownloadOptions(req: Request, doc?: Document): Promise<DownloadOptions> {
const params = parseExportParameters(req);
return {
...params,
filename: name + (params.tableId === name ? '' : '-' + params.tableId),
filename: await this._getDownloadFilename(req, params.tableId, doc),
};
}

@ -68,14 +68,10 @@ export class DocWorker {
}
public async downloadDoc(req: express.Request, res: express.Response,
storageManager: IDocStorageManager): Promise<void> {
storageManager: IDocStorageManager, filename: string): Promise<void> {
const mreq = req as RequestWithLogin;
const docId = getDocId(mreq);
// Query DB for doc metadata to get the doc title.
const doc = await this._dbManager.getDoc(req);
const docTitle = doc.name;
// Get a copy of document for downloading.
const tmpPath = await storageManager.getCopy(docId);
if (isAffirmative(req.query.template)) {
@ -90,7 +86,7 @@ export class DocWorker {
return res.type('application/x-sqlite3')
.download(
tmpPath,
(optStringParam(req.query.title, 'title') || docTitle || 'document') + ".grist",
filename + ".grist",
async (err: any) => {
if (err) {
if (err.message && /Request aborted/.test(err.message)) {

@ -1,6 +1,8 @@
import {ObjMetadata, ObjSnapshot, ObjSnapshotWithMetadata} from 'app/common/DocSnapshot';
import {isAffirmative} from 'app/common/gutil';
import log from 'app/server/lib/log';
import {createTmpDir} from 'app/server/lib/uploads';
import {delay} from 'bluebird';
import * as fse from 'fs-extra';
import * as path from 'path';
@ -226,13 +228,27 @@ export class ChecksummedExternalStorage implements ExternalStorage {
const expectedChecksum = await this._options.sharedHash.load(fromKey);
// Let null docMD5s pass. Otherwise we get stuck if redis is cleared.
// Otherwise, make sure what we've got matches what we expect to get.
// S3 is eventually consistent - if you overwrite an object in it, and then read from it,
// you may get an old version for some time.
// AWS S3 was eventually consistent, but now has stronger guarantees:
// https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/
//
// Previous to this change, if you overwrote an object in it,
// and then read from it, you may have got an old version for some time.
// We are confident this should not be the case anymore, though this has to be studied carefully.
// If a snapshotId was specified, we can skip this check.
if (expectedChecksum && expectedChecksum !== checksum) {
log.error("ext %s download: data for %s has wrong checksum: %s (expected %s)",
this.label, fromKey, checksum, expectedChecksum);
return undefined;
const message = `ext ${this.label} download: data for ${fromKey} has wrong checksum:` +
` ${checksum} (expected ${expectedChecksum})`;
// If GRIST_SKIP_REDIS_CHECKSUM_MISMATCH is set, issue a warning only and continue,
// rather than issuing an error and failing.
// This flag is experimental and should be removed once we are
// confident that the checksums verification is useless.
if (isAffirmative(process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH)) {
log.warn(message);
} else {
log.error(message);
return undefined;
}
}
}

@ -8,7 +8,6 @@ import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {Request, Response} from 'express';
import _ from 'lodash';
import {Writable} from 'stream';
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
@ -87,7 +86,7 @@ export function trustOrigin(req: Request, resp: Response): boolean {
const origin = req.get('origin');
if (!origin) { return true; } // Not a CORS request.
if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; }
if (!allowHost(req, new URL(origin)) && !isEnvironmentAllowedHost(new URL(origin))) { return false; }
if (!allowHost(req, new URL(origin))) { return false; }
// For a request to a custom domain, the full hostname must match.
resp.header("Access-Control-Allow-Origin", origin);
@ -104,14 +103,14 @@ export function allowHost(req: Request, allowedHost: string|URL) {
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost;
if (mreq.isCustomHost) {
// For a request to a custom domain, the full hostname must match.
return actualUrl.hostname === allowedUrl.hostname;
return actualUrl.hostname === allowedUrl.hostname;
} else {
// For requests to a native subdomains, only the base domain needs to match.
const allowedDomain = parseSubdomain(allowedUrl.hostname);
const actualDomain = parseSubdomain(actualUrl.hostname);
return (!_.isEmpty(actualDomain) ?
return actualDomain.base ?
actualDomain.base === allowedDomain.base :
allowedUrl.hostname === actualUrl.hostname);
actualUrl.hostname === allowedUrl.hostname;
}
}
@ -119,13 +118,6 @@ export function matchesBaseDomain(domain: string, baseDomain: string) {
return domain === baseDomain || domain.endsWith("." + baseDomain);
}
export function isEnvironmentAllowedHost(url: string|URL) {
const urlHost = (typeof url === 'string') ? url : url.hostname;
return (process.env.GRIST_ALLOWED_HOSTS || "").split(",").some(domain =>
domain && matchesBaseDomain(urlHost, domain)
);
}
export function isParameterOn(parameter: any): boolean {
return gutil.isAffirmative(parameter);
}

@ -1,4 +1,12 @@
import {Features, getPageTitleSuffix, GristLoadConfig, IFeature} from 'app/common/gristUrls';
import {
Features,
getContactSupportUrl,
getFreeCoachingCallUrl,
getHelpCenterUrl,
getPageTitleSuffix,
GristLoadConfig,
IFeature
} from 'app/common/gristUrls';
import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI';
@ -53,8 +61,9 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
org: process.env.GRIST_SINGLE_ORG || (mreq && mreq.org),
baseDomain,
singleOrg: process.env.GRIST_SINGLE_ORG,
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
freeCoachingCallUrl: process.env.FREE_COACHING_CALL_URL || "https://calendly.com/grist-team/grist-free-coaching-call",
helpCenterUrl: getHelpCenterUrl(),
freeCoachingCallUrl: getFreeCoachingCallUrl(),
contactSupportUrl: getContactSupportUrl(),
pathOnly,
supportAnon: shouldSupportAnon(),
enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),

@ -1,6 +1,6 @@
{
"name": "grist-core",
"version": "1.1.11",
"version": "1.1.12",
"license": "Apache-2.0",
"description": "Grist is the evolution of spreadsheets",
"homepage": "https://github.com/gristlabs/grist-core",

@ -579,7 +579,9 @@
"You do not have write access to this site": "Sie haben keinen Schreibzugriff auf diese Seite",
"Download full document and history": "Vollständiges Dokument und Geschichte herunterladen",
"Remove all data but keep the structure to use as a template": "Entfernen Sie alle Daten, behalten Sie aber die Struktur als Vorlage bei",
"Remove document history (can significantly reduce file size)": "Dokumentverlauf entfernen (kann die Dateigröße deutlich reduzieren)"
"Remove document history (can significantly reduce file size)": "Dokumentverlauf entfernen (kann die Dateigröße deutlich reduzieren)",
"Download document": "Dokument herunterladen",
"Download": "Download"
},
"NTextBox": {
"false": "falsch",
@ -1439,5 +1441,15 @@
},
"FormPage": {
"There was an error submitting your form. Please try again.": "Beim Absenden Ihres Formulars ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."
},
"DateRangeOptions": {
"Last 30 days": "Letzte 30 Tage",
"Last 7 days": "Letzte 7 Tage",
"Last Week": "Letzte Woche",
"Next 7 days": "Nächste 7 Tage",
"This month": "Diesen Monat",
"This week": "Diese Woche",
"This year": "Dieses Jahr",
"Today": "Heute"
}
}

@ -547,7 +547,9 @@
"You do not have write access to this site": "You do not have write access to this site",
"Download full document and history": "Download full document and history",
"Remove all data but keep the structure to use as a template": "Remove all data but keep the structure to use as a template",
"Remove document history (can significantly reduce file size)": "Remove document history (can significantly reduce file size)"
"Remove document history (can significantly reduce file size)": "Remove document history (can significantly reduce file size)",
"Download": "Download",
"Download document": "Download document"
},
"NotifyUI": {
"Ask for help": "Ask for help",
@ -1376,5 +1378,15 @@
"FormSuccessPage": {
"Form Submitted": "Form Submitted",
"Thank you! Your response has been recorded.": "Thank you! Your response has been recorded."
},
"DateRangeOptions": {
"Last 30 days": "Last 30 days",
"Last 7 days": "Last 7 days",
"Last Week": "Last Week",
"Next 7 days": "Next 7 days",
"This month": "This month",
"This week": "This week",
"This year": "This year",
"Today": "Today"
}
}

@ -475,7 +475,9 @@
"You do not have write access to this site": "No tiene acceso de escritura a este sitio",
"Download full document and history": "Descargar documento completo e historial",
"Remove all data but keep the structure to use as a template": "Elimine todos los datos pero mantenga la estructura para usarla como plantilla",
"Remove document history (can significantly reduce file size)": "Eliminar el historial del documento (puede reducir significativamente el tamaño del archivo)"
"Remove document history (can significantly reduce file size)": "Eliminar el historial del documento (puede reducir significativamente el tamaño del archivo)",
"Download": "Descargar",
"Download document": "Descargar el documento"
},
"NTextBox": {
"false": "falso",
@ -1429,5 +1431,15 @@
"FormSuccessPage": {
"Form Submitted": "Formulario enviado",
"Thank you! Your response has been recorded.": "¡Muchas gracias! Su respuesta ha quedado registrada."
},
"DateRangeOptions": {
"Last 30 days": "Últimos 30 días",
"Last 7 days": "Últimos 7 días",
"Last Week": "Última semana",
"Next 7 days": "Próximos 7 días",
"This month": "Este mes",
"This week": "Esta semana",
"This year": "Este año",
"Today": "Hoy"
}
}

@ -544,7 +544,9 @@
"You do not have write access to the selected workspace": "Vous navez pas accès en écriture à ce dossier",
"Remove all data but keep the structure to use as a template": "Supprimer toutes les données mais garder la structure comme modèle",
"Remove document history (can significantly reduce file size)": "Supprimer l'historique du document (peut réduire sensiblement la taille du fichier)",
"Download full document and history": "Télécharger le document complet et l'historique"
"Download full document and history": "Télécharger le document complet et l'historique",
"Download": "Télécharger",
"Download document": "Télécharger le document"
},
"NotifyUI": {
"Upgrade Plan": "Améliorer votre abonnement",

@ -38,8 +38,8 @@
"Insert row below": "下に行を挿入",
"Delete": "削除",
"Copy anchor link": "リンクをコピー",
"Duplicate rows_one": "重複行",
"Duplicate rows_other": "重複行",
"Duplicate rows_one": "行を複製",
"Duplicate rows_other": "行を複製",
"Insert row above": "上に行を挿入"
},
"Drafts": {
@ -103,7 +103,7 @@
"Reset {{count}} entire columns_one": "列全体をリセット",
"Convert formula to data": "数式をデータに変換する",
"Freeze {{count}} more columns_other": "さらに {{count}} 列固定する",
"Hide {{count}} columns_one": "列非表示",
"Hide {{count}} columns_one": "列非表示",
"Insert column to the left": "左側に列を挿入",
"Sorted (#{{count}})_other": "ソート (#{{count}})",
"Attachment": "添付ファイル",
@ -125,14 +125,17 @@
"Date": "日付",
"DateTime": "日時",
"Choice": "選択",
"Choice List": "複数選択"
"Choice List": "複数選択",
"Lookups": "Lookups",
"Shortcuts": "ショートカット",
"Show hidden columns": "非表示列を再表示"
},
"DocMenu": {
"This service is not available right now": "このサービスは現在ご利用いただけません",
"Workspace not found": "ワークスペースが見つかりません",
"Discover More Templates": "その他のテンプレート",
"Current workspace": "現在のワークスペース",
"Edited {{at}}": "{{at}}による編集",
"Edited {{at}}": "{{at}}に更新",
"Pin Document": "ドキュメントをピン留めする",
"Remove": "削除",
"By Date Modified": "変更日",
@ -195,7 +198,14 @@
"Add referenced columns": "参照列の追加",
"TRANSFORM": "変換",
"Sort & Filter": "ソート&フィルター",
"Widget": "ウィジェット"
"Widget": "ウィジェット",
"Submit button label": "送信ボタンのラベル",
"Submit another response": "別の回答を送信",
"Display button": "ボタンを表示",
"Redirect automatically after submission": "送信後に自動的にリダイレクトする",
"Redirection": "リダイレクト",
"Required field": "必須フィールド",
"Enter redirect URL": "リダイレクト先URL"
},
"FloatingPopup": {
"Maximize": "最大化",
@ -306,27 +316,27 @@
"team site": "チームサイト",
"Create a team to share with more people": "より多くの人と共有するためにチームを作る",
"guest": "ゲスト",
"Public access: ": "パブリックアクセス ",
"Public access: ": "公開 ",
"Team member": "チームメンバー",
"Manage members of team site": "チームサイトのメンバーの管理",
"Off": "Off",
"Save & ": "保存 ",
"Outside collaborator": "外部コラボレーター",
"{{collaborator}} limit exceeded": "{{collaborator}} 制限超過",
"User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "ユーザは{{parent})}からパーミッションを継承します。削除するには、'Inherit access' オプションを 'None' に設定します。",
"User inherits permissions from {{parent})}. To remove, set 'Inherit access' option to 'None'.": "ユーザは{{parent})}からパーミッションを継承します。削除するには、'アクセス権の継承' オプションを 'None' に設定します。",
"Your role for this {{resourceType}}": "この{{resourceType}}のあなたの役割",
"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}} に十分なアクセス権を持つ他の誰かの援助がない限り、元に戻すことはできません。",
"Close": "閉じる",
"Allow anyone with the link to open.": "誰でもリンクを開くことができるようにする。",
"Invite people to {{resourceType}}": "{{resourceType}} に招待する",
"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "パブリックアクセスは{{parent}} から継承されます。 削除するには、'Inherit access' オプションを 'None' に設定します。",
"Public access inherited from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "公開設定は{{parent}} から継承されます。 削除するには、'アクセス権の継承' オプションを 'None' に設定します。",
"Remove my access": "アクセスを削除",
"Public access": "パブリック・アクセス",
"Public Access": "パブリック・アクセス",
"Public access": "公開",
"Public Access": "公開",
"Cancel": "キャンセル",
"Grist support": "Gristサポート",
"You are about to remove your own access to this {{resourceType}}": "この {{resourceType}} への自分のアクセス権を削除しようとしています",
"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "ユーザは{{parent}} からパーミッションを継承します。 削除するには、'Inherit access' オプションを 'None' に設定します。",
"User inherits permissions from {{parent}}. To remove, set 'Inherit access' option to 'None'.": "ユーザは{{parent}} からパーミッションを継承します。 削除するには、'アクセス権の継承' オプションを 'None' に設定します。",
"Guest": "ゲスト",
"Invite multiple": "複数招待",
"Confirm": "確認",
@ -394,9 +404,10 @@
"Rules for table ": "テーブルのルール ",
"Checking...": "チェック中…",
"Special Rules": "特別ルール",
"View As": "として表示",
"View As": "役割で表示",
"Seed rules": "シード・ルール",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "編集者による構造の編集(例:テーブル、列、レイアウトの変更や削除)、および数式の書き込みを許可し、読み取り制限に関係なくすべてのデータにアクセスできるようにする。"
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "編集者による構造の編集(例:テーブル、列、レイアウトの変更や削除)、および数式の書き込みを許可し、読み取り制限に関係なくすべてのデータにアクセスできるようにする。",
"Add Table-wide Rule": "テーブル全体のルールを追加"
},
"FieldEditor": {
"It should be impossible to save a plain data value into a formula column": "単純なデータ値を数式列に保存することは不可能なはずです",
@ -454,13 +465,13 @@
"Copy": "コピー",
"Delete {{count}} columns_one": "列の削除",
"Delete {{count}} columns_other": "{{count}}列削除",
"Duplicate rows_one": "重複行",
"Duplicate rows_one": "行を複製",
"Insert row above": "上に行を挿入",
"Delete {{count}} rows_other": "{{count}}行削除",
"Clear values": "値をクリア",
"Clear cell": "セルをクリア",
"Comment": "コメント",
"Duplicate rows_other": "重複行",
"Duplicate rows_other": "行を複製",
"Reset {{count}} columns_one": "列をリセット",
"Insert column to the right": "右側に列を挿入",
"Filter by this value": "この値でフィルタ",
@ -543,7 +554,7 @@
"Code View": "コードビュー",
"Return to viewing as yourself": "自分自身のビューに戻る",
"Raw Data": "生データ",
"Document History": "ドキュメント履歴"
"Document History": "ドキュメント履歴"
},
"menus": {
"Reference List": "参照リスト",
@ -568,7 +579,7 @@
"You do not have edit access to this document": "このドキュメントの編集権限がありません",
"Add Widget to Page": "ページにウィジェットを追加する",
"Add Page": "ページを追加",
"Document owners can attempt to recover the document. [{{error}}]": "ドキュメントの所有者は、ドキュメントの回復を試みることができます。[{{error}}]",
"Document owners can attempt to recover the document. [{{error}}]": "ドキュメントのオーナーは、ドキュメントの回復を試みることができます。[{{error}}]",
"Reload": "再読み込み",
"Error accessing document": "ドキュメントへのアクセスエラー",
"Enter recovery mode": "回復モードに入る"
@ -742,13 +753,13 @@
"Save Copy": "コピーを保存"
},
"UserManagerModel": {
"View & Edit": "表示と編集",
"View & Edit": "閲覧と編集",
"Owner": "オーナー",
"None": "なし",
"View Only": "閲覧のみ",
"No Default Access": "デフォルトアクセスなし",
"Viewer": "ビューア",
"Editor": "エディター"
"Viewer": "閲覧者",
"Editor": "編集者"
},
"DocumentUsage": {
"Data Size": "データサイズ",
@ -937,7 +948,9 @@
"Formulas support many Excel functions, full Python syntax, and include a helpful AI Assistant.": "数式は多くの Excel 関数、完全な Python 構文をサポートし、便利な AI アシスタントが含まれています。",
"A UUID is a randomly-generated string that is useful for unique identifiers and link keys.": "UUIDはランダムに生成される文字列で、一意の識別子やキーとして役立ちます。",
"The total size of all data in this document, excluding attachments.": "添付ファイルを除く、このドキュメント内のすべてのデータの合計サイズ。",
"Lookups return data from related tables.": "Lookupは関連テーブルからデータを返します。"
"Lookups return data from related tables.": "Lookupは関連テーブルからデータを返します。",
"Reference columns are the key to {{relational}} data in Grist.": "参照列は、Grist の {{relational}} データへのキーです。",
"These rules are applied after all column rules have been processed, if applicable.": "これらのルールは、列ルールの処理が終わった後に適用されます。"
},
"AppHeader": {
"Personal Site": "個人サイト",
@ -1108,5 +1121,17 @@
},
"sendToDrive": {
"Sending file to Google Drive": "Googleドライブにファイルを送信する"
},
"CardContextMenu": {
"Insert card": "カードを挿入",
"Duplicate card": "カードを複製",
"Delete card": "カードを削除"
},
"FormView": {
"Publish your form?": "このフォームを公開しますか?",
"Publish": "公開"
},
"Menu": {
"Paragraph": "段落"
}
}

@ -579,7 +579,9 @@
"You do not have write access to this site": "Você não tem acesso de gravação a este site",
"Download full document and history": "Baixe documento completo e histórico",
"Remove all data but keep the structure to use as a template": "Remova todos os dados, mas mantenha a estrutura para usar como um modelo",
"Remove document history (can significantly reduce file size)": "Remova o histórico do documento (pode reduzir significativamente o tamanho do arquivo)"
"Remove document history (can significantly reduce file size)": "Remova o histórico do documento (pode reduzir significativamente o tamanho do arquivo)",
"Download": "Baixar",
"Download document": "Baixar documento"
},
"NTextBox": {
"false": "falso",
@ -1439,5 +1441,15 @@
"FormSuccessPage": {
"Form Submitted": "Formulário enviado",
"Thank you! Your response has been recorded.": "Obrigado! Sua resposta foi registrada."
},
"DateRangeOptions": {
"Last 30 days": "Últimos 30 dias",
"Last 7 days": "Últimos 7 dias",
"Last Week": "Semana passada",
"Next 7 days": "Próximo 7 dias",
"This month": "Este mês",
"This week": "Esta semana",
"This year": "Este ano",
"Today": "Hoje"
}
}

@ -644,7 +644,9 @@
"However, it appears to be already identical.": "Vendar se zdi, da je že identična.",
"Update Original": "Posodobitev izvirnika",
"You do not have write access to this site": "Nimate dovoljenja za pisanje za to spletno mesto",
"Download full document and history": "Prenesite celoten dokument in zgodovino"
"Download full document and history": "Prenesite celoten dokument in zgodovino",
"Download": "Prenesi",
"Download document": "Prenesi dokument"
},
"SortConfig": {
"Add Column": "Dodaj stolpec",
@ -1375,5 +1377,15 @@
"FormSuccessPage": {
"Form Submitted": "Obrazec oddan",
"Thank you! Your response has been recorded.": "Hvala ti! Tvoj odgovor je bil zabeležen."
},
"DateRangeOptions": {
"Last 30 days": "Zadnjih 30 dni",
"Last 7 days": "Zadnjih 7 dni",
"Last Week": "Zadnji teden",
"Next 7 days": "Naslednjih 7 dni",
"This month": "Ta mesec",
"This week": "Ta teden",
"This year": "To leto",
"Today": "Danes"
}
}

@ -0,0 +1,852 @@
/**
* Credit: https://github.com/Amoenus/SwaggerDark/ (MIT License)
*/
@media only screen and (prefers-color-scheme: dark) {
a { color: #8c8cfa; }
::-webkit-scrollbar-track-piece { background-color: rgba(255, 255, 255, .2) !important; }
::-webkit-scrollbar-track { background-color: rgba(255, 255, 255, .3) !important; }
::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, .5) !important; }
embed[type="application/pdf"] { filter: invert(90%); }
html {
background: #1f1f1f !important;
box-sizing: border-box;
filter: contrast(100%) brightness(100%) saturate(100%);
overflow-y: scroll;
}
body {
background: #1f1f1f;
background-color: #1f1f1f;
background-image: none !important;
}
button, input, select, textarea {
background-color: #1f1f1f;
color: #bfbfbf;
}
font, html { color: #bfbfbf; }
.swagger-ui, .swagger-ui section h3 { color: #b5bac9; }
.swagger-ui a { background-color: transparent; }
.swagger-ui mark {
background-color: #664b00;
color: #bfbfbf;
}
.swagger-ui legend { color: inherit; }
.swagger-ui .debug * { outline: #e6da99 solid 1px; }
.swagger-ui .debug-white * { outline: #fff solid 1px; }
.swagger-ui .debug-black * { outline: #bfbfbf solid 1px; }
.swagger-ui .debug-grid { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) 0 0; }
.swagger-ui .debug-grid-16 { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) 0 0; }
.swagger-ui .debug-grid-8-solid { background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) 0 0 #1c1c21; }
.swagger-ui .debug-grid-16-solid { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) 0 0 #1c1c21; }
.swagger-ui .b--black { border-color: #000; }
.swagger-ui .b--near-black { border-color: #121212; }
.swagger-ui .b--dark-gray { border-color: #333; }
.swagger-ui .b--mid-gray { border-color: #545454; }
.swagger-ui .b--gray { border-color: #787878; }
.swagger-ui .b--silver { border-color: #999; }
.swagger-ui .b--light-silver { border-color: #6e6e6e; }
.swagger-ui .b--moon-gray { border-color: #4d4d4d; }
.swagger-ui .b--light-gray { border-color: #2b2b2b; }
.swagger-ui .b--near-white { border-color: #242424; }
.swagger-ui .b--white { border-color: #1c1c21; }
.swagger-ui .b--white-90 { border-color: rgba(28, 28, 33, .9); }
.swagger-ui .b--white-80 { border-color: rgba(28, 28, 33, .8); }
.swagger-ui .b--white-70 { border-color: rgba(28, 28, 33, .7); }
.swagger-ui .b--white-60 { border-color: rgba(28, 28, 33, .6); }
.swagger-ui .b--white-50 { border-color: rgba(28, 28, 33, .5); }
.swagger-ui .b--white-40 { border-color: rgba(28, 28, 33, .4); }
.swagger-ui .b--white-30 { border-color: rgba(28, 28, 33, .3); }
.swagger-ui .b--white-20 { border-color: rgba(28, 28, 33, .2); }
.swagger-ui .b--white-10 { border-color: rgba(28, 28, 33, .1); }
.swagger-ui .b--white-05 { border-color: rgba(28, 28, 33, .05); }
.swagger-ui .b--white-025 { border-color: rgba(28, 28, 33, .024); }
.swagger-ui .b--white-0125 { border-color: rgba(28, 28, 33, .01); }
.swagger-ui .b--black-90 { border-color: rgba(0, 0, 0, .9); }
.swagger-ui .b--black-80 { border-color: rgba(0, 0, 0, .8); }
.swagger-ui .b--black-70 { border-color: rgba(0, 0, 0, .7); }
.swagger-ui .b--black-60 { border-color: rgba(0, 0, 0, .6); }
.swagger-ui .b--black-50 { border-color: rgba(0, 0, 0, .5); }
.swagger-ui .b--black-40 { border-color: rgba(0, 0, 0, .4); }
.swagger-ui .b--black-30 { border-color: rgba(0, 0, 0, .3); }
.swagger-ui .b--black-20 { border-color: rgba(0, 0, 0, .2); }
.swagger-ui .b--black-10 { border-color: rgba(0, 0, 0, .1); }
.swagger-ui .b--black-05 { border-color: rgba(0, 0, 0, .05); }
.swagger-ui .b--black-025 { border-color: rgba(0, 0, 0, .024); }
.swagger-ui .b--black-0125 { border-color: rgba(0, 0, 0, .01); }
.swagger-ui .b--dark-red { border-color: #bc2f36; }
.swagger-ui .b--red { border-color: #c83932; }
.swagger-ui .b--light-red { border-color: #ab3c2b; }
.swagger-ui .b--orange { border-color: #cc6e33; }
.swagger-ui .b--purple { border-color: #5e2ca5; }
.swagger-ui .b--light-purple { border-color: #672caf; }
.swagger-ui .b--dark-pink { border-color: #ab2b81; }
.swagger-ui .b--hot-pink { border-color: #c03086; }
.swagger-ui .b--pink { border-color: #8f2464; }
.swagger-ui .b--light-pink { border-color: #721d4d; }
.swagger-ui .b--dark-green { border-color: #1c6e50; }
.swagger-ui .b--green { border-color: #279b70; }
.swagger-ui .b--light-green { border-color: #228762; }
.swagger-ui .b--navy { border-color: #0d1d35; }
.swagger-ui .b--dark-blue { border-color: #20497e; }
.swagger-ui .b--blue { border-color: #4380d0; }
.swagger-ui .b--light-blue { border-color: #20517e; }
.swagger-ui .b--lightest-blue { border-color: #143a52; }
.swagger-ui .b--washed-blue { border-color: #0c312d; }
.swagger-ui .b--washed-green { border-color: #0f3d2c; }
.swagger-ui .b--washed-red { border-color: #411010; }
.swagger-ui .b--transparent { border-color: transparent; }
.swagger-ui .b--gold, .swagger-ui .b--light-yellow, .swagger-ui .b--washed-yellow, .swagger-ui .b--yellow { border-color: #664b00; }
.swagger-ui .shadow-1 { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }
.swagger-ui .shadow-2 { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }
.swagger-ui .shadow-3 { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }
.swagger-ui .shadow-4 { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }
.swagger-ui .shadow-5 { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }
@media screen and (min-width: 30em) {
.swagger-ui .shadow-1-ns { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }
.swagger-ui .shadow-2-ns { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }
.swagger-ui .shadow-3-ns { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }
.swagger-ui .shadow-4-ns { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }
.swagger-ui .shadow-5-ns { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }
}
@media screen and (max-width: 60em) and (min-width: 30em) {
.swagger-ui .shadow-1-m { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }
.swagger-ui .shadow-2-m { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }
.swagger-ui .shadow-3-m { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }
.swagger-ui .shadow-4-m { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }
.swagger-ui .shadow-5-m { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }
}
@media screen and (min-width: 60em) {
.swagger-ui .shadow-1-l { box-shadow: rgba(0, 0, 0, .2) 0 0 4px 2px; }
.swagger-ui .shadow-2-l { box-shadow: rgba(0, 0, 0, .2) 0 0 8px 2px; }
.swagger-ui .shadow-3-l { box-shadow: rgba(0, 0, 0, .2) 2px 2px 4px 2px; }
.swagger-ui .shadow-4-l { box-shadow: rgba(0, 0, 0, .2) 2px 2px 8px 0; }
.swagger-ui .shadow-5-l { box-shadow: rgba(0, 0, 0, .2) 4px 4px 8px 0; }
}
.swagger-ui .black-05 { color: rgba(191, 191, 191, .05); }
.swagger-ui .bg-black-05 { background-color: rgba(0, 0, 0, .05); }
.swagger-ui .black-90, .swagger-ui .hover-black-90:focus, .swagger-ui .hover-black-90:hover { color: rgba(191, 191, 191, .9); }
.swagger-ui .black-80, .swagger-ui .hover-black-80:focus, .swagger-ui .hover-black-80:hover { color: rgba(191, 191, 191, .8); }
.swagger-ui .black-70, .swagger-ui .hover-black-70:focus, .swagger-ui .hover-black-70:hover { color: rgba(191, 191, 191, .7); }
.swagger-ui .black-60, .swagger-ui .hover-black-60:focus, .swagger-ui .hover-black-60:hover { color: rgba(191, 191, 191, .6); }
.swagger-ui .black-50, .swagger-ui .hover-black-50:focus, .swagger-ui .hover-black-50:hover { color: rgba(191, 191, 191, .5); }
.swagger-ui .black-40, .swagger-ui .hover-black-40:focus, .swagger-ui .hover-black-40:hover { color: rgba(191, 191, 191, .4); }
.swagger-ui .black-30, .swagger-ui .hover-black-30:focus, .swagger-ui .hover-black-30:hover { color: rgba(191, 191, 191, .3); }
.swagger-ui .black-20, .swagger-ui .hover-black-20:focus, .swagger-ui .hover-black-20:hover { color: rgba(191, 191, 191, .2); }
.swagger-ui .black-10, .swagger-ui .hover-black-10:focus, .swagger-ui .hover-black-10:hover { color: rgba(191, 191, 191, .1); }
.swagger-ui .hover-white-90:focus, .swagger-ui .hover-white-90:hover, .swagger-ui .white-90 { color: rgba(255, 255, 255, .9); }
.swagger-ui .hover-white-80:focus, .swagger-ui .hover-white-80:hover, .swagger-ui .white-80 { color: rgba(255, 255, 255, .8); }
.swagger-ui .hover-white-70:focus, .swagger-ui .hover-white-70:hover, .swagger-ui .white-70 { color: rgba(255, 255, 255, .7); }
.swagger-ui .hover-white-60:focus, .swagger-ui .hover-white-60:hover, .swagger-ui .white-60 { color: rgba(255, 255, 255, .6); }
.swagger-ui .hover-white-50:focus, .swagger-ui .hover-white-50:hover, .swagger-ui .white-50 { color: rgba(255, 255, 255, .5); }
.swagger-ui .hover-white-40:focus, .swagger-ui .hover-white-40:hover, .swagger-ui .white-40 { color: rgba(255, 255, 255, .4); }
.swagger-ui .hover-white-30:focus, .swagger-ui .hover-white-30:hover, .swagger-ui .white-30 { color: rgba(255, 255, 255, .3); }
.swagger-ui .hover-white-20:focus, .swagger-ui .hover-white-20:hover, .swagger-ui .white-20 { color: rgba(255, 255, 255, .2); }
.swagger-ui .hover-white-10:focus, .swagger-ui .hover-white-10:hover, .swagger-ui .white-10 { color: rgba(255, 255, 255, .1); }
.swagger-ui .hover-moon-gray:focus, .swagger-ui .hover-moon-gray:hover, .swagger-ui .moon-gray { color: #ccc; }
.swagger-ui .hover-light-gray:focus, .swagger-ui .hover-light-gray:hover, .swagger-ui .light-gray { color: #ededed; }
.swagger-ui .hover-near-white:focus, .swagger-ui .hover-near-white:hover, .swagger-ui .near-white { color: #f5f5f5; }
.swagger-ui .dark-red, .swagger-ui .hover-dark-red:focus, .swagger-ui .hover-dark-red:hover { color: #e6999d; }
.swagger-ui .hover-red:focus, .swagger-ui .hover-red:hover, .swagger-ui .red { color: #e69d99; }
.swagger-ui .hover-light-red:focus, .swagger-ui .hover-light-red:hover, .swagger-ui .light-red { color: #e6a399; }
.swagger-ui .hover-orange:focus, .swagger-ui .hover-orange:hover, .swagger-ui .orange { color: #e6b699; }
.swagger-ui .gold, .swagger-ui .hover-gold:focus, .swagger-ui .hover-gold:hover { color: #e6d099; }
.swagger-ui .hover-yellow:focus, .swagger-ui .hover-yellow:hover, .swagger-ui .yellow { color: #e6da99; }
.swagger-ui .hover-light-yellow:focus, .swagger-ui .hover-light-yellow:hover, .swagger-ui .light-yellow { color: #ede6b6; }
.swagger-ui .hover-purple:focus, .swagger-ui .hover-purple:hover, .swagger-ui .purple { color: #b99ae4; }
.swagger-ui .hover-light-purple:focus, .swagger-ui .hover-light-purple:hover, .swagger-ui .light-purple { color: #bb99e6; }
.swagger-ui .dark-pink, .swagger-ui .hover-dark-pink:focus, .swagger-ui .hover-dark-pink:hover { color: #e699cc; }
.swagger-ui .hot-pink, .swagger-ui .hover-hot-pink:focus, .swagger-ui .hover-hot-pink:hover, .swagger-ui .hover-pink:focus, .swagger-ui .hover-pink:hover, .swagger-ui .pink { color: #e699c7; }
.swagger-ui .hover-light-pink:focus, .swagger-ui .hover-light-pink:hover, .swagger-ui .light-pink { color: #edb6d5; }
.swagger-ui .dark-green, .swagger-ui .green, .swagger-ui .hover-dark-green:focus, .swagger-ui .hover-dark-green:hover, .swagger-ui .hover-green:focus, .swagger-ui .hover-green:hover { color: #99e6c9; }
.swagger-ui .hover-light-green:focus, .swagger-ui .hover-light-green:hover, .swagger-ui .light-green { color: #a1e8ce; }
.swagger-ui .hover-navy:focus, .swagger-ui .hover-navy:hover, .swagger-ui .navy { color: #99b8e6; }
.swagger-ui .blue, .swagger-ui .dark-blue, .swagger-ui .hover-blue:focus, .swagger-ui .hover-blue:hover, .swagger-ui .hover-dark-blue:focus, .swagger-ui .hover-dark-blue:hover { color: #99bae6; }
.swagger-ui .hover-light-blue:focus, .swagger-ui .hover-light-blue:hover, .swagger-ui .light-blue { color: #a9cbea; }
.swagger-ui .hover-lightest-blue:focus, .swagger-ui .hover-lightest-blue:hover, .swagger-ui .lightest-blue { color: #d6e9f5; }
.swagger-ui .hover-washed-blue:focus, .swagger-ui .hover-washed-blue:hover, .swagger-ui .washed-blue { color: #f7fdfc; }
.swagger-ui .hover-washed-green:focus, .swagger-ui .hover-washed-green:hover, .swagger-ui .washed-green { color: #ebfaf4; }
.swagger-ui .hover-washed-yellow:focus, .swagger-ui .hover-washed-yellow:hover, .swagger-ui .washed-yellow { color: #fbf9ef; }
.swagger-ui .hover-washed-red:focus, .swagger-ui .hover-washed-red:hover, .swagger-ui .washed-red { color: #f9e7e7; }
.swagger-ui .color-inherit, .swagger-ui .hover-inherit:focus, .swagger-ui .hover-inherit:hover { color: inherit; }
.swagger-ui .bg-black-90, .swagger-ui .hover-bg-black-90:focus, .swagger-ui .hover-bg-black-90:hover { background-color: rgba(0, 0, 0, .9); }
.swagger-ui .bg-black-80, .swagger-ui .hover-bg-black-80:focus, .swagger-ui .hover-bg-black-80:hover { background-color: rgba(0, 0, 0, .8); }
.swagger-ui .bg-black-70, .swagger-ui .hover-bg-black-70:focus, .swagger-ui .hover-bg-black-70:hover { background-color: rgba(0, 0, 0, .7); }
.swagger-ui .bg-black-60, .swagger-ui .hover-bg-black-60:focus, .swagger-ui .hover-bg-black-60:hover { background-color: rgba(0, 0, 0, .6); }
.swagger-ui .bg-black-50, .swagger-ui .hover-bg-black-50:focus, .swagger-ui .hover-bg-black-50:hover { background-color: rgba(0, 0, 0, .5); }
.swagger-ui .bg-black-40, .swagger-ui .hover-bg-black-40:focus, .swagger-ui .hover-bg-black-40:hover { background-color: rgba(0, 0, 0, .4); }
.swagger-ui .bg-black-30, .swagger-ui .hover-bg-black-30:focus, .swagger-ui .hover-bg-black-30:hover { background-color: rgba(0, 0, 0, .3); }
.swagger-ui .bg-black-20, .swagger-ui .hover-bg-black-20:focus, .swagger-ui .hover-bg-black-20:hover { background-color: rgba(0, 0, 0, .2); }
.swagger-ui .bg-white-90, .swagger-ui .hover-bg-white-90:focus, .swagger-ui .hover-bg-white-90:hover { background-color: rgba(28, 28, 33, .9); }
.swagger-ui .bg-white-80, .swagger-ui .hover-bg-white-80:focus, .swagger-ui .hover-bg-white-80:hover { background-color: rgba(28, 28, 33, .8); }
.swagger-ui .bg-white-70, .swagger-ui .hover-bg-white-70:focus, .swagger-ui .hover-bg-white-70:hover { background-color: rgba(28, 28, 33, .7); }
.swagger-ui .bg-white-60, .swagger-ui .hover-bg-white-60:focus, .swagger-ui .hover-bg-white-60:hover { background-color: rgba(28, 28, 33, .6); }
.swagger-ui .bg-white-50, .swagger-ui .hover-bg-white-50:focus, .swagger-ui .hover-bg-white-50:hover { background-color: rgba(28, 28, 33, .5); }
.swagger-ui .bg-white-40, .swagger-ui .hover-bg-white-40:focus, .swagger-ui .hover-bg-white-40:hover { background-color: rgba(28, 28, 33, .4); }
.swagger-ui .bg-white-30, .swagger-ui .hover-bg-white-30:focus, .swagger-ui .hover-bg-white-30:hover { background-color: rgba(28, 28, 33, .3); }
.swagger-ui .bg-white-20, .swagger-ui .hover-bg-white-20:focus, .swagger-ui .hover-bg-white-20:hover { background-color: rgba(28, 28, 33, .2); }
.swagger-ui .bg-black, .swagger-ui .hover-bg-black:focus, .swagger-ui .hover-bg-black:hover { background-color: #000; }
.swagger-ui .bg-near-black, .swagger-ui .hover-bg-near-black:focus, .swagger-ui .hover-bg-near-black:hover { background-color: #121212; }
.swagger-ui .bg-dark-gray, .swagger-ui .hover-bg-dark-gray:focus, .swagger-ui .hover-bg-dark-gray:hover { background-color: #333; }
.swagger-ui .bg-mid-gray, .swagger-ui .hover-bg-mid-gray:focus, .swagger-ui .hover-bg-mid-gray:hover { background-color: #545454; }
.swagger-ui .bg-gray, .swagger-ui .hover-bg-gray:focus, .swagger-ui .hover-bg-gray:hover { background-color: #787878; }
.swagger-ui .bg-silver, .swagger-ui .hover-bg-silver:focus, .swagger-ui .hover-bg-silver:hover { background-color: #999; }
.swagger-ui .bg-white, .swagger-ui .hover-bg-white:focus, .swagger-ui .hover-bg-white:hover { background-color: #1c1c21; }
.swagger-ui .bg-transparent, .swagger-ui .hover-bg-transparent:focus, .swagger-ui .hover-bg-transparent:hover { background-color: transparent; }
.swagger-ui .bg-dark-red, .swagger-ui .hover-bg-dark-red:focus, .swagger-ui .hover-bg-dark-red:hover { background-color: #bc2f36; }
.swagger-ui .bg-red, .swagger-ui .hover-bg-red:focus, .swagger-ui .hover-bg-red:hover { background-color: #c83932; }
.swagger-ui .bg-light-red, .swagger-ui .hover-bg-light-red:focus, .swagger-ui .hover-bg-light-red:hover { background-color: #ab3c2b; }
.swagger-ui .bg-orange, .swagger-ui .hover-bg-orange:focus, .swagger-ui .hover-bg-orange:hover { background-color: #cc6e33; }
.swagger-ui .bg-gold, .swagger-ui .bg-light-yellow, .swagger-ui .bg-washed-yellow, .swagger-ui .bg-yellow, .swagger-ui .hover-bg-gold:focus, .swagger-ui .hover-bg-gold:hover, .swagger-ui .hover-bg-light-yellow:focus, .swagger-ui .hover-bg-light-yellow:hover, .swagger-ui .hover-bg-washed-yellow:focus, .swagger-ui .hover-bg-washed-yellow:hover, .swagger-ui .hover-bg-yellow:focus, .swagger-ui .hover-bg-yellow:hover { background-color: #664b00; }
.swagger-ui .bg-purple, .swagger-ui .hover-bg-purple:focus, .swagger-ui .hover-bg-purple:hover { background-color: #5e2ca5; }
.swagger-ui .bg-light-purple, .swagger-ui .hover-bg-light-purple:focus, .swagger-ui .hover-bg-light-purple:hover { background-color: #672caf; }
.swagger-ui .bg-dark-pink, .swagger-ui .hover-bg-dark-pink:focus, .swagger-ui .hover-bg-dark-pink:hover { background-color: #ab2b81; }
.swagger-ui .bg-hot-pink, .swagger-ui .hover-bg-hot-pink:focus, .swagger-ui .hover-bg-hot-pink:hover { background-color: #c03086; }
.swagger-ui .bg-pink, .swagger-ui .hover-bg-pink:focus, .swagger-ui .hover-bg-pink:hover { background-color: #8f2464; }
.swagger-ui .bg-light-pink, .swagger-ui .hover-bg-light-pink:focus, .swagger-ui .hover-bg-light-pink:hover { background-color: #721d4d; }
.swagger-ui .bg-dark-green, .swagger-ui .hover-bg-dark-green:focus, .swagger-ui .hover-bg-dark-green:hover { background-color: #1c6e50; }
.swagger-ui .bg-green, .swagger-ui .hover-bg-green:focus, .swagger-ui .hover-bg-green:hover { background-color: #279b70; }
.swagger-ui .bg-light-green, .swagger-ui .hover-bg-light-green:focus, .swagger-ui .hover-bg-light-green:hover { background-color: #228762; }
.swagger-ui .bg-navy, .swagger-ui .hover-bg-navy:focus, .swagger-ui .hover-bg-navy:hover { background-color: #0d1d35; }
.swagger-ui .bg-dark-blue, .swagger-ui .hover-bg-dark-blue:focus, .swagger-ui .hover-bg-dark-blue:hover { background-color: #20497e; }
.swagger-ui .bg-blue, .swagger-ui .hover-bg-blue:focus, .swagger-ui .hover-bg-blue:hover { background-color: #4380d0; }
.swagger-ui .bg-light-blue, .swagger-ui .hover-bg-light-blue:focus, .swagger-ui .hover-bg-light-blue:hover { background-color: #20517e; }
.swagger-ui .bg-lightest-blue, .swagger-ui .hover-bg-lightest-blue:focus, .swagger-ui .hover-bg-lightest-blue:hover { background-color: #143a52; }
.swagger-ui .bg-washed-blue, .swagger-ui .hover-bg-washed-blue:focus, .swagger-ui .hover-bg-washed-blue:hover { background-color: #0c312d; }
.swagger-ui .bg-washed-green, .swagger-ui .hover-bg-washed-green:focus, .swagger-ui .hover-bg-washed-green:hover { background-color: #0f3d2c; }
.swagger-ui .bg-washed-red, .swagger-ui .hover-bg-washed-red:focus, .swagger-ui .hover-bg-washed-red:hover { background-color: #411010; }
.swagger-ui .bg-inherit, .swagger-ui .hover-bg-inherit:focus, .swagger-ui .hover-bg-inherit:hover { background-color: inherit; }
.swagger-ui .shadow-hover { transition: all .5s cubic-bezier(.165, .84, .44, 1) 0s; }
.swagger-ui .shadow-hover::after {
border-radius: inherit;
box-shadow: rgba(0, 0, 0, .2) 0 0 16px 2px;
content: "";
height: 100%;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transition: opacity .5s cubic-bezier(.165, .84, .44, 1) 0s;
width: 100%;
z-index: -1;
}
.swagger-ui .bg-animate, .swagger-ui .bg-animate:focus, .swagger-ui .bg-animate:hover { transition: background-color .15s ease-in-out 0s; }
.swagger-ui .nested-links a {
color: #99bae6;
transition: color .15s ease-in 0s;
}
.swagger-ui .nested-links a:focus, .swagger-ui .nested-links a:hover {
color: #a9cbea;
transition: color .15s ease-in 0s;
}
.swagger-ui .opblock-tag {
border-bottom: 1px solid rgba(58, 64, 80, .3);
color: #b5bac9;
transition: all .2s ease 0s;
}
.swagger-ui .opblock-tag svg, .swagger-ui section.models h4 svg { transition: all .4s ease 0s; }
.swagger-ui .opblock {
border: 1px solid #000;
border-radius: 4px;
box-shadow: rgba(0, 0, 0, .19) 0 0 3px;
margin: 0 0 15px;
}
.swagger-ui .opblock .tab-header .tab-item.active h4 span::after { background: gray; }
.swagger-ui .opblock.is-open .opblock-summary { border-bottom: 1px solid #000; }
.swagger-ui .opblock .opblock-section-header {
background: rgba(28, 28, 33, .8);
box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;
}
.swagger-ui .opblock .opblock-section-header > label > span { padding: 0 10px 0 0; }
.swagger-ui .opblock .opblock-summary-method {
background: #000;
color: #fff;
text-shadow: rgba(0, 0, 0, .1) 0 1px 0;
}
.swagger-ui .opblock.opblock-post {
background: rgba(72, 203, 144, .1);
border-color: #48cb90;
}
.swagger-ui .opblock.opblock-post .opblock-summary-method, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { background: #48cb90; }
.swagger-ui .opblock.opblock-post .opblock-summary { border-color: #48cb90; }
.swagger-ui .opblock.opblock-put {
background: rgba(213, 157, 88, .1);
border-color: #d59d58;
}
.swagger-ui .opblock.opblock-put .opblock-summary-method, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after { background: #d59d58; }
.swagger-ui .opblock.opblock-put .opblock-summary { border-color: #d59d58; }
.swagger-ui .opblock.opblock-delete {
background: rgba(200, 50, 50, .1);
border-color: #c83232;
}
.swagger-ui .opblock.opblock-delete .opblock-summary-method, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { background: #c83232; }
.swagger-ui .opblock.opblock-delete .opblock-summary { border-color: #c83232; }
.swagger-ui .opblock.opblock-get {
background: rgba(42, 105, 167, .1);
border-color: #2a69a7;
}
.swagger-ui .opblock.opblock-get .opblock-summary-method, .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { background: #2a69a7; }
.swagger-ui .opblock.opblock-get .opblock-summary { border-color: #2a69a7; }
.swagger-ui .opblock.opblock-patch {
background: rgba(92, 214, 188, .1);
border-color: #5cd6bc;
}
.swagger-ui .opblock.opblock-patch .opblock-summary-method, .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after { background: #5cd6bc; }
.swagger-ui .opblock.opblock-patch .opblock-summary { border-color: #5cd6bc; }
.swagger-ui .opblock.opblock-head {
background: rgba(140, 63, 207, .1);
border-color: #8c3fcf;
}
.swagger-ui .opblock.opblock-head .opblock-summary-method, .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after { background: #8c3fcf; }
.swagger-ui .opblock.opblock-head .opblock-summary { border-color: #8c3fcf; }
.swagger-ui .opblock.opblock-options {
background: rgba(36, 89, 143, .1);
border-color: #24598f;
}
.swagger-ui .opblock.opblock-options .opblock-summary-method, .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after { background: #24598f; }
.swagger-ui .opblock.opblock-options .opblock-summary { border-color: #24598f; }
.swagger-ui .opblock.opblock-deprecated {
background: rgba(46, 46, 46, .1);
border-color: #2e2e2e;
opacity: .6;
}
.swagger-ui .opblock.opblock-deprecated .opblock-summary-method, .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after { background: #2e2e2e; }
.swagger-ui .opblock.opblock-deprecated .opblock-summary { border-color: #2e2e2e; }
.swagger-ui .filter .operation-filter-input { border: 2px solid #2b3446; }
.swagger-ui .tab li:first-of-type::after { background: rgba(0, 0, 0, .2); }
.swagger-ui .download-contents {
background: #7c8192;
color: #fff;
}
.swagger-ui .scheme-container {
background: #1c1c21;
box-shadow: rgba(0, 0, 0, .15) 0 1px 2px 0;
}
.swagger-ui .loading-container .loading::before {
animation: 1s linear 0s infinite normal none running rotation, .5s ease 0s 1 normal none running opacity;
border-color: rgba(0, 0, 0, .6) rgba(84, 84, 84, .1) rgba(84, 84, 84, .1);
}
.swagger-ui .response-control-media-type--accept-controller select { border-color: #196619; }
.swagger-ui .response-control-media-type__accept-message { color: #99e699; }
.swagger-ui .version-pragma__message code { background-color: #3b3b3b; }
.swagger-ui .btn {
background: 0 0;
border: 2px solid gray;
box-shadow: rgba(0, 0, 0, .1) 0 1px 2px;
color: #b5bac9;
}
.swagger-ui .btn:hover { box-shadow: rgba(0, 0, 0, .3) 0 0 5px; }
.swagger-ui .btn.authorize, .swagger-ui .btn.cancel {
background-color: transparent;
border-color: #a72a2a;
color: #e69999;
}
.swagger-ui .btn.cancel:hover {
background-color: #a72a2a;
color: #fff;
}
.swagger-ui .btn.authorize {
border-color: #48cb90;
color: #9ce3c3;
}
.swagger-ui .btn.authorize svg { fill: #9ce3c3; }
.btn.authorize.unlocked:hover {
background-color: #48cb90;
color: #fff;
}
.btn.authorize.unlocked:hover svg {
fill: #fbfbfb;
}
.swagger-ui .btn.execute {
background-color: #5892d5;
border-color: #5892d5;
color: #fff;
}
.swagger-ui .copy-to-clipboard { background: #7c8192; }
.swagger-ui .copy-to-clipboard button { background: url("data:image/svg+xml;charset=utf-8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path fill=\"%23fff\" fill-rule=\"evenodd\" d=\"M2 13h4v1H2v-1zm5-6H2v1h5V7zm2 3V8l-3 3 3 3v-2h5v-2H9zM4.5 9H2v1h2.5V9zM2 12h2.5v-1H2v1zm9 1h1v2c-.02.28-.11.52-.3.7-.19.18-.42.28-.7.3H1c-.55 0-1-.45-1-1V4c0-.55.45-1 1-1h3c0-1.11.89-2 2-2 1.11 0 2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V6H1v9h10v-2zM2 5h8c0-.55-.45-1-1-1H8c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H3c-.55 0-1 .45-1 1z\"/></svg>") 50% center no-repeat; }
.swagger-ui select {
background: url("data:image/svg+xml;charset=utf-8,<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 20 20\"><path d=\"M13.418 7.859a.695.695 0 01.978 0 .68.68 0 010 .969l-3.908 3.83a.697.697 0 01-.979 0l-3.908-3.83a.68.68 0 010-.969.695.695 0 01.978 0L10 11l3.418-3.141z\"/></svg>") right 10px center/20px no-repeat #212121;
background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) right 10px center/20px no-repeat #1c1c21;
border: 2px solid #41444e;
}
.swagger-ui select[multiple] { background: #212121; }
.swagger-ui button.invalid, .swagger-ui input[type=email].invalid, .swagger-ui input[type=file].invalid, .swagger-ui input[type=password].invalid, .swagger-ui input[type=search].invalid, .swagger-ui input[type=text].invalid, .swagger-ui select.invalid, .swagger-ui textarea.invalid {
background: #390e0e;
border-color: #c83232;
}
.swagger-ui input[type=email], .swagger-ui input[type=file], .swagger-ui input[type=password], .swagger-ui input[type=search], .swagger-ui input[type=text], .swagger-ui textarea {
background: #1c1c21;
border: 1px solid #404040;
}
.swagger-ui textarea {
background: rgba(28, 28, 33, .8);
color: #b5bac9;
}
.swagger-ui input[disabled], .swagger-ui select[disabled] {
background-color: #1f1f1f;
color: #bfbfbf;
}
.swagger-ui textarea[disabled] {
background-color: #41444e;
color: #fff;
}
.swagger-ui select[disabled] { border-color: #878787; }
.swagger-ui textarea:focus { border: 2px solid #2a69a7; }
.swagger-ui .checkbox input[type=checkbox] + label > .item {
background: #303030;
box-shadow: #303030 0 0 0 2px;
}
.swagger-ui .checkbox input[type=checkbox]:checked + label > .item { background: url("data:image/svg+xml;charset=utf-8,<svg width=\"10\" height=\"8\" viewBox=\"3 7 10 8\" xmlns=\"http://www.w3.org/2000/svg\"><path fill=\"%2341474E\" fill-rule=\"evenodd\" d=\"M6.333 15L3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z\"/></svg>") 50% center no-repeat #303030; }
.swagger-ui .dialog-ux .backdrop-ux { background: rgba(0, 0, 0, .8); }
.swagger-ui .dialog-ux .modal-ux {
background: #1c1c21;
border: 1px solid #2e2e2e;
box-shadow: rgba(0, 0, 0, .2) 0 10px 30px 0;
}
.swagger-ui .dialog-ux .modal-ux-header .close-modal { background: 0 0; }
.swagger-ui .model .deprecated span, .swagger-ui .model .deprecated td { color: #bfbfbf !important; }
.swagger-ui .model-toggle::after { background: url("data:image/svg+xml;charset=utf-8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\"><path d=\"M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z\"/></svg>") 50% center/100% no-repeat; }
.swagger-ui .model-hint {
background: rgba(0, 0, 0, .7);
color: #ebebeb;
}
.swagger-ui section.models { border: 1px solid rgba(58, 64, 80, .3); }
.swagger-ui section.models.is-open h4 { border-bottom: 1px solid rgba(58, 64, 80, .3); }
.swagger-ui section.models .model-container { background: rgba(0, 0, 0, .05); }
.swagger-ui section.models .model-container:hover { background: rgba(0, 0, 0, .07); }
.swagger-ui .model-box { background: rgba(0, 0, 0, .1); }
.swagger-ui .prop-type { color: #aaaad4; }
.swagger-ui table thead tr td, .swagger-ui table thead tr th {
border-bottom: 1px solid rgba(58, 64, 80, .2);
color: #b5bac9;
}
.swagger-ui .parameter__name.required::after { color: rgba(230, 153, 153, .6); }
.swagger-ui .topbar .download-url-wrapper .select-label { color: #f0f0f0; }
.swagger-ui .topbar .download-url-wrapper .download-url-button {
background: #63a040;
color: #fff;
}
.swagger-ui .info .title small { background: #7c8492; }
.swagger-ui .info .title small.version-stamp { background-color: #7a9b27; }
.swagger-ui .auth-container .errors {
background-color: #350d0d;
color: #b5bac9;
}
.swagger-ui .errors-wrapper {
background: rgba(200, 50, 50, .1);
border: 2px solid #c83232;
}
.swagger-ui .markdown code, .swagger-ui .renderedmarkdown code {
background: rgba(0, 0, 0, .05);
color: #c299e6;
}
.swagger-ui .model-toggle:after { background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) 50% no-repeat; }
/* arrows for each operation and request are now white */
.arrow, #large-arrow-up { fill: #fff; }
#unlocked { fill: #fff; }
::-webkit-scrollbar-track { background-color: #646464 !important; }
::-webkit-scrollbar-thumb {
background-color: #242424 !important;
border: 2px solid #3e4346 !important;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:vertical:end:increment {
background: linear-gradient(310deg, #696969 40%, transparent 41%), linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background: linear-gradient(210deg, #696969 40%, transparent 41%), linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background: linear-gradient(30deg, #696969 40%, transparent 41%), linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button, ::-webkit-scrollbar-track-piece { background-color: #3e4346 !important; }
.swagger-ui .black, .swagger-ui .checkbox, .swagger-ui .dark-gray, .swagger-ui .download-url-wrapper .loading, .swagger-ui .errors-wrapper .errors small, .swagger-ui .fallback, .swagger-ui .filter .loading, .swagger-ui .gray, .swagger-ui .hover-black:focus, .swagger-ui .hover-black:hover, .swagger-ui .hover-dark-gray:focus, .swagger-ui .hover-dark-gray:hover, .swagger-ui .hover-gray:focus, .swagger-ui .hover-gray:hover, .swagger-ui .hover-light-silver:focus, .swagger-ui .hover-light-silver:hover, .swagger-ui .hover-mid-gray:focus, .swagger-ui .hover-mid-gray:hover, .swagger-ui .hover-near-black:focus, .swagger-ui .hover-near-black:hover, .swagger-ui .hover-silver:focus, .swagger-ui .hover-silver:hover, .swagger-ui .light-silver, .swagger-ui .markdown pre, .swagger-ui .mid-gray, .swagger-ui .model .property, .swagger-ui .model .property.primitive, .swagger-ui .model-title, .swagger-ui .near-black, .swagger-ui .parameter__extension, .swagger-ui .parameter__in, .swagger-ui .prop-format, .swagger-ui .renderedmarkdown pre, .swagger-ui .response-col_links .response-undocumented, .swagger-ui .response-col_status .response-undocumented, .swagger-ui .silver, .swagger-ui section.models h4, .swagger-ui section.models h5, .swagger-ui span.token-not-formatted, .swagger-ui span.token-string, .swagger-ui table.headers .header-example, .swagger-ui table.model tr.description, .swagger-ui table.model tr.extension { color: #bfbfbf; }
.swagger-ui .hover-white:focus, .swagger-ui .hover-white:hover, .swagger-ui .info .title small pre, .swagger-ui .topbar a, .swagger-ui .white { color: #fff; }
.swagger-ui .bg-black-10, .swagger-ui .hover-bg-black-10:focus, .swagger-ui .hover-bg-black-10:hover, .swagger-ui .stripe-dark:nth-child(2n + 1) { background-color: rgba(0, 0, 0, .1); }
.swagger-ui .bg-white-10, .swagger-ui .hover-bg-white-10:focus, .swagger-ui .hover-bg-white-10:hover, .swagger-ui .stripe-light:nth-child(2n + 1) { background-color: rgba(28, 28, 33, .1); }
.swagger-ui .bg-light-silver, .swagger-ui .hover-bg-light-silver:focus, .swagger-ui .hover-bg-light-silver:hover, .swagger-ui .striped--light-silver:nth-child(2n + 1) { background-color: #6e6e6e; }
.swagger-ui .bg-moon-gray, .swagger-ui .hover-bg-moon-gray:focus, .swagger-ui .hover-bg-moon-gray:hover, .swagger-ui .striped--moon-gray:nth-child(2n + 1) { background-color: #4d4d4d; }
.swagger-ui .bg-light-gray, .swagger-ui .hover-bg-light-gray:focus, .swagger-ui .hover-bg-light-gray:hover, .swagger-ui .striped--light-gray:nth-child(2n + 1) { background-color: #2b2b2b; }
.swagger-ui .bg-near-white, .swagger-ui .hover-bg-near-white:focus, .swagger-ui .hover-bg-near-white:hover, .swagger-ui .striped--near-white:nth-child(2n + 1) { background-color: #242424; }
.swagger-ui .opblock-tag:hover, .swagger-ui section.models h4:hover { background: rgba(0, 0, 0, .02); }
.swagger-ui .checkbox p, .swagger-ui .dialog-ux .modal-ux-content h4, .swagger-ui .dialog-ux .modal-ux-content p, .swagger-ui .dialog-ux .modal-ux-header h3, .swagger-ui .errors-wrapper .errors h4, .swagger-ui .errors-wrapper hgroup h4, .swagger-ui .info .base-url, .swagger-ui .info .title, .swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3, .swagger-ui .info h4, .swagger-ui .info h5, .swagger-ui .info li, .swagger-ui .info p, .swagger-ui .info table, .swagger-ui .loading-container .loading::after, .swagger-ui .model, .swagger-ui .opblock .opblock-section-header h4, .swagger-ui .opblock .opblock-section-header > label, .swagger-ui .opblock .opblock-summary-description, .swagger-ui .opblock .opblock-summary-operation-id, .swagger-ui .opblock .opblock-summary-path, .swagger-ui .opblock .opblock-summary-path__deprecated, .swagger-ui .opblock-description-wrapper, .swagger-ui .opblock-description-wrapper h4, .swagger-ui .opblock-description-wrapper p, .swagger-ui .opblock-external-docs-wrapper, .swagger-ui .opblock-external-docs-wrapper h4, .swagger-ui .opblock-external-docs-wrapper p, .swagger-ui .opblock-tag small, .swagger-ui .opblock-title_normal, .swagger-ui .opblock-title_normal h4, .swagger-ui .opblock-title_normal p, .swagger-ui .parameter__name, .swagger-ui .parameter__type, .swagger-ui .response-col_links, .swagger-ui .response-col_status, .swagger-ui .responses-inner h4, .swagger-ui .responses-inner h5, .swagger-ui .scheme-container .schemes > label, .swagger-ui .scopes h2, .swagger-ui .servers > label, .swagger-ui .tab li, .swagger-ui label, .swagger-ui select, .swagger-ui table.headers td { color: #b5bac9; }
.swagger-ui .download-url-wrapper .failed, .swagger-ui .filter .failed, .swagger-ui .model-deprecated-warning, .swagger-ui .parameter__deprecated, .swagger-ui .parameter__name.required span, .swagger-ui table.model tr.property-row .star { color: #e69999; }
.swagger-ui .opblock-body pre.microlight, .swagger-ui textarea.curl {
background: #41444e;
border-radius: 4px;
color: #fff;
}
.swagger-ui .expand-methods svg, .swagger-ui .expand-methods:hover svg { fill: #bfbfbf; }
.swagger-ui .auth-container, .swagger-ui .dialog-ux .modal-ux-header { border-bottom: 1px solid #2e2e2e; }
.swagger-ui .topbar .download-url-wrapper .select-label select, .swagger-ui .topbar .download-url-wrapper input[type=text] { border: 2px solid #63a040; }
.swagger-ui .info a, .swagger-ui .info a:hover, .swagger-ui .scopes h2 a { color: #99bde6; }
/* Dark Scrollbar */
::-webkit-scrollbar {
width: 14px;
height: 14px;
}
::-webkit-scrollbar-button {
background-color: #3e4346 !important;
}
::-webkit-scrollbar-track {
background-color: #646464 !important;
}
::-webkit-scrollbar-track-piece {
background-color: #3e4346 !important;
}
::-webkit-scrollbar-thumb {
height: 50px;
background-color: #242424 !important;
border: 2px solid #3e4346 !important;
}
::-webkit-scrollbar-corner {}
::-webkit-resizer {}
::-webkit-scrollbar-button:vertical:start:decrement {
background:
linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%),
linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:vertical:end:increment {
background:
linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:horizontal:end:increment {
background:
linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6;
}
::-webkit-scrollbar-button:horizontal:start:decrement {
background:
linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%),
linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%);
background-color: #b6b6b6;
}
}

@ -2,3 +2,6 @@ const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
// By default this is false, which affects asserts like isRejected and isFulfilled.
chai.config.includeStack = true;

@ -1,7 +1,7 @@
import {emptyPermissionSet, PartialPermissionSet,
import {emptyPermissionSet, PartialPermissionSet, PermissionKey,
summarizePermissions, summarizePermissionSet} from 'app/common/ACLPermissions';
import {makePartialPermissions, parsePermissions, permissionSetToText} from 'app/common/ACLPermissions';
import {mergePartialPermissions, mergePermissions} from 'app/common/ACLPermissions';
import {mergePartialPermissions, mergePermissions, trimPermissions} from 'app/common/ACLPermissions';
import {assert} from 'chai';
describe("ACLPermissions", function() {
@ -112,6 +112,17 @@ describe("ACLPermissions", function() {
);
});
it('should support trimPermissions', function() {
const trim = (permissionsText: string, availableBits: PermissionKey[]) =>
permissionSetToText(trimPermissions(parsePermissions(permissionsText), availableBits));
assert.deepEqual(trim("+CRUD", ["read", "update"]), "+RU");
assert.deepEqual(trim("all", ["read", "update"]), "+RU");
assert.deepEqual(trim("-C+R-U+D-S", ["update", "read"]), "+R-U");
assert.deepEqual(trim("none", ["read", "update", "create", "delete", "schemaEdit"]), "none");
assert.deepEqual(trim("none", ["read", "update", "create", "delete"]), "-CRUD");
assert.deepEqual(trim("none", ["read"]), "-R");
});
it ('should allow summarization of permission sets', function() {
assert.deepEqual(summarizePermissionSet(parsePermissions("+U-D")), 'mixed');
assert.deepEqual(summarizePermissionSet(parsePermissions("+U+D")), 'allow');

@ -218,4 +218,23 @@ describe("DuplicateDocument", function() {
await driver.find(".test-bc-workspace").click();
await gu.removeDoc(`DuplicateTest2 ${name} Copy`);
});
it("should not auto-start tour if a document with a tour is copied as a template", async function() {
const session = await gu.session().teamSite.login();
await session.tempDoc(cleanup, 'doctour.grist');
await session.tempWorkspace(cleanup, 'Test Workspace');
assert.isTrue(await driver.findWait('.test-onboarding-popup', 1000).isPresent());
await driver.find('.test-onboarding-close').click();
await gu.waitForServer();
await driver.find('.test-tb-share').click();
await driver.find('.test-save-copy').click();
await driver.findWait('.test-modal-dialog', 1000);
await driver.find('.test-save-as-template').click();
await gu.completeCopy({destName: 'DuplicateTest3', destWorkspace: 'Test Workspace'});
// Give it a second, just to be sure the tour doesn't appear.
await driver.sleep(1000);
assert.isFalse(await driver.find('.test-onboarding-popup').isPresent());
});
});

@ -150,7 +150,7 @@ describe('GridViewNewColumnMenu', function () {
describe('create column with type', function () {
revertThis();
const columsThatShouldTriggerSideMenu = [
const columnsThatShouldTriggerSideMenu = [
"Reference",
"Reference List"
];
@ -176,7 +176,7 @@ describe('GridViewNewColumnMenu', function () {
it('should show "Add Column With type" option', async function () {
// open add new colum menu
await clickAddColumn();
// check if "Add Column With type" option is persent
// check if "Add Column With type" option is present
const addWithType = await driver.findWait(
'.test-new-columns-menu-add-with-type',
100,
@ -238,7 +238,7 @@ describe('GridViewNewColumnMenu', function () {
}
for (const optionsTriggeringMenu of optionsToBeDisplayed.filter((option) =>
columsThatShouldTriggerSideMenu.includes(option.type))) {
columnsThatShouldTriggerSideMenu.includes(option.type))) {
it(`should open Right Menu on Column section after choosing ${optionsTriggeringMenu.type}`, async function(){
await gu.enableTips(session.email);
//close right panel just in case.
@ -259,7 +259,7 @@ describe('GridViewNewColumnMenu', function () {
await gu.waitForServer();
//discard rename menu
await driver.findWait('.test-column-title-close', STANDARD_WAITING_TIME).click();
// Wait for the sidepanel animation.
// Wait for the side panel animation.
await gu.waitForSidePanel();
//check if right menu is opened on column section
assert.isTrue(await driver.findWait('.test-right-tab-field', 1000).isDisplayed());
@ -308,7 +308,7 @@ describe('GridViewNewColumnMenu', function () {
describe('on mobile', function () {
gu.narrowScreen();
for (const optionsTriggeringMenu of optionsToBeDisplayed.filter((option) =>
columsThatShouldTriggerSideMenu.includes(option.type))) {
columnsThatShouldTriggerSideMenu.includes(option.type))) {
it('should not show Right Menu when user is on the mobile/narrow screen', async function() {
await gu.enableTips(session.email);
//close right panel just in case.
@ -378,7 +378,7 @@ describe('GridViewNewColumnMenu', function () {
await driver.findWait('.test-new-columns-menu-add-formula', STANDARD_WAITING_TIME).click();
//check if new column is present
await gu.waitForServer();
// there should not be a rename poup
// there should not be a rename popup
assert.isFalse(await driver.find('test-column-title-popup').isPresent());
// check if editor popup is opened
await driver.findWait('.test-floating-editor-popup', 200, 'Editor popup is not present');
@ -797,15 +797,15 @@ describe('GridViewNewColumnMenu', function () {
// For this column test other aggregations as well.
await gu.undo();
await addRefListLookup('Employees', column, 'average');
await checkTypeAndFormula('Numeric', `AVERAGE($Employees.${colId})`);
await checkTypeAndFormula('Numeric', AVERAGE('$Employees', colId));
await gu.undo();
await addRefListLookup('Employees', column, 'min');
await checkTypeAndFormula('Numeric', `MIN($Employees.${colId})`);
await checkTypeAndFormula('Numeric', MIN('$Employees', colId));
await gu.undo();
await addRefListLookup('Employees', column, 'max');
await checkTypeAndFormula('Numeric', `MAX($Employees.${colId})`);
await checkTypeAndFormula('Numeric', MAX('$Employees', colId));
break;
case "Member":
@ -813,7 +813,7 @@ describe('GridViewNewColumnMenu', function () {
// Here we also test that the formula is correct for percent.
await gu.undo();
await addRefListLookup('Employees', column, 'percent');
await checkTypeAndFormula('Numeric', `AVERAGE(map(int, $Employees.Member)) if $Employees else None`);
await checkTypeAndFormula('Numeric', PERCENT('$Employees', colId));
assert.isTrue(
await driver.findContent('.test-numeric-mode .test-select-button', /%/).matches('[class*=-selected]'));
break;
@ -821,12 +821,12 @@ describe('GridViewNewColumnMenu', function () {
await checkTypeAndFormula('Any', `$Employees.${colId}`);
await gu.undo();
await addRefListLookup('Employees', column, 'min');
await checkTypeAndFormula('DateTime', `MIN($Employees.${colId})`);
await checkTypeAndFormula('DateTime', MIN('$Employees', colId));
assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC');
await gu.undo();
await addRefListLookup('Employees', column, 'max');
await checkTypeAndFormula('DateTime', `MAX($Employees.${colId})`);
await checkTypeAndFormula('DateTime', MAX('$Employees', colId));
assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC');
break;
default:
@ -959,15 +959,15 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('average');
await checkTypeAndFormula('Numeric', `AVERAGE(Person.lookupRecords(Item=$id).Age)`);
await checkTypeAndFormula('Numeric', AVERAGE(`Person.lookupRecords(Item=$id)`, 'Age'));
await gu.undo();
await addRevLookup('min');
await checkTypeAndFormula('Numeric', `MIN(Person.lookupRecords(Item=$id).Age)`);
await checkTypeAndFormula('Numeric', MIN(`Person.lookupRecords(Item=$id)`, 'Age'));
await gu.undo();
await addRevLookup('max');
await checkTypeAndFormula('Numeric', `MAX(Person.lookupRecords(Item=$id).Age)`);
await checkTypeAndFormula('Numeric', MAX(`Person.lookupRecords(Item=$id)`, 'Age'));
break;
case "Member":
await addRevLookup('count');
@ -975,9 +975,7 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('percent');
await checkTypeAndFormula('Numeric',
`AVERAGE(map(int, Person.lookupRecords(Item=$id).Member))` +
` if Person.lookupRecords(Item=$id) else None`);
await checkTypeAndFormula('Numeric', PERCENT(`Person.lookupRecords(Item=$id)`, column));
break;
case "Birthday date":
await addRevLookup('list');
@ -985,11 +983,11 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('min');
await checkTypeAndFormula('Date', `MIN(Person.lookupRecords(Item=$id).Birthday_date)`);
await checkTypeAndFormula('Date', MIN('Person.lookupRecords(Item=$id)', 'Birthday_date'));
await gu.undo();
await addRevLookup('max');
await checkTypeAndFormula('Date', `MAX(Person.lookupRecords(Item=$id).Birthday_date)`);
await checkTypeAndFormula('Date', MAX('Person.lookupRecords(Item=$id)', 'Birthday_date'));
assert.deepEqual(await gu.getColumnNames(),
['A', 'B', 'C', 'Person_Birthday date']);
@ -997,7 +995,7 @@ describe('GridViewNewColumnMenu', function () {
break;
case "SeenAt":
await addRevLookup('max');
await checkTypeAndFormula('DateTime', `MAX(Person.lookupRecords(Item=$id).SeenAt)`);
await checkTypeAndFormula('DateTime', MAX('Person.lookupRecords(Item=$id)', 'SeenAt'));
// Here check the timezone.
assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC');
break;
@ -1076,15 +1074,15 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('average');
await checkTypeAndFormula('Numeric', `AVERAGE(Person.lookupRecords(Items=CONTAINS($id)).Age)`);
await checkTypeAndFormula('Numeric', AVERAGE(`Person.lookupRecords(Items=CONTAINS($id))`, 'Age'));
await gu.undo();
await addRevLookup('min');
await checkTypeAndFormula('Numeric', `MIN(Person.lookupRecords(Items=CONTAINS($id)).Age)`);
await checkTypeAndFormula('Numeric', MIN(`Person.lookupRecords(Items=CONTAINS($id))`, 'Age'));
await gu.undo();
await addRevLookup('max');
await checkTypeAndFormula('Numeric', `MAX(Person.lookupRecords(Items=CONTAINS($id)).Age)`);
await checkTypeAndFormula('Numeric', MAX(`Person.lookupRecords(Items=CONTAINS($id))`, 'Age'));
break;
case "Member":
await addRevLookup('count');
@ -1092,9 +1090,7 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('percent');
await checkTypeAndFormula('Numeric',
`AVERAGE(map(int, Person.lookupRecords(Items=CONTAINS($id)).Member))` +
` if Person.lookupRecords(Items=CONTAINS($id)) else None`);
await checkTypeAndFormula('Numeric', PERCENT(`Person.lookupRecords(Items=CONTAINS($id))`, column));
break;
case "Birthday date":
await addRevLookup('list');
@ -1102,15 +1098,15 @@ describe('GridViewNewColumnMenu', function () {
await gu.undo();
await addRevLookup('min');
await checkTypeAndFormula('Date', `MIN(Person.lookupRecords(Items=CONTAINS($id)).Birthday_date)`);
await checkTypeAndFormula('Date', MIN('Person.lookupRecords(Items=CONTAINS($id))', 'Birthday_date'));
await gu.undo();
await addRevLookup('max');
await checkTypeAndFormula('Date', `MAX(Person.lookupRecords(Items=CONTAINS($id)).Birthday_date)`);
await checkTypeAndFormula('Date', MAX('Person.lookupRecords(Items=CONTAINS($id))', 'Birthday_date'));
break;
case "SeenAt":
await addRevLookup('max');
await checkTypeAndFormula('DateTime', `MAX(Person.lookupRecords(Items=CONTAINS($id)).SeenAt)`);
await checkTypeAndFormula('DateTime', MAX('Person.lookupRecords(Items=CONTAINS($id))', 'SeenAt'));
// Here check the timezone.
assert.equal(await driver.find(".test-tz-autocomplete input").value(), 'UTC');
break;
@ -1518,3 +1514,8 @@ describe('GridViewNewColumnMenu', function () {
await gu.sendKeys(Key.ESCAPE);
}
});
const PERCENT = (ref: string, col: string) => `ref = ${ref}\nAVERAGE(map(int, ref.${col})) if ref else None`;
const AVERAGE = (ref: string, col: string) => `ref = ${ref}\nAVERAGE(ref.${col}) if ref else None`;
const MIN = (ref: string, col: string) => `ref = ${ref}\nMIN(ref.${col}) if ref else None`;
const MAX = (ref: string, col: string) => `ref = ${ref}\nMAX(ref.${col}) if ref else None`;

@ -4865,23 +4865,6 @@ function testDocApi() {
});
describe("Allowed Origin", () => {
it('should allow only example.com', async () => {
async function checkOrigin(origin: string, allowed: boolean) {
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`,
{...chimpy, headers: {...chimpy.headers, "Origin": origin}}
);
assert.equal(resp.headers['access-control-allow-credentials'], allowed ? 'true' : undefined);
assert.equal(resp.status, allowed ? 200 : 403);
}
await checkOrigin("https://www.toto.com", false);
await checkOrigin("https://badexample.com", false);
await checkOrigin("https://bad.com/example.com/toto", false);
await checkOrigin("https://example.com/path", true);
await checkOrigin("https://example.com:3000/path", true);
await checkOrigin("https://good.example.com/toto", true);
});
it("should respond with correct CORS headers", async function () {
const wid = await getWorkspaceId(userApi, 'Private');
const docId = await userApi.newDoc({name: 'CorsTestDoc'}, wid);

@ -25,7 +25,7 @@ import {createClient, RedisClient} from 'redis';
import * as sinon from 'sinon';
import {createInitialDb, removeConnection, setUpDB} from 'test/gen-server/seed';
import {createTmpDir, getGlobalPluginManager} from 'test/server/docTools';
import {setTmpLogLevel, useFixtureDoc} from 'test/server/testUtils';
import {EnvironmentSnapshot, setTmpLogLevel, useFixtureDoc} from 'test/server/testUtils';
import {waitForIt} from 'test/server/wait';
import uuidv4 from "uuid/v4";
@ -273,6 +273,17 @@ class TestStore {
private _externalStorageCreate: (purpose: 'doc'|'meta', extraPrefix: string) => ExternalStorage|undefined) {
}
public async run<T>(fn: () => Promise<T>): Promise<T> {
await this.begin();
let result;
try {
result = await fn();
} finally {
await this.end();
}
return result;
}
// Simulates doc worker startup.
public async begin() {
await this.end();
@ -366,6 +377,7 @@ describe('HostedStorageManager', function() {
describe(storage, function() {
const sandbox = sinon.createSandbox();
let oldEnv: EnvironmentSnapshot;
const workerId = 'dw17';
let cli: RedisClient;
@ -376,6 +388,7 @@ describe('HostedStorageManager', function() {
before(async function() {
if (!process.env.TEST_REDIS_URL) { this.skip(); return; }
cli = createClient(process.env.TEST_REDIS_URL);
oldEnv = new EnvironmentSnapshot();
await cli.flushdbAsync();
workers = new DocWorkerMap([cli]);
await workers.addWorker({
@ -439,6 +452,7 @@ describe('HostedStorageManager', function() {
});
afterEach(async function() {
oldEnv.restore();
sandbox.restore();
if (store) {
await store.end();
@ -468,94 +482,105 @@ describe('HostedStorageManager', function() {
assert.equal(await getRedisChecksum(docId), 'null');
// Create an empty document when checksum in redis is 'null'.
await store.begin();
await store.docManager.fetchDoc(docSession, docId);
assert(await store.waitForUpdates());
const checksum = await getRedisChecksum(docId);
assert.notEqual(checksum, 'null');
await store.end();
// Check if we nobble the expected checksum then fetch eventually errors.
const checksum = await store.run(async () => {
await store.docManager.fetchDoc(docSession, docId);
assert(await store.waitForUpdates());
const checksum = await getRedisChecksum(docId);
assert.notEqual(checksum, 'null');
return checksum;
});
// Check what happens when we nobble the expected checksum.
await setRedisChecksum(docId, 'nobble');
await store.removeAll();
await store.begin();
await assert.isRejected(store.docManager.fetchDoc(docSession, docId),
/operation failed to become consistent/);
await store.end();
// With GRIST_SKIP_REDIS_CHECKSUM_MISMATCH set, the fetch should work
process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH = 'true';
await store.run(async () => {
await assert.isFulfilled(store.docManager.fetchDoc(docSession, docId));
});
// By default, the fetch should eventually errors.
delete process.env.GRIST_SKIP_REDIS_CHECKSUM_MISMATCH;
await store.run(async () => {
await assert.isRejected(store.docManager.fetchDoc(docSession, docId),
/operation failed to become consistent/);
});
// Check we get the document back on fresh start if checksum is correct.
await setRedisChecksum(docId, checksum);
await store.removeAll();
await store.begin();
await store.docManager.fetchDoc(docSession, docId);
await store.end();
await store.run(async () => {
await store.docManager.fetchDoc(docSession, docId);
});
});
it('can save modifications', async function() {
await store.begin();
await store.run(async () => {
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
await workers.assignDocWorker('Hello2');
await workers.assignDocWorker('Hello2');
let doc = await store.docManager.fetchDoc(docSession, 'Hello');
let doc2 = await store.docManager.fetchDoc(docSession, 'Hello2');
await doc.docStorage.exec("update Table1 set A = 'magic_word' where id = 1");
await doc2.docStorage.exec("insert into Table1(id) values(42)");
await store.end();
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
const doc2 = await store.docManager.fetchDoc(docSession, 'Hello2');
await doc.docStorage.exec("update Table1 set A = 'magic_word' where id = 1");
await doc2.docStorage.exec("insert into Table1(id) values(42)");
return { doc, doc2 };
});
await store.removeAll();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, 'Hello');
let result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word');
doc2 = await store.docManager.fetchDoc(docSession, 'Hello2');
result = await doc2.docStorage.get("select id from Table1");
assert.equal(result!.id, 42);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
let result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word');
const doc2 = await store.docManager.fetchDoc(docSession, 'Hello2');
result = await doc2.docStorage.get("select id from Table1");
assert.equal(result!.id, 42);
});
});
it('can save modifications with interfering backup file', async function() {
await store.begin();
await store.run(async () => {
// There was a bug where if a corrupt/truncated backup file was created, all future
// backups would fail. This tickles the condition and makes sure backups now succeed.
await fse.writeFile(path.join(tmpDir, 'Hello.grist-backup'), 'not a sqlite file');
// There was a bug where if a corrupt/truncated backup file was created, all future
// backups would fail. This tickles the condition and makes sure backups now succeed.
await fse.writeFile(path.join(tmpDir, 'Hello.grist-backup'), 'not a sqlite file');
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'magic_word2' where id = 1");
});
let doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'magic_word2' where id = 1");
await store.end(); // S3 push will happen prior to this returning.
// S3 should have happened after store.run()
await store.removeAll();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, 'Hello');
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word2');
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word2');
});
});
it('survives if there is a doc marked dirty that turns out to be clean', async function() {
await store.begin();
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
await store.run(async () => {
await workers.assignDocWorker('Hello');
await useFixtureDoc('Hello.grist', store.storageManager);
let doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'magic_word' where id = 1");
await store.end();
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'magic_word' where id = 1");
});
await store.removeAll();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, 'Hello');
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word');
store.docManager.markAsChanged(doc);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'magic_word');
store.docManager.markAsChanged(doc);
});
// The real test is whether this test manages to complete.
});
@ -564,39 +589,39 @@ describe('HostedStorageManager', function() {
await workers.assignDocWorker('Hello');
// put a doc in s3
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager);
let doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'parallel' where id = 1");
await store.end();
await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager);
const doc = await store.docManager.fetchDoc(docSession, 'Hello');
await doc.docStorage.exec("update Table1 set A = 'parallel' where id = 1");
});
// now open it many times in parallel
await store.removeAll();
await store.begin();
const docs = Promise.all([
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
]);
await assert.isFulfilled(docs);
doc = (await docs)[0];
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'parallel');
await store.end();
await store.run(async () => {
const docs = Promise.all([
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
store.docManager.fetchDoc(docSession, 'Hello'),
]);
await assert.isFulfilled(docs);
const doc = (await docs)[0];
const result = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(result!.A, 'parallel');
});
// To be sure we are checking something, let's call prepareLocalDoc directly
// on storage manager and make sure it fails.
await store.removeAll();
await store.begin();
const preps = Promise.all([
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello')
]);
await assert.isRejected(preps, /in parallel/);
await store.end();
await store.run(async () => {
const preps = Promise.all([
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello'),
store.storageManager.prepareLocalDoc('Hello')
]);
await assert.isRejected(preps, /in parallel/);
});
});
it ('can delete a document', async function() {
@ -604,29 +629,29 @@ describe('HostedStorageManager', function() {
await workers.assignDocWorker(docId);
// Create a document
await store.begin();
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
});
const docPath = store.getDocPath(docId);
const ext = store.storageManager.testGetExternalStorage();
// Check that the document exists on filesystem and in external store.
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal(await fse.pathExists(docPath), true);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), true);
await waitForIt(async () => assert.equal(await ext.exists(docId), true), 20000);
await doc.docStorage.exec("insert into Table1(id) values(43)");
// Now delete the document, and check it no longer exists on filesystem or external store.
await store.docManager.deleteDoc(null, docId, true);
assert.equal(await fse.pathExists(docPath), false);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), false);
assert.equal(await getRedisChecksum(docId), DELETED_TOKEN);
await waitForIt(async () => assert.equal(await ext.exists(docId), false), 20000);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal(await fse.pathExists(docPath), true);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), true);
await waitForIt(async () => assert.equal(await ext.exists(docId), true), 20000);
await doc.docStorage.exec("insert into Table1(id) values(43)");
// Now delete the document, and check it no longer exists on filesystem or external store.
await store.docManager.deleteDoc(null, docId, true);
assert.equal(await fse.pathExists(docPath), false);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), false);
assert.equal(await getRedisChecksum(docId), DELETED_TOKEN);
await waitForIt(async () => assert.equal(await ext.exists(docId), false), 20000);
});
// As far as the underlying storage is concerned it should be
// possible to recreate a doc with the same id after deletion.
@ -634,55 +659,53 @@ describe('HostedStorageManager', function() {
// document it must exist in the db - however we'll need to watch
// out for caching.
// TODO: it could be worth tweaking fetchDoc so creation is explicit.
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
await store.end();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal(await fse.pathExists(docPath), true);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), true);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
});
await store.run(async () => {
await store.docManager.fetchDoc(docSession, docId);
assert.equal(await fse.pathExists(docPath), true);
assert.equal(await fse.pathExists(docPath + '-hash-doc'), true);
});
});
it('individual document close is orderly', async function() {
const docId = `create-${uuidv4()}`;
await workers.assignDocWorker(docId);
await store.begin();
let doc = await store.docManager.fetchDoc(docSession, docId);
await store.closeDoc(doc);
const checksum1 = await getRedisChecksum(docId);
assert.notEqual(checksum1, 'null');
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
// Add an attachment file with no corresponding metadata. It should be deleted when shutting down.
await doc.docStorage.exec("insert into _gristsys_Files(id, ident) values(23, 'foo')");
let files = await doc.docStorage.all("select * from _gristsys_Files");
assert.isNotEmpty(files);
await store.closeDoc(doc);
const checksum2 = await getRedisChecksum(docId);
assert.notEqual(checksum1, checksum2);
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(43)");
// Attachment file should have been deleted on previous close.
files = await doc.docStorage.all("select * from _gristsys_Files");
assert.isEmpty(files);
const asyncClose = store.closeDoc(doc); // this time, don't explicitly wait for closeDoc.
doc = await store.docManager.fetchDoc(docSession, docId);
const checksum3 = await getRedisChecksum(docId);
assert.notEqual(checksum2, checksum3);
await asyncClose;
await store.end();
await store.run(async () => {
let doc = await store.docManager.fetchDoc(docSession, docId);
await store.closeDoc(doc);
const checksum1 = await getRedisChecksum(docId);
assert.notEqual(checksum1, 'null');
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(42)");
// Add an attachment file with no corresponding metadata. It should be deleted when shutting down.
await doc.docStorage.exec("insert into _gristsys_Files(id, ident) values(23, 'foo')");
let files = await doc.docStorage.all("select * from _gristsys_Files");
assert.isNotEmpty(files);
await store.closeDoc(doc);
const checksum2 = await getRedisChecksum(docId);
assert.notEqual(checksum1, checksum2);
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("insert into Table1(id) values(43)");
// Attachment file should have been deleted on previous close.
files = await doc.docStorage.all("select * from _gristsys_Files");
assert.isEmpty(files);
const asyncClose = store.closeDoc(doc); // this time, don't explicitly wait for closeDoc.
doc = await store.docManager.fetchDoc(docSession, docId);
const checksum3 = await getRedisChecksum(docId);
assert.notEqual(checksum2, checksum3);
await asyncClose;
});
});
// Viewing a document should not mark it as changed (unless a document-level migration
@ -691,24 +714,22 @@ describe('HostedStorageManager', function() {
const docId = `create-${uuidv4()}`;
await workers.assignDocWorker(docId);
await store.begin();
const markAsChanged: {callCount: number} = store.storageManager.markAsChanged as any;
await store.run(async () => {
const markAsChanged: {callCount: number} = store.storageManager.markAsChanged as any;
const changesInitial = markAsChanged.callCount;
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
await store.closeDoc(doc);
const changesAfterCreation = markAsChanged.callCount;
assert.isAbove(changesAfterCreation, changesInitial);
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
await store.closeDoc(doc);
const changesAfterViewing = markAsChanged.callCount;
assert.equal(changesAfterViewing, changesAfterCreation);
await store.end();
const changesInitial = markAsChanged.callCount;
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
await store.closeDoc(doc);
const changesAfterCreation = markAsChanged.callCount;
assert.isAbove(changesAfterCreation, changesInitial);
doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
await store.closeDoc(doc);
const changesAfterViewing = markAsChanged.callCount;
assert.equal(changesAfterViewing, changesAfterCreation);
});
});
it('can fork documents', async function() {
@ -717,35 +738,35 @@ describe('HostedStorageManager', function() {
await workers.assignDocWorker(docId);
await workers.assignDocWorker(forkId);
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("update Table1 set A = 'trunk' where id = 1");
await store.end();
await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("update Table1 set A = 'trunk' where id = 1");
});
await store.begin();
await store.docManager.storageManager.prepareFork(docId, forkId);
doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
await doc.docStorage.exec("update Table1 set A = 'fork' where id = 1");
await store.end();
await store.run(async () => {
await store.docManager.storageManager.prepareFork(docId, forkId);
const doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
await doc.docStorage.exec("update Table1 set A = 'fork' where id = 1");
});
await store.removeAll();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('fork', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
await store.end();
await store.run(async () => {
let doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('fork', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
});
// Check that the trunk can be replaced by a fork
await store.removeAll();
await store.begin();
await store.storageManager.replace(docId, {sourceDocId: forkId});
doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal('fork', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
await store.end();
await store.run(async () => {
await store.storageManager.replace(docId, {sourceDocId: forkId});
const doc = await store.docManager.fetchDoc(docSession, docId);
assert.equal('fork', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
});
});
it('can persist a fork with no modifications', async function() {
@ -755,16 +776,16 @@ describe('HostedStorageManager', function() {
await workers.assignDocWorker(forkId);
// Create a document.
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("update Table1 set A = 'trunk' where id = 1");
await store.end();
await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.docStorage.exec("update Table1 set A = 'trunk' where id = 1");
});
// Create a fork with no modifications.
await store.begin();
await store.docManager.storageManager.prepareFork(docId, forkId);
await store.end();
await store.run(async () => {
await store.docManager.storageManager.prepareFork(docId, forkId);
});
await store.waitForUpdates();
await store.removeAll();
@ -772,10 +793,10 @@ describe('HostedStorageManager', function() {
await fse.remove(store.getDocPath(docId));
// Make sure opening the fork works as expected.
await store.begin();
doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal('trunk', (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
});
await store.removeAll();
});
@ -792,70 +813,72 @@ describe('HostedStorageManager', function() {
await workers.assignDocWorker(forkId2);
await workers.assignDocWorker(forkId3);
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
let doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
for (let i = 0; i < forks; i++) {
await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);
await doc.testKeepOpen();
await store.waitForUpdates();
}
await store.end();
const doc = await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
for (let i = 0; i < forks; i++) {
await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);
await doc.testKeepOpen();
await store.waitForUpdates();
}
return doc;
});
const {snapshots} = await store.storageManager.getSnapshots(doc.docName);
assert.isAtLeast(snapshots.length, forks + 1); // May be 1 greater depending on how long
// it takes to run initial migrations.
await store.begin();
for (let i = forks - 1; i >= 0; i--) {
const snapshot = snapshots.shift()!;
const forkId = snapshot.docId;
await workers.assignDocWorker(forkId);
doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal(`v${i}`, (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
}
await store.end();
await store.run(async () => {
for (let i = forks - 1; i >= 0; i--) {
const snapshot = snapshots.shift()!;
const forkId = snapshot.docId;
await workers.assignDocWorker(forkId);
const doc = await store.docManager.fetchDoc(docSession, forkId);
assert.equal(`v${i}`, (await doc.docStorage.get("select A from Table1 where id = 1"))!.A);
}
});
});
it('can access snapshots with old schema versions', async function() {
const snapshotId = `World~v=1`;
await workers.assignDocWorker(snapshotId);
await store.begin();
// Pretend we have a snapshot of World-v33.grist and fetch/load it.
await useFixtureDoc('World-v33.grist', store.storageManager, `${snapshotId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, snapshotId);
// Check that the snapshot isn't broken.
assert.doesNotThrow(async () => await doc.waitForInitialization());
// Check that the snapshot was migrated to the latest schema version.
assert.equal(
SCHEMA_VERSION,
(await doc.docStorage.get("select schemaVersion from _grist_DocInfo where id = 1"))!.schemaVersion
);
// Check that the document is actually a snapshot.
await assert.isRejected(doc.replace(docSession, {sourceDocId: 'docId'}),
/Snapshots cannot be replaced/);
await assert.isRejected(doc.applyUserActions(docSession, [['AddTable', 'NewTable', [{id: 'A'}]]]),
/pyCall is not available in snapshots/);
await store.end();
await store.run(async () => {
// Pretend we have a snapshot of World-v33.grist and fetch/load it.
await useFixtureDoc('World-v33.grist', store.storageManager, `${snapshotId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, snapshotId);
// Check that the snapshot isn't broken.
assert.doesNotThrow(async () => await doc.waitForInitialization());
// Check that the snapshot was migrated to the latest schema version.
assert.equal(
SCHEMA_VERSION,
(await doc.docStorage.get("select schemaVersion from _grist_DocInfo where id = 1"))!.schemaVersion
);
// Check that the document is actually a snapshot.
await assert.isRejected(doc.replace(docSession, {sourceDocId: 'docId'}),
/Snapshots cannot be replaced/);
await assert.isRejected(doc.applyUserActions(docSession, [['AddTable', 'NewTable', [{id: 'A'}]]]),
/pyCall is not available in snapshots/);
});
});
it('can prune snapshots', async function() {
const versions = 8;
const docId = `create-${uuidv4()}`;
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
for (let i = 0; i < versions; i++) {
await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);
await doc.testKeepOpen();
await store.waitForUpdates();
}
await store.storageManager.testWaitForPrunes();
await store.end();
const doc = await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
for (let i = 0; i < versions; i++) {
await doc.docStorage.exec(`update Table1 set A = 'v${i}' where id = 1`);
await doc.testKeepOpen();
await store.waitForUpdates();
}
await store.storageManager.testWaitForPrunes();
return doc;
});
await waitForIt(async () => {
const {snapshots} = await store.storageManager.getSnapshots(doc.docName);
// Should be keeping at least five, and then maybe 1 more if the hour changed
@ -878,20 +901,20 @@ describe('HostedStorageManager', function() {
// Create a series of versions of a document, and fetch them sequentially
// so that they are potentially available as stale values.
await store.begin();
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
let doc = await store.docManager.fetchDoc(docSession, docId);
await store.end();
await store.run(async () => {
await useFixtureDoc('Hello.grist', store.storageManager, `${docId}.grist`);
await store.docManager.fetchDoc(docSession, docId);
});
for (let i = 0; i < 3; i++) {
await store.removeAll();
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
if (i > 0) {
const prev = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(prev!.A, `magic_word${i - 1}`);
}
await doc.docStorage.exec(`update Table1 set A = 'magic_word${i}' where id = 1`);
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, docId);
if (i > 0) {
const prev = await doc.docStorage.get("select A from Table1 where id = 1");
assert.equal(prev!.A, `magic_word${i - 1}`);
}
await doc.docStorage.exec(`update Table1 set A = 'magic_word${i}' where id = 1`);
});
}
// Wipe all checksums and make sure (1) we don't get any errors and (2) the
@ -903,10 +926,10 @@ describe('HostedStorageManager', function() {
// Optionally wipe all local files.
await store.removeAll();
}
await store.begin();
doc = await store.docManager.fetchDoc(docSession, docId);
result = (await doc.docStorage.get("select A from Table1 where id = 1"))?.A;
await store.end();
await store.run(async () => {
const doc = await store.docManager.fetchDoc(docSession, docId);
result = (await doc.docStorage.get("select A from Table1 where id = 1"))?.A;
});
if (result !== 'magic_word2') {
throw new Error(`inconsistent result: ${result}`);
}
@ -917,16 +940,17 @@ describe('HostedStorageManager', function() {
it('can access metadata', async function() {
const docId = `create-${uuidv4()}`;
await store.begin();
// Use a doc that's up-to-date on storage migrations, but needs a python schema migration.
await useFixtureDoc('BlobMigrationV8.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
const rec = await doc.fetchTable(makeExceptionalDocSession('system'), '_grist_DocInfo');
const tz = rec.tableData[3].timezone[0];
const h = (await doc.getRecentStates(makeExceptionalDocSession('system')))[0].h;
await store.docManager.makeBackup(doc, 'hello');
await store.end();
const { tz, h, doc } = await store.run(async () => {
// Use a doc that's up-to-date on storage migrations, but needs a python schema migration.
await useFixtureDoc('BlobMigrationV8.grist', store.storageManager, `${docId}.grist`);
const doc = await store.docManager.fetchDoc(docSession, docId);
await doc.waitForInitialization();
const rec = await doc.fetchTable(makeExceptionalDocSession('system'), '_grist_DocInfo');
const tz = rec.tableData[3].timezone[0];
const h = (await doc.getRecentStates(makeExceptionalDocSession('system')))[0].h;
await store.docManager.makeBackup(doc, 'hello');
return { tz, h, doc };
});
const {snapshots} = await store.storageManager.getSnapshots(doc.docName);
assert.equal(snapshots[0]?.metadata?.label, 'hello');
// There can be extra snapshots, depending on timing.

@ -49,7 +49,6 @@ export class TestServer {
GRIST_PORT: '0',
GRIST_DISABLE_S3: 'true',
REDIS_URL: process.env.TEST_REDIS_URL,
GRIST_ALLOWED_HOSTS: `example.com,localhost`,
GRIST_TRIGGER_WAIT_DELAY: '100',
// this is calculated value, some tests expect 4 attempts and some will try 3 times
GRIST_TRIGGER_MAX_ATTEMPTS: '4',

Loading…
Cancel
Save