* bump version to 1.7.0

* update HMR mode; make a single dev container for every service; etc

* update translations
This commit is contained in:
Evgeny
2026-05-01 09:14:01 +05:00
committed by GitHub
parent b3bda3622c
commit d0abf84aa8
17 changed files with 772 additions and 330 deletions
+5 -10
View File
@@ -1,11 +1,6 @@
APP_PATH=/opt/outline COMPOSE_PROFILES=dev
SRC_PATH=./outline
ADDRESS=localhost # PORT=10240
PORT_OUTLINE=10240 # PORT_OIDC=10241
PORT_OIDC=10241 # PORT_VITE=10242
PORT_REDIS=10242 # SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef # openssl rand -hex 32
PORT_POSTGRES=10243
COMMON=outline
SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
+13 -15
View File
@@ -9,8 +9,7 @@ on:
- .github/workflows/** - .github/workflows/**
- outline/** - outline/**
- translation/ru.json - translation/ru.json
- patches/** - Dockerfile
- Dockerfile.prod
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -42,16 +41,16 @@ jobs:
submodules: recursive submodules: recursive
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR - name: Login to GitHub CR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -65,7 +64,7 @@ jobs:
echo "version=$version" | tee -a $GITHUB_OUTPUT echo "version=$version" | tee -a $GITHUB_OUTPUT
- name: Set Metadata - name: Set Metadata
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
id: metadata id: metadata
with: with:
images: | images: |
@@ -73,13 +72,12 @@ jobs:
ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}
- name: Build Image - name: Build Image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
id: build id: build
with: with:
cache-from: type=gha,scope=build-${{ matrix.arch }} cache-from: type=gha,scope=build-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
file: Dockerfile.prod
build-args: | build-args: |
APP_PATH=/opt/outline APP_PATH=/opt/outline
SRC_PATH=./outline SRC_PATH=./outline
@@ -93,7 +91,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}" touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload Digests - name: Upload Digests
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v7
with: with:
name: digests-linux-${{ matrix.arch }} name: digests-linux-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -111,30 +109,30 @@ jobs:
version: ${{ needs.build.outputs.version }} version: ${{ needs.build.outputs.version }}
steps: steps:
- name: Download Digests - name: Download Digests
uses: actions/download-artifact@v7 uses: actions/download-artifact@v8
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-* pattern: digests-*
merge-multiple: true merge-multiple: true
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR - name: Login to GitHub CR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Set Metadata - name: Set Metadata
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ github.repository }} ${{ github.repository }}
@@ -153,7 +151,7 @@ jobs:
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
with: with:
name: ${{ env.version }} name: ${{ env.version }}
+34 -13
View File
@@ -1,7 +1,10 @@
FROM node:22.21.0 ARG APP_PATH=/opt/outline
ARG SRC_PATH=./outline
FROM node:24.15.0 AS build
ARG CDN_URL
ARG APP_PATH ARG APP_PATH
ARG SRC_PATH ARG SRC_PATH
ARG CDN_URL
WORKDIR $APP_PATH WORKDIR $APP_PATH
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./ COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
COPY ${SRC_PATH}/patches ./patches COPY ${SRC_PATH}/patches ./patches
@@ -10,16 +13,34 @@ RUN corepack enable && \
yarn install --immutable --network-timeout 1000000 && \ yarn install --immutable --network-timeout 1000000 && \
yarn cache clean yarn cache clean
COPY ${SRC_PATH} . COPY ${SRC_PATH} .
COPY ./patches/* . COPY ./patches/lang.patch .
RUN for patch in $(ls *.patch); do patch -p1 < $patch; done RUN patch -p1 < lang.patch
RUN cat << EOF > /entrypoint.sh COPY ./translation/ru.json ./shared/i18n/locales/ru_RU/translation.json
npx yarn concurrently -n "dev,i18n" \ RUN yarn build && \
"yarn dev:watch" \ yarn workspaces focus --production
"yarn nodemon \
--watch './shared/i18n/locales/ru_RU' \ FROM node:24.15.0-slim AS release
--exec 'yarn build:i18n'" RUN apt-get update && \
EOF apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
ENV DATA_PATH=/var/lib/outline/data ENV DATA_PATH=/var/lib/outline/data
ENV USER=nodejs
RUN addgroup --gid 1001 ${USER} && \
adduser --uid 1001 --ingroup ${USER} ${USER} && \
mkdir -p ${DATA_PATH} && \
chown -R ${USER}:${USER} ${DATA_PATH}/..
ARG APP_PATH
WORKDIR $APP_PATH
COPY --chown=${USER}:${USER} --from=build $APP_PATH/node_modules ./node_modules
COPY --chown=${USER}:${USER} --from=build $APP_PATH/build ./build
COPY --chown=${USER}:${USER} --from=build $APP_PATH/server ./server
COPY --chown=${USER}:${USER} --from=build $APP_PATH/public ./public
COPY --chown=${USER}:${USER} --from=build $APP_PATH/.sequelizerc .
COPY --chown=${USER}:${USER} --from=build $APP_PATH/package.json .
ENV NODE_ENV=production
ENV PORT=3000
USER ${USER}
EXPOSE ${PORT}
VOLUME ${DATA_PATH} VOLUME ${DATA_PATH}
STOPSIGNAL SIGKILL HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
ENTRYPOINT ["bash", "/entrypoint.sh"] CMD ["node", "build/server/index.js"]
+24
View File
@@ -0,0 +1,24 @@
ARG APP_PATH=/opt/outline
ARG SRC_PATH=./outline
FROM node:24.15.0
ARG APP_PATH
ARG SRC_PATH
ARG CDN_URL
WORKDIR $APP_PATH
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
COPY ${SRC_PATH}/patches ./patches
ENV NODE_OPTIONS='--max-old-space-size=24000'
RUN corepack enable && \
yarn install --immutable --network-timeout 1000000 && \
yarn cache clean
COPY ${SRC_PATH} .
COPY ./patches/* .
RUN for patch in $(ls *.patch); do patch -p1 < $patch; done
RUN cat << EOF > /entrypoint.sh
yarn dev:watch
EOF
ENV DATA_PATH=/var/lib/outline/data
VOLUME ${DATA_PATH}
STOPSIGNAL SIGKILL
ENTRYPOINT ["bash", "/entrypoint.sh"]
-43
View File
@@ -1,43 +0,0 @@
FROM node:22.21.0 AS build
ARG APP_PATH
ARG SRC_PATH
ARG CDN_URL
WORKDIR $APP_PATH
COPY ${SRC_PATH}/package.json ${SRC_PATH}/yarn.lock ${SRC_PATH}/.yarnrc.yml ./
COPY ${SRC_PATH}/patches ./patches
ENV NODE_OPTIONS='--max-old-space-size=24000'
RUN corepack enable && \
yarn install --immutable --network-timeout 1000000 && \
yarn cache clean
COPY ${SRC_PATH} .
COPY ./patches/lang.patch .
RUN patch -p1 < lang.patch
COPY ./translation/ru.json ./shared/i18n/locales/ru_RU/translation.json
RUN yarn build && \
yarn workspaces focus --production
FROM node:22.21.0-slim AS release
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
ENV DATA_PATH=/var/lib/outline/data
ENV USER=nodejs
RUN addgroup --gid 1001 ${USER} && \
adduser --uid 1001 --ingroup ${USER} ${USER} && \
mkdir -p ${DATA_PATH} && \
chown -R ${USER}:${USER} ${DATA_PATH}/..
ARG APP_PATH
WORKDIR $APP_PATH
COPY --chown=${USER}:${USER} --from=build $APP_PATH/node_modules ./node_modules
COPY --chown=${USER}:${USER} --from=build $APP_PATH/build ./build
COPY --chown=${USER}:${USER} --from=build $APP_PATH/server ./server
COPY --chown=${USER}:${USER} --from=build $APP_PATH/public ./public
COPY --chown=${USER}:${USER} --from=build $APP_PATH/.sequelizerc .
COPY --chown=${USER}:${USER} --from=build $APP_PATH/package.json .
ENV NODE_ENV=production
ENV PORT=3000
USER ${USER}
EXPOSE ${PORT}
VOLUME ${DATA_PATH}
HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
CMD ["node", "build/server/index.js"]
+3 -3
View File
@@ -20,8 +20,8 @@
```yaml ```yaml
services: services:
outline: outline:
image: flameshikari/outline-ru:1.6.1 image: flameshikari/outline-ru:1.7.0
# image: ghcr.io/flameshikari/outline-ru:1.6.1 # image: ghcr.io/flameshikari/outline-ru:1.7.0
env_file: ./docker.env env_file: ./docker.env
expose: expose:
- 3000 - 3000
@@ -93,7 +93,7 @@ services:
2. Пулл изменений в подмодуле и переключение на последний доступный тег: 2. Пулл изменений в подмодуле и переключение на последний доступный тег:
```sh ```sh
git submodule foreach 'git pull --rebase --tags && git checkout v1.6.1' git submodule foreach 'git pull --rebase --tags && git checkout v1.7.0'
``` ```
3. Запуск контейнеров: 3. Запуск контейнеров:
-140
View File
@@ -1,140 +0,0 @@
volumes:
outline:
name: outline
outline-postgres:
name: outline-postgres
networks:
default:
name: outline
services:
outline:
container_name: outline
image: flameshikari/outline-ru:nightly
build:
context: .
dockerfile: Dockerfile.prod
args:
- APP_PATH=${APP_PATH}
- SRC_PATH=${SRC_PATH}
network_mode: host
pull_policy: always
volumes:
- outline:/var/lib/outline/data
depends_on:
- outline-postgres
- outline-redis
- outline-oidc
environment:
FILE_STORAGE: local
FORCE_HTTPS: false
PORT: ${PORT_OUTLINE}
URL: http://${ADDRESS}:${PORT_OUTLINE}
SECRET_KEY: ${SECRET}
UTILS_SECRET: ${SECRET}
REDIS_URL: redis://${ADDRESS}:${PORT_REDIS}
DATABASE_URL: postgres://${COMMON}:${COMMON}@${ADDRESS}:${PORT_POSTGRES}/${COMMON}
PGSSLMODE: disable
OIDC_ISSUER_URL: http://${ADDRESS}:${PORT_OIDC}
OIDC_CLIENT_ID: ${COMMON}
OIDC_CLIENT_SECRET: ${COMMON}
outline-oidc:
container_name: outline-oidc
image: ghcr.io/soluto/oidc-server-mock:0.11.0
ports:
- ${PORT_OIDC}:80
healthcheck:
test: curl -fs ${ADDRESS}/health || exit 1
start_period: 2s
interval: 1s
timeout: 100ms
retries: 10
environment:
ASPNETCORE_URLS: http://+:80
ASPNETCORE_ENVIRONMENT: Development
CLIENTS_CONFIGURATION_INLINE: |
[
{
"ClientId": "${COMMON}",
"ClientSecrets": ["${COMMON}"],
"RedirectUris": ["http://${ADDRESS}:${PORT_OUTLINE}/auth/oidc.callback"],
"AllowedGrantTypes": ["authorization_code"],
"AllowedScopes": ["openid", "profile", "email"],
"RequirePkce": false
}
]
USERS_CONFIGURATION_INLINE: |
[
{
"SubjectId": "1",
"Username": "${COMMON}",
"Password": "${COMMON}",
"Claims": [
{
"Type": "email",
"Value": "mail@example.com",
"ValueType": "string"
},
{
"Type": "name",
"Value": "Outline",
"ValueType": "string"
}
]
}
]
SERVER_OPTIONS_INLINE: |
{
"AccessTokenJwtType": "JWT",
"Discovery": {
"ShowKeySet": true
},
"Authentication": {
"CookieSameSiteMode": "Lax",
"CheckSessionCookieSameSiteMode": "Lax"
}
}
LOGIN_OPTIONS_INLINE: |
{
"AllowRememberLogin": false
}
LOGOUT_OPTIONS_INLINE: |
{
"AutomaticRedirectAfterSignOut": true
}
ASPNET_SERVICES_OPTIONS_INLINE: |
{
"ForwardedHeadersOptions": {
"ForwardedHeaders" : "All"
}
}
outline-redis:
container_name: outline-redis
image: redis:7
ports:
- ${PORT_REDIS}:6379
healthcheck:
test: redis-cli ping
interval: 10s
timeout: 30s
retries: 3
outline-postgres:
container_name: outline-postgres
image: postgres:17
ports:
- ${PORT_POSTGRES}:5432
volumes:
- outline-postgres:/var/lib/postgresql/data
healthcheck:
test: pg_isready
interval: 30s
timeout: 20s
retries: 3
environment:
POSTGRES_USER: ${COMMON}
POSTGRES_PASSWORD: ${COMMON}
POSTGRES_DB: ${COMMON}
+58 -25
View File
@@ -8,33 +8,66 @@ networks:
default: default:
name: outline name: outline
x-outline-base: &outline-base
container_name: outline
depends_on:
- outline-services
environment:
FILE_STORAGE: local
FORCE_HTTPS: false
PORT: ${PORT:-10240}
PORT_VITE: ${PORT_VITE:-10242}
URL: http://127.0.0.1:${PORT:-10240}
SECRET_KEY: ${SECRET:-deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef}
UTILS_SECRET: ${SECRET:-deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef}
REDIS_URL: redis://outline-services
DATABASE_URL: postgres://outline:outline@outline-services/outline
PGSSLMODE: disable
OIDC_ISSUER_URL: http://outline-services:8080
OIDC_CLIENT_ID: outline
OIDC_CLIENT_SECRET: outline
OIDC_DISPLAY_NAME: OIDC
services: services:
outline: outline:
extends: <<: *outline-base
file: docker-compose.prod.yml profiles: [prod]
service: outline build: .
image: !reset image: flameshikari/outline-ru:nightly
pull_policy: !reset ports:
build: - ${PORT:-10240}:${PORT:-10240}
dockerfile: !reset pull_policy: always
depends_on:
- outline-postgres
- outline-redis
- outline-oidc
volumes: volumes:
- outline:/var/lib/outline/data
outline-dev:
<<: *outline-base
build:
dockerfile: Dockerfile.dev
profiles: [dev]
ports:
- ${PORT:-10240}:${PORT:-10240}
- ${PORT_VITE:-10242}:${PORT_VITE:-10242}
volumes:
- outline:/var/lib/outline/data
- ./translation/ru.json:/opt/outline/shared/i18n/locales/ru_RU/translation.json - ./translation/ru.json:/opt/outline/shared/i18n/locales/ru_RU/translation.json
outline-oidc: outline-services:
extends: container_name: outline-services
file: docker-compose.prod.yml image: outline-services
service: outline-oidc build: services
environment:
outline-redis: ISSUER: http://outline-services:8080
extends: PUBLIC_URL: http://127.0.0.1:${PORT_OIDC:-10241}
file: docker-compose.prod.yml volumes:
service: outline-redis - outline-postgres:/var/lib/postgresql/data
ports:
outline-postgres: - ${PORT_OIDC:-10241}:8080
extends: healthcheck:
file: docker-compose.prod.yml test: |
service: outline-postgres pg_isready -U outline && \
redis-cli ping && \
wget -qO- http://127.0.0.1:8080/health
interval: 10s
timeout: 5s
retries: 5
+1 -1
Submodule outline updated: 05eac5bc3b...568b4ac074
+122
View File
@@ -0,0 +1,122 @@
diff --git a/app/utils/i18n.ts b/app/utils/i18n.ts
index d20fe802a..de4987a99 100644
--- a/app/utils/i18n.ts
+++ b/app/utils/i18n.ts
@@ -51,5 +51,16 @@ export function initI18n(defaultLanguage = "en_US") {
Logger.error("Failed to initialize i18n", err);
});
+ // HMR: when a translation JSON changes on disk, the Vite dev server emits
+ // an "i18n:update" event (see vite.config.ts). Reload the resources for
+ // that language and ask react-i18next to re-render the tree.
+ if (typeof import.meta.hot !== "undefined") {
+ import.meta.hot.on("i18n:update", async ({ lng: changed }) => {
+ const target = unicodeCLDRtoBCP47(changed);
+ await i18n.reloadResources(target);
+ i18n.emit("languageChanged", i18n.language);
+ });
+ }
+
return i18n;
}
diff --git a/server/middlewares/csp.ts b/server/middlewares/csp.ts
index 477793da6..93bc7c6e9 100644
--- a/server/middlewares/csp.ts
+++ b/server/middlewares/csp.ts
@@ -59,8 +59,9 @@ export default function createCSPMiddleware(options?: CSPOptions) {
// Allow to load assets from Vite
if (!env.isProduction) {
- scriptSrc.push(env.URL.replace(`:${env.PORT}`, ":3001"));
- scriptSrc.push("localhost:3001");
+ const vitePort = Number(process.env.PORT_VITE) || 3001;
+ scriptSrc.push(env.URL.replace(`:${env.PORT}`, `:${vitePort}`));
+ scriptSrc.push(`localhost:${vitePort}`);
} else {
scriptSrc.push(env.URL);
}
diff --git a/server/routes/app.ts b/server/routes/app.ts
index b598752f2..11667c560 100644
--- a/server/routes/app.ts
+++ b/server/routes/app.ts
@@ -22,7 +22,8 @@ import { loadPublicShare } from "@server/commands/shareLoader";
const readFile = util.promisify(fs.readFile);
const entry = "app/index.tsx";
-const viteHost = env.URL.replace(`:${env.PORT}`, ":3001");
+const vitePort = Number(process.env.PORT_VITE) || 3001;
+const viteHost = env.URL.replace(`:${env.PORT}`, `:${vitePort}`);
let indexHtmlCache: Buffer | undefined;
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 0d304ec50..e32ec2da8 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -101,14 +101,23 @@ router.get("/locales/:lng.json", async (ctx) => {
await send(ctx, path.join(lng, "translation.json"), {
setHeaders: (res, _, stats) => {
res.setHeader("Last-Modified", formatRFC7231(stats.mtime));
- res.setHeader("Cache-Control", `public, max-age=${7 * Day.seconds}`);
+ res.setHeader(
+ "Cache-Control",
+ env.isDevelopment
+ ? "no-store"
+ : `public, max-age=${7 * Day.seconds}`
+ );
res.setHeader(
"ETag",
crypto.createHash("md5").update(stats.mtime.toISOString()).digest("hex")
);
res.setHeader("Access-Control-Allow-Origin", "*");
},
- root: path.join(__dirname, "../../shared/i18n/locales"),
+ // In dev read the source tree directly so the bind-mounted translation
+ // files (and HMR'd changes) are seen without rebuilding.
+ root: env.isDevelopment
+ ? path.join(__dirname, "../../../shared/i18n/locales")
+ : path.join(__dirname, "../../shared/i18n/locales"),
});
});
diff --git a/vite.config.ts b/vite.config.ts
index 829346243..244308bb3 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -31,7 +31,7 @@ export default () =>
publicDir: "./server/static",
base: (environment.CDN_URL ?? "") + "/static/",
server: {
- port: 3001,
+ port: Number(process.env.PORT_VITE),
host: true,
https: httpsConfig,
allowedHosts: host ? [host] : undefined,
@@ -45,6 +45,27 @@ export default () =>
: { strict: true },
},
plugins: [
+ {
+ // Custom HMR for translation JSON files. Watches shared/i18n/locales
+ // and sends a custom WS event so the client can call
+ // i18n.reloadResources() instead of doing a full page reload.
+ name: "outline-i18n-hmr",
+ apply: "serve",
+ configureServer(server) {
+ const dir = path.resolve(__dirname, "shared/i18n/locales");
+ server.watcher.add(dir);
+ server.watcher.on("change", (file) => {
+ const m = /locales[\\/]([^\\/]+)[\\/]translation\.json$/.exec(file);
+ if (m) {
+ server.ws.send({
+ type: "custom",
+ event: "i18n:update",
+ data: { lng: m[1] },
+ });
+ }
+ });
+ },
+ },
react(),
// https://vite-pwa-org.netlify.app/
VitePWA({
-34
View File
@@ -1,34 +0,0 @@
diff --git a/server/routes/index.ts b/server/routes/index.ts
index 26fbded27..478de1922 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -103,7 +103,7 @@ router.get("/locales/:lng.json", async (ctx) => {
await send(ctx, path.join(lng, "translation.json"), {
setHeaders: (res, _, stats) => {
res.setHeader("Last-Modified", formatRFC7231(stats.mtime));
- res.setHeader("Cache-Control", `public, max-age=${7 * Day.seconds}`);
+ res.setHeader("Cache-Control", "no-store");
res.setHeader(
"ETag",
crypto.createHash("md5").update(stats.mtime.toISOString()).digest("hex")
diff --git a/vite.config.ts b/vite.config.ts
index 32b52d44e..26d611fee 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -45,6 +45,17 @@ export default () =>
: { strict: true },
},
plugins: [
+ {
+ name: 'reload',
+ configureServer(server) {
+ const { ws, watcher } = server;
+ watcher.on('change', file => {
+ if (file.endsWith('build/shared/i18n/locales/ru_RU/translation.json')) {
+ ws.send({ type: 'full-reload' });
+ }
+ });
+ },
+ },
react(),
// https://vite-pwa-org.netlify.app/
+17
View File
@@ -0,0 +1,17 @@
FROM oven/bun:1-alpine
USER root
RUN apk add --no-cache postgresql17 postgresql17-contrib redis su-exec bash
ENV PGDATA=/var/lib/postgresql/data
RUN mkdir -p "$PGDATA" /run/postgresql /var/log \
&& chown -R postgres:postgres "$PGDATA" /run/postgresql
WORKDIR /app
COPY package.json ./
RUN bun install --production
COPY server.js entrypoint.sh avatar.png ./
RUN chmod +x entrypoint.sh
CMD ["./entrypoint.sh"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
set -e
PG_USER="${POSTGRES_USER:-${COMMON:-outline}}"
PG_PASS="${POSTGRES_PASSWORD:-${COMMON:-outline}}"
PG_DB="${POSTGRES_DB:-${COMMON:-outline}}"
# named-volume mount comes up root-owned; reclaim it for postgres
chown -R postgres:postgres "$PGDATA" /run/postgresql
chmod 700 "$PGDATA"
# ---- Postgres ----
if [ ! -s "$PGDATA/PG_VERSION" ]; then
echo "==> initdb in $PGDATA"
su-exec postgres initdb -D "$PGDATA" --username="$PG_USER" \
--auth-local=trust --auth-host=md5 >/dev/null
echo "listen_addresses = '*'" >> "$PGDATA/postgresql.conf"
echo "host all all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf"
fi
echo "==> starting postgres on :5432"
su-exec postgres pg_ctl -D "$PGDATA" -l "$PGDATA/postgres.log" -w start
# ensure password is set (md5 auth needs it for host connections)
su-exec postgres psql -U "$PG_USER" -d postgres -c \
"ALTER ROLE \"$PG_USER\" WITH LOGIN PASSWORD '$PG_PASS';" >/dev/null
# create db idempotently
su-exec postgres psql -U "$PG_USER" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname='$PG_DB'" | grep -q 1 || \
su-exec postgres createdb -U "$PG_USER" -O "$PG_USER" "$PG_DB"
# ---- Redis ----
echo "==> starting redis on :6379"
redis-server --daemonize yes --bind 0.0.0.0 --port 6379 \
--logfile "" --protected-mode no
# ---- OIDC mock (foreground) ----
echo "==> starting bun OIDC mock on :${PORT_OIDC:-8080}"
exec bun server.js
+13
View File
@@ -0,0 +1,13 @@
{
"name": "mock-oidc",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "bun server.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.0",
"jose": "^5.9.6"
}
}
+230
View File
@@ -0,0 +1,230 @@
// Mock OIDC server with autologin to a single user.
// Compatible with IdentityServer-style clients (/connect/*) and generic OIDC clients.
// NOT FOR PRODUCTION.
import express from 'express';
import cors from 'cors';
import crypto from 'crypto';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
// ---------- Config ----------
// ISSUER = internal URL used by server-side relying parties (outline backend),
// also signed into JWT `iss` claim.
// PUBLIC = browser-facing URL (host:published-port) used in redirect endpoints.
// PORT = derived from ISSUER URL so a single var sets where we listen.
const ISSUER = process.env.ISSUER || 'http://localhost:8080';
const PUBLIC = process.env.PUBLIC_URL || ISSUER;
const PORT = parseInt(new URL(ISSUER).port || '8080', 10);
const CLIENT_ID = process.env.CLIENT_ID || 'mock-client';
const CLIENT_SECRET = process.env.CLIENT_SECRET || 'mock-secret';
const TOKEN_TTL = parseInt(process.env.TOKEN_TTL || '3600', 10);
const CODE_TTL = parseInt(process.env.CODE_TTL || '60', 10);
const USER = {
sub: process.env.USER_SUB || 'user-1',
email: process.env.USER_EMAIL || 'mail@example.com',
email_verified: true,
name: process.env.USER_NAME || 'Outline',
preferred_username: process.env.USER_USERNAME || 'outline',
given_name: process.env.USER_GIVEN || 'Outline',
family_name: process.env.USER_FAMILY || 'Wiki',
roles: (process.env.USER_ROLES || 'admin,user').split(','),
picture: `${PUBLIC}/avatar.png`,
};
// ---------- Keys ----------
const { publicKey, privateKey } = await generateKeyPair('RS256');
const jwk = { ...(await exportJWK(publicKey)), kid: 'mock-key-1', alg: 'RS256', use: 'sig' };
// ---------- In-memory stores ----------
const codes = new Map();
const tokens = new Map();
const refreshTokens = new Map();
// ---------- Helpers ----------
const now = () => Math.floor(Date.now() / 1000);
const signJwt = (payload, audience, ttl = TOKEN_TTL) =>
new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', kid: jwk.kid, typ: 'JWT' })
.setIssuer(ISSUER)
.setSubject(USER.sub)
.setAudience(audience)
.setIssuedAt(now())
.setExpirationTime(now() + ttl)
.setJti(crypto.randomBytes(16).toString('hex'))
.sign(privateKey);
const extractClientCreds = (req) => {
const auth = req.headers.authorization;
if (auth?.startsWith('Basic ')) {
const decoded = Buffer.from(auth.slice(6), 'base64').toString();
const idx = decoded.indexOf(':');
return {
clientId: decodeURIComponent(decoded.slice(0, idx)),
clientSecret: decodeURIComponent(decoded.slice(idx + 1)),
};
}
return { clientId: req.body.client_id, clientSecret: req.body.client_secret };
};
const pkceMatches = (verifier, challenge, method) => {
if (!verifier) return false;
if (method === 'S256') return crypto.createHash('sha256').update(verifier).digest('base64url') === challenge;
return verifier === challenge;
};
// ---------- App ----------
const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use((req, _res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// ---------- Discovery + JWKS ----------
app.get('/.well-known/openid-configuration', (_req, res) => res.json({
issuer: ISSUER,
authorization_endpoint: `${PUBLIC}/connect/authorize`,
token_endpoint: `${ISSUER}/connect/token`,
userinfo_endpoint: `${ISSUER}/connect/userinfo`,
end_session_endpoint: `${PUBLIC}/connect/endsession`,
jwks_uri: `${ISSUER}/.well-known/jwks`,
response_types_supported: ['code'],
response_modes_supported: ['query', 'fragment'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'],
claims_supported: ['sub', 'email', 'email_verified', 'name', 'preferred_username', 'given_name', 'family_name', 'roles'],
code_challenge_methods_supported: ['S256', 'plain'],
grant_types_supported: ['authorization_code', 'refresh_token'],
}));
app.get(['/.well-known/jwks', '/.well-known/openid-configuration/jwks', '/jwks'],
(_req, res) => res.json({ keys: [jwk] }));
// ---------- /authorize: AUTOLOGIN ----------
app.get(['/connect/authorize', '/authorize'], (req, res) => {
const { redirect_uri, state, nonce, scope, response_type,
client_id, code_challenge, code_challenge_method } = req.query;
const fail = (status, error, description) =>
res.status(status).json({ error, error_description: description });
if (response_type !== 'code') return fail(400, 'unsupported_response_type', 'response_type must be "code"');
if (!client_id) return fail(400, 'invalid_request', 'client_id is required');
if (!redirect_uri) return fail(400, 'invalid_request', 'redirect_uri is required');
const code = crypto.randomBytes(32).toString('hex');
codes.set(code, {
client_id, redirect_uri, nonce,
scope: scope || 'openid',
code_challenge, code_challenge_method,
expires_at: Date.now() + CODE_TTL * 1000,
});
const url = new URL(redirect_uri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
console.log(` -> autologin: redirect to ${url.toString()}`);
res.redirect(url.toString());
});
// ---------- /token ----------
app.post(['/connect/token', '/token'], async (req, res) => {
const { grant_type } = req.body;
if (grant_type === 'authorization_code') {
const { code, redirect_uri, code_verifier } = req.body;
const { clientId, clientSecret } = extractClientCreds(req);
const ctx = codes.get(code);
codes.delete(code);
if (!ctx) return res.status(400).json({ error: 'invalid_grant', error_description: 'unknown code' });
if (ctx.expires_at < Date.now()) return res.status(400).json({ error: 'invalid_grant', error_description: 'code expired' });
if (ctx.redirect_uri !== redirect_uri) return res.status(400).json({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' });
if (ctx.code_challenge) {
if (!pkceMatches(code_verifier, ctx.code_challenge, ctx.code_challenge_method))
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
} else if (clientId !== CLIENT_ID || clientSecret !== CLIENT_SECRET) {
return res.status(401).json({ error: 'invalid_client' });
}
const aud = clientId || CLIENT_ID;
const id_token = await signJwt({ ...USER, nonce: ctx.nonce, auth_time: now() }, aud);
const access_token = await signJwt({ scope: ctx.scope, client_id: aud, ...USER }, aud);
tokens.set(access_token, USER);
const response = { access_token, id_token, token_type: 'Bearer', expires_in: TOKEN_TTL, scope: ctx.scope };
if (ctx.scope.split(' ').includes('offline_access')) {
const refresh_token = crypto.randomBytes(32).toString('hex');
refreshTokens.set(refresh_token, { client_id: aud, scope: ctx.scope });
response.refresh_token = refresh_token;
}
return res.json(response);
}
if (grant_type === 'refresh_token') {
const { refresh_token } = req.body;
const ctx = refreshTokens.get(refresh_token);
if (!ctx) return res.status(400).json({ error: 'invalid_grant' });
const id_token = await signJwt({ ...USER, auth_time: now() }, ctx.client_id);
const access_token = await signJwt({ scope: ctx.scope, client_id: ctx.client_id, ...USER }, ctx.client_id);
tokens.set(access_token, USER);
return res.json({
access_token, id_token, refresh_token,
token_type: 'Bearer', expires_in: TOKEN_TTL, scope: ctx.scope,
});
}
res.status(400).json({ error: 'unsupported_grant_type' });
});
// ---------- /userinfo ----------
app.all(['/connect/userinfo', '/userinfo'], (req, res) => {
const auth = req.headers.authorization;
const user = auth?.startsWith('Bearer ') ? tokens.get(auth.slice(7)) : null;
if (!user) return res.status(401).json({ error: 'invalid_token' });
res.json(user);
});
// ---------- /endsession ----------
app.get(['/connect/endsession', '/logout'], (req, res) => {
const { post_logout_redirect_uri, state } = req.query;
if (post_logout_redirect_uri) {
const url = new URL(post_logout_redirect_uri);
if (state) url.searchParams.set('state', state);
return res.redirect(url.toString());
}
res.type('html').send('<h1>Logged out</h1>');
});
// ---------- Index + health + 404 ----------
app.get('/', (_req, res) => res.type('html').send(`<h1>OIDC Mock Server</h1>
<p>Autologin as <strong>${USER.email}</strong> (sub: <code>${USER.sub}</code>)</p>
<ul>
<li><a href="/.well-known/openid-configuration">/.well-known/openid-configuration</a></li>
<li><a href="/.well-known/jwks">/.well-known/jwks</a></li>
<li><a href="/connect/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=http://localhost:3000/callback&scope=openid+profile+email&state=test">Simulate /authorize</a></li>
</ul>
<p>Client ID: <code>${CLIENT_ID}</code> &middot; Client Secret: <code>${CLIENT_SECRET}</code></p>`));
app.get('/avatar.png', (_req, res) => res.sendFile('avatar.png', { root: import.meta.dirname }));
app.get('/health', (_req, res) => res.json({ status: 'ok', issuer: ISSUER }));
app.use((req, res) => res.status(404).json({
error: 'not_found', path: req.path,
hint: 'See /.well-known/openid-configuration',
}));
// ---------- Start ----------
app.listen(PORT, () => {
console.log(`Mock OIDC listening on :${PORT} issuer=${ISSUER} public=${PUBLIC} client_id=${CLIENT_ID} autologin=${USER.email} (sub=${USER.sub}, roles=${USER.roles.join(',')})`);
});
+212 -46
View File
@@ -1,5 +1,6 @@
{ {
"New API key": "Новый ключ API", "New API key": "Новый ключ API",
"Copy": "Скопировать",
"Delete": "Удалить", "Delete": "Удалить",
"Revoke": "Отозвать", "Revoke": "Отозвать",
"Revoke API key": "Отозвать ключ API", "Revoke API key": "Отозвать ключ API",
@@ -76,7 +77,6 @@
"Copy public link": "Скопировать публичную ссылку", "Copy public link": "Скопировать публичную ссылку",
"Link copied to clipboard": "Ссылка скопирована в буфер", "Link copied to clipboard": "Ссылка скопирована в буфер",
"Copy link": "Скопировать ссылку", "Copy link": "Скопировать ссылку",
"Copy": "Скопировать",
"Duplicate": "Дублировать", "Duplicate": "Дублировать",
"Duplicate document": "Дублировать документ", "Duplicate document": "Дублировать документ",
"Copy document": "Скопировать документ", "Copy document": "Скопировать документ",
@@ -173,6 +173,7 @@
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Вы уверены? Удаление шаблона <em>{{ templateName }}</em> необратимо.", "Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Вы уверены? Удаление шаблона <em>{{ templateName }}</em> необратимо.",
"Move to workspace": "Переместить в рабочее пространство", "Move to workspace": "Переместить в рабочее пространство",
"Template moved": "Шаблон перемещён", "Template moved": "Шаблон перемещён",
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Move to collection": "Переместить в коллекцию", "Move to collection": "Переместить в коллекцию",
"Move template": "Переместить шаблон", "Move template": "Переместить шаблон",
"Print template": "Распечатать шаблон", "Print template": "Распечатать шаблон",
@@ -197,21 +198,21 @@
"People": "Люди", "People": "Люди",
"Share": "Поделиться", "Share": "Поделиться",
"Workspace": "Рабочее пространство", "Workspace": "Рабочее пространство",
"Recent searches": "Недавние запросы",
"currently editing": "сейчас редактируется", "currently editing": "сейчас редактируется",
"currently viewing": "сейчас просматривается", "currently viewing": "сейчас просматривается",
"previously edited": "ранее отредактировано", "previously edited": "ранее отредактировано",
"You": "Вы", "You": "Вы",
"Avatar of {{ name }}": "Аватар {{ name }}", "Avatar of {{ name }}": "Аватар {{ name }}",
"Viewers": "Наблюдатели", "Viewers": "Наблюдатели",
"Collections are used to group documents and choose permissions": "Коллекции используются для группировки документов и выбора разрешений", "Members": "Участники",
"Name": "Имя",
"The default access for workspace members, you can share with more users or groups later.": "Доступ по умолчанию для участников рабочего пространства. Позже вы сможете поделиться ими с другими пользователями или группами.",
"Advanced options": "Расширенные параметры",
"Public document sharing": "Общий доступ к документу", "Public document sharing": "Общий доступ к документу",
"Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.", "Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.",
"Commenting": "Комментарий", "Commenting": "Комментарий",
"Allow commenting on documents within this collection.": "Разрешить комментирование документов в этой коллекции.", "Allow commenting on documents within this collection.": "Разрешить комментирование документов в этой коллекции.",
"Collections are used to group documents and choose permissions": "Коллекции используются для группировки документов и выбора разрешений",
"Name": "Имя",
"The default access for workspace members, you can share with more users or groups later.": "Доступ по умолчанию для участников рабочего пространства. Позже вы сможете поделиться ими с другими пользователями или группами.",
"Advanced options": "Расширенные параметры",
"Saving": "Сохранение", "Saving": "Сохранение",
"Save": "Сохранить", "Save": "Сохранить",
"Creating": "Создание", "Creating": "Создание",
@@ -239,6 +240,7 @@
"Install now": "Установить сейчас", "Install now": "Установить сейчас",
"Deleted Collection": "Удаленная коллекция", "Deleted Collection": "Удаленная коллекция",
"Untitled": "Без названия", "Untitled": "Без названия",
"Document options": "Параметры документа",
"Unpin": "Открепить", "Unpin": "Открепить",
"Export started": "Экспорт начат", "Export started": "Экспорт начат",
"A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте", "A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте",
@@ -263,7 +265,6 @@
"Couldnt move the document, try again?": "Не удалось переместить документ. Попробовать снова?", "Couldnt move the document, try again?": "Не удалось переместить документ. Попробовать снова?",
"Move to <em>{{ location }}</em>": "Переместить в <em>{{ location }}</em>", "Move to <em>{{ location }}</em>": "Переместить в <em>{{ location }}</em>",
"Couldnt move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?", "Couldnt move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Document options": "Параметры документа",
"New": "Новое", "New": "Новое",
"Only visible to you": "Видно только вам", "Only visible to you": "Видно только вам",
"Draft": "Черновик", "Draft": "Черновик",
@@ -296,16 +297,15 @@
"Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}", "Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}",
"File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.", "File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.",
"File size too large. Maximum size is {{ size }}.": "Размер файла слишком большой. Максимальный размер — {{ size }}.", "File size too large. Maximum size is {{ size }}.": "Размер файла слишком большой. Максимальный размер — {{ size }}.",
"Please enter a name for the emoji": "Пожалуйста, введите имя для эмодзи.",
"Please select an image file": "Пожалуйста, выберите изображение",
"Emoji created successfully": "Эмодзи успешно созданы",
"Add emoji": "Добавить эмодзи",
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you.": "Квадратные изображения с прозрачным фоном подходят лучше всего. Если изображение слишком большое, мы попробуем уменьшить его размер за вас.",
"Upload an image": "Загрузить изображение",
"Click or drag to replace": "Кликните или перетащите, чтобы заменить", "Click or drag to replace": "Кликните или перетащите, чтобы заменить",
"Drop the image here": "Перетащите изображение сюда", "Drop the image here": "Перетащите изображение сюда",
"Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда", "Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда",
"PNG, JPG, GIF, or WebP up to {{ size }}": "PNG, JPG, GIF или WebP размером до {{ size }}", "PNG, JPG, GIF, or WebP up to {{ size }}": "PNG, JPG, GIF или WebP размером до {{ size }}",
"Please enter a name for the emoji": "Пожалуйста, введите имя для эмодзи.",
"Please select an image file": "Пожалуйста, выберите изображение",
"Emoji created successfully": "Эмодзи успешно созданы",
"Add emoji": "Добавить эмодзи",
"Upload an image": "Загрузить изображение",
"Choose a name": "Выберите имя", "Choose a name": "Выберите имя",
"name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.", "name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.",
"This emoji will be available as": "Этот эмодзи будет доступен как", "This emoji will be available as": "Этот эмодзи будет доступен как",
@@ -319,15 +319,6 @@
"our engineers have been notified": "наши инженеры были уведомлены", "our engineers have been notified": "наши инженеры были уведомлены",
"Clear cache + reload": "Очистить кэш и перезагрузить", "Clear cache + reload": "Очистить кэш и перезагрузить",
"Show detail": "Показать детали", "Show detail": "Показать детали",
"{{userName}} archived": "{{userName}} архивирован",
"{{userName}} restored": "{{userName}} восстановлен",
"{{userName}} deleted": "{{userName}} удален",
"{{userName}} added {{addedUserName}}": "{{userName}} добавил {{addedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} удалил {{removedUserName}}",
"{{userName}} moved from trash": "{{userName}} перемещен из корзины",
"{{userName}} published": "{{userName}} опубликовал",
"{{userName}} unpublished": "{{userName}} снял с публикации",
"{{userName}} moved": "{{userName}} переместил",
"A ZIP file containing the images, and documents in the Markdown format.": "ZIP-архив, содержащий изображения и документы в формате Markdown.", "A ZIP file containing the images, and documents in the Markdown format.": "ZIP-архив, содержащий изображения и документы в формате Markdown.",
"A ZIP file containing the images, and documents as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.", "A ZIP file containing the images, and documents as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.",
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Структурированные данные, которые можно использовать для передачи данных в другой совместимый инстанс {{ appName }}.", "Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Структурированные данные, которые можно использовать для передачи данных в другой совместимый инстанс {{ appName }}.",
@@ -422,15 +413,6 @@
"{{ hours }}h {{ minutes }}m read": "{{ hours }}ч {{ minutes }}м чтения", "{{ hours }}h {{ minutes }}m read": "{{ hours }}ч {{ minutes }}м чтения",
"{{ hours }}h read": "{{ hours }}ч чтения", "{{ hours }}h read": "{{ hours }}ч чтения",
"{{ minutes }}m read": "{{ minutes }}м чтения", "{{ minutes }}m read": "{{ minutes }}м чтения",
"Revision deleted": "Ревизия удалена",
"{{count}} people_0": "{{count}} человек",
"{{count}} people_1": "{{count}} человека",
"{{count}} people_2": "{{count}} человек",
"Current version": "Текущая версия",
"{{userName}} edited": "{{userName}} отредактировал",
"Revision options": "Настройка ревизии",
"Results": "Результаты",
"No results for {{query}}": "По запросу «{{ query }}» ничего не найдено",
"Manage": "Управлять", "Manage": "Управлять",
"All members": "Все участники", "All members": "Все участники",
"Everyone in the workspace": "Все в рабочем пространстве", "Everyone in the workspace": "Все в рабочем пространстве",
@@ -462,6 +444,8 @@
"Switch to light": "Вкл. светлую тему", "Switch to light": "Вкл. светлую тему",
"Add": "Добавить", "Add": "Добавить",
"Add or invite": "Добавить или пригласить", "Add or invite": "Добавить или пригласить",
"Something went wrong": "Что-то пошло не так",
"Email address": "Адрес почты",
"Viewer": "Наблюдатель", "Viewer": "Наблюдатель",
"Editor": "Редактор", "Editor": "Редактор",
"Suggestions for invitation": "Предложения для приглашения", "Suggestions for invitation": "Предложения для приглашения",
@@ -498,9 +482,9 @@
"Expand sidebar": "Развернуть боковую панель", "Expand sidebar": "Развернуть боковую панель",
"Collapse sidebar": "Свернуть боковую панель", "Collapse sidebar": "Свернуть боковую панель",
"Archived collections": "Архивированные коллекции", "Archived collections": "Архивированные коллекции",
"Empty": "Пусто",
"New doc": "Новый документ", "New doc": "Новый документ",
"New nested document": "Новый вложенный документ", "New nested document": "Новый вложенный документ",
"Empty": "Пусто",
"No collections": "Нет коллекций", "No collections": "Нет коллекций",
"Collapse": "Свернуть", "Collapse": "Свернуть",
"Expand": "Развернуть", "Expand": "Развернуть",
@@ -522,6 +506,7 @@
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}", "{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту", "You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту",
"{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда", "{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда",
"Integrations": "Интеграции",
"Return to App": "На главную", "Return to App": "На главную",
"Installation": "Установка", "Installation": "Установка",
"Unstar document": "Убрать документ из избранного", "Unstar document": "Убрать документ из избранного",
@@ -565,12 +550,14 @@
"Height": "Высота", "Height": "Высота",
"Profile picture": "Фото профиля", "Profile picture": "Фото профиля",
"Create a new doc": "Создать новый документ", "Create a new doc": "Создать новый документ",
"Create a nested doc": "Создать вложенный документ",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} не будет уведомлен, так как у него нет доступа к этому документу", "{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} не будет уведомлен, так как у него нет доступа к этому документу",
"Members of \"{{ groupName }}\" that have access to this document will be notified": "Участники группы «{{ groupName }}», имеющие доступ к этому документу, будут уведомлены", "Members of \"{{ groupName }}\" that have access to this document will be notified": "Участники группы «{{ groupName }}», имеющие доступ к этому документу, будут уведомлены",
"Keep as link": "Сохранить как ссылку", "Keep as link": "Сохранить как ссылку",
"Mention": "Упоминание", "Mention": "Упоминание",
"Embed": "Вставить", "Embed": "Вставить",
"Not supported": "Не поддерживается", "Not supported": "Не поддерживается",
"Upload file": "Загрузить файл",
"More options": "Больше параметров", "More options": "Больше параметров",
"Rename": "Переименовать", "Rename": "Переименовать",
"Insert after": "Вставить после", "Insert after": "Вставить после",
@@ -677,7 +664,6 @@
"Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Вы уверены, что хотите удалить эмодзи <em>{{emojiName}}</em>? Вы больше не сможете использовать его в своих документах или коллекциях.", "Are you sure you want to delete the <em>{{emojiName}}</em> emoji? You will no longer be able to use it in your documents or collections.": "Вы уверены, что хотите удалить эмодзи <em>{{emojiName}}</em>? Вы больше не сможете использовать его в своих документах или коллекциях.",
"Edit group": "Редактировать группу", "Edit group": "Редактировать группу",
"Delete group": "Удалить группу", "Delete group": "Удалить группу",
"Members": "Участники",
"Could not import file": "Не удалось импортировать файл", "Could not import file": "Не удалось импортировать файл",
"Unsubscribed from document": "Отписаться от документа", "Unsubscribed from document": "Отписаться от документа",
"Unsubscribed from collection": "Отменена подписка на коллекцию", "Unsubscribed from collection": "Отменена подписка на коллекцию",
@@ -693,7 +679,6 @@
"Import": "Импорт", "Import": "Импорт",
"Embeds": "Встраивания", "Embeds": "Встраивания",
"Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.", "Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.",
"Integrations": "Интеграции",
"Install": "Установить", "Install": "Установить",
"Change name": "Изменить имя", "Change name": "Изменить имя",
"Change email": "Изменить адрес почты", "Change email": "Изменить адрес почты",
@@ -721,6 +706,7 @@
"Revoking": "Отзыв доступа", "Revoking": "Отзыв доступа",
"Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?", "Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?",
"Delete app": "Удалить приложение", "Delete app": "Удалить приложение",
"Revision options": "Настройка ревизии",
"Share options": "Настройка доступа", "Share options": "Настройка доступа",
"Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ", "Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ",
"Contents": "Содержимое", "Contents": "Содержимое",
@@ -734,8 +720,8 @@
"published": "опубликованный", "published": "опубликованный",
"edited": "отредактировано", "edited": "отредактировано",
"created the collection": "создана коллекция", "created the collection": "создана коллекция",
"mentioned you in": "упомянул вас в", "mentioned you in": "упомянул(а) вас в",
"mentioned your group in": "упомянул вашу группу в", "mentioned your group in": "упомянул(а) вашу группу в",
"left a comment on": "оставил комментарий в", "left a comment on": "оставил комментарий в",
"resolved a comment on": "отметил комментарий как решённый в", "resolved a comment on": "отметил комментарий как решённый в",
"reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в", "reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в",
@@ -836,8 +822,23 @@
"Archived": "Архивировано", "Archived": "Архивировано",
"Save draft": "Сохранить черновик", "Save draft": "Сохранить черновик",
"Restore version": "Восстановить версию", "Restore version": "Восстановить версию",
"{{userName}} archived": "{{userName}} архивирован",
"{{userName}} restored": "{{userName}} восстановлен",
"{{userName}} deleted": "{{userName}} удален",
"{{userName}} added {{addedUserName}}": "{{userName}} добавил {{addedUserName}}",
"{{userName}} removed {{removedUserName}}": "{{userName}} удалил {{removedUserName}}",
"{{userName}} moved from trash": "{{userName}} перемещен из корзины",
"{{userName}} published": "{{userName}} опубликовал",
"{{userName}} unpublished": "{{userName}} снял с публикации",
"{{userName}} moved": "{{userName}} переместил",
"Highlight changes": "Выделить изменения", "Highlight changes": "Выделить изменения",
"No history yet": "Истории пока нет", "No history yet": "Истории пока нет",
"Revision deleted": "Ревизия удалена",
"Current version": "Текущая версия",
"{{userName}} edited": "{{userName}} отредактировал",
"{{count}} people_0": "{{count}} человек",
"{{count}} people_1": "{{count}} человека",
"{{count}} people_2": "{{count}} человек",
"Source": "Источник", "Source": "Источник",
"Created": "Создан", "Created": "Создан",
"Imported from {{ source }}": "Импортировано из {{ source }}", "Imported from {{ source }}": "Импортировано из {{ source }}",
@@ -901,7 +902,6 @@
"Your account has been suspended": "Ваш аккаунт отключён", "Your account has been suspended": "Ваш аккаунт отключён",
"Warning Sign": "Предупреждающий знак", "Warning Sign": "Предупреждающий знак",
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Администратор рабочего пространства (<em>{{ suspendedContactEmail }}</em>) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.", "A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "Администратор рабочего пространства (<em>{{ suspendedContactEmail }}</em>) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.",
"Something went wrong": "Что-то пошло не так",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.", "Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.",
"Created by me": "Созданные мной", "Created by me": "Созданные мной",
"Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы", "Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы",
@@ -1067,6 +1067,7 @@
"Any time": "За любое время", "Any time": "За любое время",
"Remove document filter": "Удалить фильтр документа", "Remove document filter": "Удалить фильтр документа",
"Any status": "Любой статус", "Any status": "Любой статус",
"Recent searches": "Недавние запросы",
"Remove search": "Убрать поиск", "Remove search": "Убрать поиск",
"Relevance": "Релевантность", "Relevance": "Релевантность",
"Newest": "Новее", "Newest": "Новее",
@@ -1353,7 +1354,6 @@
"Photo": "Фото", "Photo": "Фото",
"Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.", "Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.", "This could be your real name, or a nickname — however youd like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.",
"Email address": "Адрес почты",
"Members and guests": "Участники и гости", "Members and guests": "Участники и гости",
"No one": "Никто", "No one": "Никто",
"Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?", "Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?",
@@ -1580,6 +1580,9 @@
"Script name": "Имя скрипта", "Script name": "Имя скрипта",
"The name of the script file that Umami uses to track analytics.": "Имя файла скрипта, который Umami использует для отслеживания аналитики.", "The name of the script file that Umami uses to track analytics.": "Имя файла скрипта, который Umami использует для отслеживания аналитики.",
"An ID that uniquely identifies the website in your Umami instance.": "Идентификатор, который однозначно идентифицирует сайт в вашем инстансе Umami.", "An ID that uniquely identifies the website in your Umami instance.": "Идентификатор, который однозначно идентифицирует сайт в вашем инстансе Umami.",
"New webhook": "Новый вебхук",
"Edit webhook": "Редактировать вебхук",
"Delete webhook": "Удалить вебхук",
"Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?", "Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?",
"Webhook updated": "Вебхук обновлен", "Webhook updated": "Вебхук обновлен",
"Update": "Обновить", "Update": "Обновить",
@@ -1591,14 +1594,9 @@
"Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.", "Subscribe to all events, groups, or individual events. We recommend only subscribing to the minimum amount of events that your application needs to function.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.",
"All events": "Все события", "All events": "Все события",
"All {{ groupName }} events": "Все события {{ groupName }}", "All {{ groupName }} events": "Все события {{ groupName }}",
"Delete webhook": "Удалить вебхук",
"Subscribed events": "События, на которые оформлена подписка",
"Edit webhook": "Редактировать вебхук",
"Webhook created": "Вебхук создан", "Webhook created": "Вебхук создан",
"Webhooks": "Вебхуки", "Webhooks": "Вебхуки",
"New webhook": "Новый вебхук",
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Вебхуки можно использовать для уведомления вашего приложения о событиях, происходящих в {{appName}}. События отправляются в виде HTTPS-запроса с JSON-полезной нагрузкой практически в реальном времени.", "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Вебхуки можно использовать для уведомления вашего приложения о событиях, происходящих в {{appName}}. События отправляются в виде HTTPS-запроса с JSON-полезной нагрузкой практически в реальном времени.",
"Inactive": "Неактивно",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier — это платформа, которая позволяет {{appName}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.", "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier — это платформа, которая позволяет {{appName}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.",
"Never logged in": "Никогда не входил", "Never logged in": "Никогда не входил",
"Online now": "Сейчас в сети", "Online now": "Сейчас в сети",
@@ -1614,7 +1612,175 @@
"Open": "Открыть", "Open": "Открыть",
"Loading": "Загрузка", "Loading": "Загрузка",
"Error loading data": "Не удалось загрузить данные", "Error loading data": "Не удалось загрузить данные",
"Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?", "API key copied": "Ключ API скопирован",
"Create a nested doc": "Создать вложенный документ", "Search results": "Результаты поиска",
"Upload file": "Загрузить файл" "Managers": "Менеджеры",
"Manage templates": "Управление шаблонами",
"Choose who can create and edit templates in this collection.": "Выберите, кто может создавать и редактировать шаблоны в этой коллекции.",
"Collection options": "Параметры коллекции",
"Square images with transparent backgrounds work best. If your image is too large, we'll try to resize it for you.": "Лучше всего подходят квадратные изображения с прозрачным фоном. Если изображение слишком большое, мы попробуем уменьшить его размер.",
"Emoji replaced": "Эмодзи заменено",
"Upload a new image to replace the current one for <em>{{emojiName}}</em>. All existing uses of this emoji will be updated automatically.": "Загрузите новое изображение, чтобы заменить текущее для <em>{{emojiName}}</em>. Все существующие случаи использования этого эмодзи будут обновлены автоматически.",
"Email subscriptions": "Подписки на уведомления по почте",
"Allow viewers to subscribe and receive email notifications when documents are updated": "Разрешить наблюдателям подписываться и получать уведомления по почте при обновлении документов",
"Subscribe to updates": "Подписаться на обновления",
"Check your email to confirm your subscription": "Проверьте почту, чтобы подтвердить подписку",
"Get notified when this document is updated": "Получать уведомления при обновлении этого документа",
"Allow viewers to subscribe and receive email notifications when this document is updated": "Разрешить наблюдателям подписываться и получать уведомления по почте при обновлении этого документа",
"Recent": "Недавние",
"Subscription successful": "Подписка оформлена",
"Unsubscribed": "Отписка выполнена",
"Previous version": "Предыдущая версия",
"Compare to": "Сравнить с",
"Personal keys": "Личные ключи",
"Could not load API keys": "Не удалось загрузить ключи API",
"Key": "Ключ",
"Created by": "Создал",
"Never": "Никогда",
"Expires": "Истекает",
"Additional guidance": "Дополнительные указания",
"You can use these optional instructions to tell MCP clients how to use your knowledge base.": "Вы можете использовать эти необязательные инструкции, чтобы сообщить MCP-клиентам, как использовать вашу базу знаний.",
"New passkey added to your {{ appName }} account": "В аккаунт {{ appName }} добавлен новый ключ доступа",
"A new passkey was created for your account.": "Для вашего аккаунта создан новый ключ доступа.",
"New Passkey Created": "Создан новый ключ доступа",
"A new passkey has been added to your {{ appName }} account": "К вашему аккаунту {{ appName }} добавлен новый ключ доступа",
"Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.": "Ключи доступа обеспечивают безопасный вход в аккаунт без пароля. Если вы не создавали этот ключ, немедленно проверьте настройки безопасности своего аккаунта.",
"You can manage your passkeys at any time": "Вы можете управлять своими ключами доступа в любое время",
"If you have any concerns about your account security, please contact a workspace admin.": "Если у вас есть опасения по поводу безопасности аккаунта, обратитесь к администратору рабочего пространства.",
"Manage Passkeys": "Управление ключами доступа",
"Webhook": "Вебхук",
"Could not load webhooks": "Не удалось загрузить вебхуки",
"Delayed notification": "Отложенное уведомление",
"“{{ collectionName }}” created": "«{{ collectionName }}» создана",
"{{ userName }} created a collection": "{{ userName }} создал(а) коллекцию",
"{{ userName }} created the collection “{{ collectionName }}”": "{{ userName }} создал(а) коллекцию «{{ collectionName }}»",
"Open Collection": "Открыть коллекцию",
"View Collection": "Просмотр коллекции",
"{{ userName }} created the collection “{{ collectionName }}”.": "{{ userName }} создал(а) коллекцию «{{ collectionName }}».",
"Unsubscribe from these emails": "Отписаться от этих писем",
"{{ actorName }} invited you to the “{{ collectionName }}” collection": "{{ actorName }} пригласил(а) вас в коллекцию «{{ collectionName }}»",
"{{ actorName }} invited you to a collection": "{{ actorName }} пригласил(а) вас в коллекцию",
"{{ actorName }} invited you to the “{{ collectionName }}” collection.": "{{ actorName }} пригласил(а) вас в коллекцию «{{ collectionName }}».",
"view and edit": "просмотр и редактирование",
"manage": "управление",
"view": "просмотр",
"{{ actorName }} invited you to {{ permission }} documents in the": "{{ actorName }} пригласил(а) вас на {{ permission }} документов в",
"Re": "Re",
"New comment on “{{ documentTitle }}” - {{ trimmedText }}": "Новый комментарий к «{{ documentTitle }}» — {{ trimmedText }}",
"{{ actorName }} replied in a thread": "{{ actorName }} ответил(а) в обсуждении",
"{{ actorName }} commented on the document": "{{ actorName }} оставил(а) комментарий к документу",
"{{ actorName }} replied to a thread in “{{ documentTitle }}”": "{{ actorName }} ответил(а) в обсуждении в «{{ documentTitle }}»",
"{{ actorName }} commented on “{{ documentTitle }}”": "{{ actorName }} оставил(а) комментарий в «{{ documentTitle }}»",
"in the {{ collectionName }} collection": "в коллекции {{ collectionName }}",
"Open Thread": "Открыть обсуждение",
"View Thread": "Просмотр обсуждения",
"{{ actorName }} replied to a thread in": "{{ actorName }} ответил(а) в обсуждении в",
"{{ actorName }} commented on": "{{ actorName }} оставил(а) комментарий в",
"Mentioned you in “{{ documentTitle }}”": "Упомянул(а) вас в «{{ documentTitle }}»",
"{{ actorName }} mentioned you in a thread": "{{ actorName }} упомянул(а) вас в обсуждении",
"{{ actorName }} mentioned you in a comment on “{{ documentTitle }}”": "{{ actorName }} упомянул(а) вас в комментарии в «{{ documentTitle }}»",
"{{ actorName }} mentioned you in a comment on": "{{ actorName }} упомянул(а) вас в комментарии в",
"Resolved a comment thread in “{{ documentTitle }}”": "Отметил(а) ветку комментариев как решённую в «{{ documentTitle }}»",
"{{ actorName }} resolved a comment thread": "{{ actorName }} отметил(а) ветку комментариев как решённую",
"{{ actorName }} resolved a comment thread on “{{ documentTitle }}”": "{{ actorName }} отметил(а) ветку комментариев как решённую в «{{ documentTitle }}»",
"{{ actorName }} resolved a comment on": "{{ actorName }} отметил(а) комментарий как решённый в",
"Your workspace deletion request": "Ваш запрос на удаление рабочего пространства",
"Your requested workspace deletion code": "Запрошенный код для удаления рабочего пространства",
"You requested to permanently delete your {{ appName }} workspace. Please enter the code below to confirm your workspace deletion.": "Вы запросили окончательное удаление рабочего пространства {{ appName }}. Введите код ниже, чтобы подтвердить удаление.",
"Your email update request": "Ваш запрос на изменение адреса почты",
"Heres your email change confirmation.": "Вот ваше подтверждение изменения адреса почты.",
"You requested to update your {{ appName }} account email. Please follow the link below to confirm the change from {{ previous }} to {{ to }}.": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Перейдите по ссылке ниже, чтобы подтвердить изменение с {{ previous }} на {{ to }}.",
"You requested to update your {{ appName }} account email. Please follow the link below to confirm the change to {{ to }}.": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Перейдите по ссылке ниже, чтобы подтвердить изменение на {{ to }}.",
"You requested to update your {{ appName }} account email. Please click below to confirm the change from {{ previous }} to": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Нажмите ниже, чтобы подтвердить изменение с {{ previous }} на",
"You requested to update your {{ appName }} account email. Please click below to confirm the change to": "Вы запросили изменение адреса почты аккаунта {{ appName }}. Нажмите ниже, чтобы подтвердить изменение на",
"Confirm Change": "Подтвердить изменение",
"Your account deletion request": "Ваш запрос на удаление аккаунта",
"Your requested account deletion code": "Запрошенный код для удаления аккаунта",
"You requested to permanently delete your {{ appName }} user account in the {{ teamName }} workspace. Please enter the code below to confirm your account deletion.": "Вы запросили окончательное удаление аккаунта {{ appName }} в рабочем пространстве {{ teamName }}. Введите код ниже, чтобы подтвердить удаление аккаунта.",
"You requested to permanently delete your {{ appName }} user account in the": "Вы запросили окончательное удаление аккаунта {{ appName }} в",
"workspace. Please enter the code below to confirm your account deletion.": "рабочем пространстве. Введите код ниже, чтобы подтвердить удаление аккаунта.",
"{{ actorName }} mentioned you": "{{ actorName }} упомянул(а) вас",
"You were mentioned": "Вас упомянули",
"{{ actorName }} mentioned you in the document “{{ documentTitle }}”.": "{{ actorName }} упомянул(а) вас в документе «{{ documentTitle }}».",
"Open Document": "Открыть документ",
"View Document": "Просмотр документа",
"{{ actorName }} mentioned you in the document": "{{ actorName }} упомянул(а) вас в документе",
"updated": "обновил(а)",
"“{{ documentTitle }}” {{ eventName }}": "«{{ documentTitle }}» — {{ eventName }}",
"{{ actorName }} {{ eventName }} a document": "{{ actorName }} {{ eventName }} документ",
"\"{{ documentTitle }}\" {{ eventName }}": "\"{{ documentTitle }}\" {{ eventName }}",
"{{ actorName }} {{ eventName }} the document \"{{ documentTitle }}\"": "{{ actorName }} {{ eventName }} документ \"{{ documentTitle }}\"",
"{{ actorName }} {{ eventName }} the document": "{{ actorName }} {{ eventName }} документ",
"Unsubscribe from this doc": "Отписаться от этого документа",
"{{ actorName }} shared “{{ documentTitle }}” with you": "{{ actorName }} поделил(а)ся с вами «{{ documentTitle }}»",
"{{ actorName }} shared a document": "{{ actorName }} поделил(а)ся документом",
"{{ actorName }} shared “{{ documentTitle }}” with you.": "{{ actorName }} поделил(а)ся с вами «{{ documentTitle }}».",
"edit": "редактирование",
"{{ actorName }} invited you to {{ permission }} the": "{{ actorName }} пригласил(а) вас на {{ permission }}",
"Your requested export": "Запрошенный вами экспорт",
"Sorry, your requested data export has failed": "К сожалению, экспорт данных не выполнен",
"Your Data Export": "Экспорт ваших данных",
"Sorry, your requested data export has failed, please visit the admin section to try again if the problem persists please contact support.": "К сожалению, экспорт данных не выполнен. Перейдите в раздел администратора, чтобы повторить попытку — если проблема сохраняется, обратитесь в поддержку.",
"Sorry, your requested data export has failed, please visit the": "К сожалению, экспорт данных не выполнен. Перейдите в",
"admin section": "раздел администратора",
"to try again if the problem persists please contact support.": "чтобы повторить попытку — если проблема сохраняется, обратитесь в поддержку.",
"Go to export": "Перейти к экспорту",
"Here's your request data export from {{ appName }}": "Вот ваш запрошенный экспорт данных из {{ appName }}",
"Your requested data export is complete, you can download from the link below in a browser that is logged into your account.": "Запрошенный экспорт данных готов. Скачайте его по ссылке ниже в браузере, в котором выполнен вход в ваш аккаунт.",
"Download export": "Скачать экспорт",
"Your requested data export is complete - you can download from the link below in a browser that is logged into your account.": "Запрошенный экспорт данных готов — скачайте его по ссылке ниже в браузере, в котором выполнен вход в ваш аккаунт.",
"The {{ groupName }} group was mentioned in “{{ documentTitle }}”": "Группа {{ groupName }} упомянута в «{{ documentTitle }}»",
"{{ actorName }} mentioned the “{{ groupName }}” group in a thread": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в обсуждении",
"{{ actorName }} mentioned the “{{ groupName }}” group in a comment on “{{ documentTitle }}”": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в комментарии в «{{ documentTitle }}»",
"{{ actorName }} mentioned the “{{ groupName }}” group in a comment on": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в комментарии в",
"{{ actorName }} mentioned the “{{ groupName }}” group": "{{ actorName }} упомянул(а) группу «{{ groupName }}»",
"{{ actorName }} mentioned the “{{ groupName }}” group in the document “{{ documentTitle }}”.": "{{ actorName }} упомянул(а) группу «{{ groupName }}» в документе «{{ documentTitle }}».",
"Your group was mentioned": "Ваша группа упомянута",
"{{ actorName }} mentioned the \"{{ groupName }}\" group in the document": "{{ actorName }} упомянул(а) группу \"{{ groupName }}\" в документе",
"{{ invitedName }} has joined your {{ appName }} team": "{{ invitedName }} присоединил(а)ся к вашей команде в {{ appName }}",
"Great news, {{ invitedName }}, accepted your invitation": "Отличные новости — {{ invitedName }} принял(а) ваше приглашение",
"Great news, {{ invitedName }} just accepted your invitation and has created an account. You can now start collaborating on documents.": "Отличные новости — {{ invitedName }} только что принял(а) ваше приглашение и создал(а) аккаунт. Теперь вы можете совместно работать над документами.",
"Open {{ appName }}": "Открыть {{ appName }}",
"{{ invitedName }} has joined your team": "{{ invitedName }} присоединил(а)ся к вашей команде",
"{{ actorName }} invited you to join {{ teamName }}s workspace": "{{ actorName }} пригласил(а) вас в рабочее пространство {{ teamName }}",
"{{ appName }} is a place for your team to build and share knowledge.": "{{ appName }} — место, где ваша команда создаёт и делится знаниями.",
"Join {{ teamName }} on {{ appName }}": "Присоединиться к {{ teamName }} в {{ appName }}",
"has invited you to join {{ appName }}, a place for your team to build and share knowledge.": "пригласил(а) вас в {{ appName }} — место, где ваша команда создаёт и делится знаниями.",
"Join now": "Присоединиться",
"Reminder": "Напоминание",
"This is just a quick reminder that {{ actorName }} {{ actorEmail }} invited you to join them in the {{ teamName }} team on {{ appName }}, a place for your team to build and share knowledge.": "Краткое напоминание: {{ actorName }} {{ actorEmail }} пригласил(а) вас в команду {{ teamName }} в {{ appName }} — месте, где ваша команда создаёт и делится знаниями.",
"We only send a reminder once.": "Напоминание отправляется только один раз.",
"If you haven't signed up yet, you can do so here": "Если вы ещё не зарегистрировались, это можно сделать здесь",
"\"{{ documentTitle }}\" updated": "\"{{ documentTitle }}\" обновлён",
"\"{{ documentTitle }}\" has been updated.": "\"{{ documentTitle }}\" обновлён.",
"A document you subscribed to has been updated.": "Документ, на который вы подписаны, был обновлён.",
"Click below to view the latest version.": "Нажмите ниже, чтобы посмотреть последнюю версию.",
"Confirm your subscription": "Подтвердите подписку",
"Confirm your subscription to receive updates when \"{{ documentTitle }}\" changes.": "Подтвердите подписку, чтобы получать обновления при изменениях в \"{{ documentTitle }}\".",
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}. Please confirm your subscription by following the link below.": "Вы запросили уведомления по почте об обновлениях \"{{ documentTitle }}\" в {{ appName }}. Подтвердите подписку, перейдя по ссылке ниже.",
"Confirm Subscription": "Подтвердить подписку",
"This link will expire in 24 hours.": "Срок действия ссылки — 24 часа.",
"You requested to receive email notifications when \"{{ documentTitle }}\" is updated on {{ appName }}.": "Вы запросили уведомления по почте об обновлениях \"{{ documentTitle }}\" в {{ appName }}.",
"Please confirm your subscription by clicking the button below.": "Подтвердите подписку, нажав кнопку ниже.",
"Magic Sign-in Link": "Ссылка для входа",
"Sign in verification code": "Код подтверждения входа",
"Heres your link to signin to {{ appName }}.": "Вот ваша ссылка для входа в {{ appName }}.",
"Use the link below to sign in": "Перейдите по ссылке ниже, чтобы войти",
"If the link expired you can request a new one from your team's signin page at": "Если срок действия ссылки истёк, запросите новую на странице входа вашей команды по адресу",
"Enter this verification code": "Введите этот код подтверждения",
"If the code expired you can request a new one from your team's signin page at": "Если срок действия кода истёк, запросите новый на странице входа вашей команды по адресу",
"Click the button below to sign in to {{ appName }}.": "Нажмите кнопку ниже, чтобы войти в {{ appName }}.",
"If the link expired you can request a new one from your team's sign-in page at": "Если срок действия ссылки истёк, запросите новую на странице входа вашей команды по адресу",
"Sign-in Code": "Код для входа",
"Enter this code on your team's sign-in page to continue.": "Введите этот код на странице входа вашей команды, чтобы продолжить.",
"If the code expired you can request a new one from your team's sign-in page at": "Если срок действия кода истёк, запросите новый на странице входа вашей команды по адресу",
"Webhook disabled": "Вебхук отключён",
"Your webhook ({{ webhookName }}) has been disabled": "Ваш вебхук ({{ webhookName }}) отключён",
"Your webhook ({{ webhookName }}) has been automatically disabled due to a high failure rate in recent delivery attempts. You can re-enable by editing the webhook.": "Ваш вебхук ({{ webhookName }}) автоматически отключён из-за высокого процента ошибок при последних попытках доставки. Включить его снова можно, отредактировав вебхук.",
"Webhook settings": "Настройки вебхука",
"Welcome to {{ appName }}!": "Добро пожаловать в {{ appName }}!",
"To get started, head to the home screen and try creating a collection to help document your processes, create playbooks, or plan your team's work.": "Для начала перейдите на главный экран и попробуйте создать коллекцию, чтобы документировать процессы, создавать плейбуки или планировать работу команды.",
"Or, learn more about everything {{ appName }} can do in the guide": "Или узнайте больше обо всех возможностях {{ appName }} в руководстве",
"Or, learn more about everything {{ appName }} can do in": "Или узнайте больше обо всех возможностях {{ appName }} в",
"the guide": "руководстве"
} }