diff --git a/.env b/.env
index 2557328..12562dd 100644
--- a/.env
+++ b/.env
@@ -1,11 +1,6 @@
-APP_PATH=/opt/outline
-SRC_PATH=./outline
+COMPOSE_PROFILES=dev
-ADDRESS=localhost
-PORT_OUTLINE=10240
-PORT_OIDC=10241
-PORT_REDIS=10242
-PORT_POSTGRES=10243
-
-COMMON=outline
-SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
+# PORT=10240
+# PORT_OIDC=10241
+# PORT_VITE=10242
+# SECRET=deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef # openssl rand -hex 32
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a74b695..ceb42a9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,8 +9,7 @@ on:
- .github/workflows/**
- outline/**
- translation/ru.json
- - patches/**
- - Dockerfile.prod
+ - Dockerfile
workflow_dispatch:
concurrency:
@@ -42,16 +41,16 @@ jobs:
submodules: recursive
- name: Setup Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -65,7 +64,7 @@ jobs:
echo "version=$version" | tee -a $GITHUB_OUTPUT
- name: Set Metadata
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
id: metadata
with:
images: |
@@ -73,13 +72,12 @@ jobs:
ghcr.io/${{ github.repository }}
- name: Build Image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
id: build
with:
cache-from: type=gha,scope=build-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
platforms: linux/${{ matrix.arch }}
- file: Dockerfile.prod
build-args: |
APP_PATH=/opt/outline
SRC_PATH=./outline
@@ -93,7 +91,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload Digests
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v7
with:
name: digests-linux-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
@@ -111,30 +109,30 @@ jobs:
version: ${{ needs.build.outputs.version }}
steps:
- name: Download Digests
- uses: actions/download-artifact@v7
+ uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
username: ${{ github.actor }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub CR
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Set Metadata
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: |
${{ github.repository }}
@@ -153,7 +151,7 @@ jobs:
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Create Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
name: ${{ env.version }}
diff --git a/Dockerfile b/Dockerfile
index 769e8e2..4c44f70 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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 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
@@ -10,16 +13,34 @@ 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
-npx yarn concurrently -n "dev,i18n" \
- "yarn dev:watch" \
- "yarn nodemon \
- --watch './shared/i18n/locales/ru_RU' \
- --exec 'yarn build:i18n'"
-EOF
+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:24.15.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}
-STOPSIGNAL SIGKILL
-ENTRYPOINT ["bash", "/entrypoint.sh"]
+HEALTHCHECK --interval=1m CMD curl -fs localhost:${PORT}/_health | grep -q OK || exit 1
+CMD ["node", "build/server/index.js"]
\ No newline at end of file
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 0000000..be1f4e2
--- /dev/null
+++ b/Dockerfile.dev
@@ -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"]
diff --git a/Dockerfile.prod b/Dockerfile.prod
deleted file mode 100644
index 2233e5d..0000000
--- a/Dockerfile.prod
+++ /dev/null
@@ -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"]
\ No newline at end of file
diff --git a/README.md b/README.md
index d96e185..bb01bc5 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@
```yaml
services:
outline:
- image: flameshikari/outline-ru:1.6.1
- # image: ghcr.io/flameshikari/outline-ru:1.6.1
+ image: flameshikari/outline-ru:1.7.0
+ # image: ghcr.io/flameshikari/outline-ru:1.7.0
env_file: ./docker.env
expose:
- 3000
@@ -93,7 +93,7 @@ services:
2. Пулл изменений в подмодуле и переключение на последний доступный тег:
```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. Запуск контейнеров:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
deleted file mode 100644
index b562f83..0000000
--- a/docker-compose.prod.yml
+++ /dev/null
@@ -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}
diff --git a/docker-compose.yml b/docker-compose.yml
index b8dfc2b..dee2440 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,33 +8,66 @@ networks:
default:
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:
outline:
- extends:
- file: docker-compose.prod.yml
- service: outline
- image: !reset
- pull_policy: !reset
- build:
- dockerfile: !reset
- depends_on:
- - outline-postgres
- - outline-redis
- - outline-oidc
+ <<: *outline-base
+ profiles: [prod]
+ build: .
+ image: flameshikari/outline-ru:nightly
+ ports:
+ - ${PORT:-10240}:${PORT:-10240}
+ pull_policy: always
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
- outline-oidc:
- extends:
- file: docker-compose.prod.yml
- service: outline-oidc
-
- outline-redis:
- extends:
- file: docker-compose.prod.yml
- service: outline-redis
-
- outline-postgres:
- extends:
- file: docker-compose.prod.yml
- service: outline-postgres
+ outline-services:
+ container_name: outline-services
+ image: outline-services
+ build: services
+ environment:
+ ISSUER: http://outline-services:8080
+ PUBLIC_URL: http://127.0.0.1:${PORT_OIDC:-10241}
+ volumes:
+ - outline-postgres:/var/lib/postgresql/data
+ ports:
+ - ${PORT_OIDC:-10241}:8080
+ healthcheck:
+ test: |
+ pg_isready -U outline && \
+ redis-cli ping && \
+ wget -qO- http://127.0.0.1:8080/health
+ interval: 10s
+ timeout: 5s
+ retries: 5
diff --git a/outline b/outline
index 05eac5b..568b4ac 160000
--- a/outline
+++ b/outline
@@ -1 +1 @@
-Subproject commit 05eac5bc3ba7d2d2ecb26c1783cd8b86d3fd408d
+Subproject commit 568b4ac074c67c5c58c75637c3a60b41f5bb1e6b
diff --git a/patches/hmr.patch b/patches/hmr.patch
new file mode 100644
index 0000000..3d1e174
--- /dev/null
+++ b/patches/hmr.patch
@@ -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({
diff --git a/patches/vite.patch b/patches/vite.patch
deleted file mode 100644
index 2dace1d..0000000
--- a/patches/vite.patch
+++ /dev/null
@@ -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/
diff --git a/services/Dockerfile b/services/Dockerfile
new file mode 100644
index 0000000..3bf8503
--- /dev/null
+++ b/services/Dockerfile
@@ -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"]
diff --git a/services/avatar.png b/services/avatar.png
new file mode 100644
index 0000000..77997b1
Binary files /dev/null and b/services/avatar.png differ
diff --git a/services/entrypoint.sh b/services/entrypoint.sh
new file mode 100644
index 0000000..9cc6160
--- /dev/null
+++ b/services/entrypoint.sh
@@ -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
diff --git a/services/package.json b/services/package.json
new file mode 100644
index 0000000..a793d39
--- /dev/null
+++ b/services/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/services/server.js b/services/server.js
new file mode 100644
index 0000000..39a3f20
--- /dev/null
+++ b/services/server.js
@@ -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('
Logged out
');
+});
+
+// ---------- Index + health + 404 ----------
+app.get('/', (_req, res) => res.type('html').send(`OIDC Mock Server
+Autologin as ${USER.email} (sub: ${USER.sub})
+
+Client ID: ${CLIENT_ID} · Client Secret: ${CLIENT_SECRET}
`));
+
+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(',')})`);
+});
diff --git a/translation/ru.json b/translation/ru.json
index d69f332..f70d5d7 100644
--- a/translation/ru.json
+++ b/translation/ru.json
@@ -1,5 +1,6 @@
{
"New API key": "Новый ключ API",
+ "Copy": "Скопировать",
"Delete": "Удалить",
"Revoke": "Отозвать",
"Revoke API key": "Отозвать ключ API",
@@ -76,7 +77,6 @@
"Copy public link": "Скопировать публичную ссылку",
"Link copied to clipboard": "Ссылка скопирована в буфер",
"Copy link": "Скопировать ссылку",
- "Copy": "Скопировать",
"Duplicate": "Дублировать",
"Duplicate document": "Дублировать документ",
"Copy document": "Скопировать документ",
@@ -173,6 +173,7 @@
"Are you sure about that? Deleting the {{ templateName }} template is permanent.": "Вы уверены? Удаление шаблона {{ templateName }} необратимо.",
"Move to workspace": "Переместить в рабочее пространство",
"Template moved": "Шаблон перемещён",
+ "Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
"Move to collection": "Переместить в коллекцию",
"Move template": "Переместить шаблон",
"Print template": "Распечатать шаблон",
@@ -197,21 +198,21 @@
"People": "Люди",
"Share": "Поделиться",
"Workspace": "Рабочее пространство",
- "Recent searches": "Недавние запросы",
"currently editing": "сейчас редактируется",
"currently viewing": "сейчас просматривается",
"previously edited": "ранее отредактировано",
"You": "Вы",
"Avatar of {{ name }}": "Аватар {{ name }}",
"Viewers": "Наблюдатели",
- "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": "Расширенные параметры",
+ "Members": "Участники",
"Public document sharing": "Общий доступ к документу",
"Allow documents within this collection to be shared publicly on the internet.": "Разрешить общий доступ к документам из этой коллекции в Интернете.",
"Commenting": "Комментарий",
"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": "Сохранение",
"Save": "Сохранить",
"Creating": "Создание",
@@ -239,6 +240,7 @@
"Install now": "Установить сейчас",
"Deleted Collection": "Удаленная коллекция",
"Untitled": "Без названия",
+ "Document options": "Параметры документа",
"Unpin": "Открепить",
"Export started": "Экспорт начат",
"A link to your file will be sent through email soon": "Ссылка на ваш файл скоро будет отправлена по почте",
@@ -263,7 +265,6 @@
"Couldn’t move the document, try again?": "Не удалось переместить документ. Попробовать снова?",
"Move to {{ location }}": "Переместить в {{ location }}",
"Couldn’t move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
- "Document options": "Параметры документа",
"New": "Новое",
"Only visible to you": "Видно только вам",
"Draft": "Черновик",
@@ -296,16 +297,15 @@
"Viewed {{ timeAgo }}": "Просмотрено {{ timeAgo }}",
"File type not supported. Please use PNG, JPG, GIF, or WebP.": "Тип файла не поддерживается. Пожалуйста, используйте PNG, JPG, GIF или WebP.",
"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, we’ll try to resize it for you.": "Квадратные изображения с прозрачным фоном подходят лучше всего. Если изображение слишком большое, мы попробуем уменьшить его размер за вас.",
- "Upload an image": "Загрузить изображение",
"Click or drag to replace": "Кликните или перетащите, чтобы заменить",
"Drop the image here": "Перетащите изображение сюда",
"Click, drop, or paste an image here": "Кликните, перетащите или вставьте изображение сюда",
"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": "Выберите имя",
"name can only contain lowercase letters, numbers, and underscores.": "имя может состоять только из строчных латинских букв, цифр и подчеркиваний.",
"This emoji will be available as": "Этот эмодзи будет доступен как",
@@ -319,15 +319,6 @@
"our engineers have been notified": "наши инженеры были уведомлены",
"Clear cache + reload": "Очистить кэш и перезагрузить",
"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 as HTML files.": "ZIP-архив, содержащий изображения и документы в формате HTML.",
"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 read": "{{ hours }}ч чтения",
"{{ 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": "Управлять",
"All members": "Все участники",
"Everyone in the workspace": "Все в рабочем пространстве",
@@ -462,6 +444,8 @@
"Switch to light": "Вкл. светлую тему",
"Add": "Добавить",
"Add or invite": "Добавить или пригласить",
+ "Something went wrong": "Что-то пошло не так",
+ "Email address": "Адрес почты",
"Viewer": "Наблюдатель",
"Editor": "Редактор",
"Suggestions for invitation": "Предложения для приглашения",
@@ -498,9 +482,9 @@
"Expand sidebar": "Развернуть боковую панель",
"Collapse sidebar": "Свернуть боковую панель",
"Archived collections": "Архивированные коллекции",
+ "Empty": "Пусто",
"New doc": "Новый документ",
"New nested document": "Новый вложенный документ",
- "Empty": "Пусто",
"No collections": "Нет коллекций",
"Collapse": "Свернуть",
"Expand": "Развернуть",
@@ -522,6 +506,7 @@
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} не может быть перемещён внутри {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "Вы не можете изменить порядок документов в коллекции, отсортированной по алфавиту",
"{{ documentName }} cannot be moved here": "{{ documentName }} нельзя переместить сюда",
+ "Integrations": "Интеграции",
"Return to App": "На главную",
"Installation": "Установка",
"Unstar document": "Убрать документ из избранного",
@@ -565,12 +550,14 @@
"Height": "Высота",
"Profile picture": "Фото профиля",
"Create a new doc": "Создать новый документ",
+ "Create a nested doc": "Создать вложенный документ",
"{{ 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 }}», имеющие доступ к этому документу, будут уведомлены",
"Keep as link": "Сохранить как ссылку",
"Mention": "Упоминание",
"Embed": "Вставить",
"Not supported": "Не поддерживается",
+ "Upload file": "Загрузить файл",
"More options": "Больше параметров",
"Rename": "Переименовать",
"Insert after": "Вставить после",
@@ -677,7 +664,6 @@
"Are you sure you want to delete the {{emojiName}} emoji? You will no longer be able to use it in your documents or collections.": "Вы уверены, что хотите удалить эмодзи {{emojiName}}? Вы больше не сможете использовать его в своих документах или коллекциях.",
"Edit group": "Редактировать группу",
"Delete group": "Удалить группу",
- "Members": "Участники",
"Could not import file": "Не удалось импортировать файл",
"Unsubscribed from document": "Отписаться от документа",
"Unsubscribed from collection": "Отменена подписка на коллекцию",
@@ -693,7 +679,6 @@
"Import": "Импорт",
"Embeds": "Встраивания",
"Configure which embed providers are available in the editor.": "Настройте, какие провайдеры встраиваний доступны в редакторе.",
- "Integrations": "Интеграции",
"Install": "Установить",
"Change name": "Изменить имя",
"Change email": "Изменить адрес почты",
@@ -721,6 +706,7 @@
"Revoking": "Отзыв доступа",
"Are you sure you want to revoke access?": "Вы уверены, что хотите отозвать доступ?",
"Delete app": "Удалить приложение",
+ "Revision options": "Настройка ревизии",
"Share options": "Настройка доступа",
"Headings you add to the document will appear here": "Здесь появятся заголовки, которые вы добавляете в документ",
"Contents": "Содержимое",
@@ -734,8 +720,8 @@
"published": "опубликованный",
"edited": "отредактировано",
"created the collection": "создана коллекция",
- "mentioned you in": "упомянул вас в",
- "mentioned your group in": "упомянул вашу группу в",
+ "mentioned you in": "упомянул(а) вас в",
+ "mentioned your group in": "упомянул(а) вашу группу в",
"left a comment on": "оставил комментарий в",
"resolved a comment on": "отметил комментарий как решённый в",
"reacted {{ emoji }} to your comment on": "оставил реакцию {{ emoji }} на ваш комментарий в",
@@ -836,8 +822,23 @@
"Archived": "Архивировано",
"Save draft": "Сохранить черновик",
"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": "Выделить изменения",
"No history yet": "Истории пока нет",
+ "Revision deleted": "Ревизия удалена",
+ "Current version": "Текущая версия",
+ "{{userName}} edited": "{{userName}} отредактировал",
+ "{{count}} people_0": "{{count}} человек",
+ "{{count}} people_1": "{{count}} человека",
+ "{{count}} people_2": "{{count}} человек",
"Source": "Источник",
"Created": "Создан",
"Imported from {{ source }}": "Импортировано из {{ source }}",
@@ -901,7 +902,6 @@
"Your account has been suspended": "Ваш аккаунт отключён",
"Warning Sign": "Предупреждающий знак",
"A workspace admin ({{ suspendedContactEmail }}) has suspended your account. To re-activate your account, please reach out to them directly.": "Администратор рабочего пространства ({{ suspendedContactEmail }}) отключил ваш аккаунт. Свяжитесь с ним напрямую, чтобы повторно активировать аккаунт.",
- "Something went wrong": "Что-то пошло не так",
"Sorry, an unknown error occurred loading the page. Please try again or contact support if the issue persists.": "Извините, при загрузке страницы произошла неизвестная ошибка. Попробуйте ещё раз или обратитесь в службу поддержки, если проблема не исчезнет.",
"Created by me": "Созданные мной",
"Weird, this shouldn't ever be empty": "Здесь будут появляться недавно обновлённые документы",
@@ -1067,6 +1067,7 @@
"Any time": "За любое время",
"Remove document filter": "Удалить фильтр документа",
"Any status": "Любой статус",
+ "Recent searches": "Недавние запросы",
"Remove search": "Убрать поиск",
"Relevance": "Релевантность",
"Newest": "Новее",
@@ -1353,7 +1354,6 @@
"Photo": "Фото",
"Choose a photo or image to represent yourself.": "Выберите фотографию или изображение, чтобы представить себя.",
"This could be your real name, or a nickname — however you’d like people to refer to you.": "Это может быть ваше настоящее имя или псевдоним — как бы вы хотели, чтобы люди обращались к вам.",
- "Email address": "Адрес почты",
"Members and guests": "Участники и гости",
"No one": "Никто",
"Are you sure you want to require invites?": "Вы уверены, что хотите требовать приглашений?",
@@ -1580,6 +1580,9 @@
"Script name": "Имя скрипта",
"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.",
+ "New webhook": "Новый вебхук",
+ "Edit webhook": "Редактировать вебхук",
+ "Delete webhook": "Удалить вебхук",
"Are you sure you want to delete the {{ name }} webhook?": "Вы уверены, что хотите удалить webhook {{ name }}?",
"Webhook updated": "Вебхук обновлен",
"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.": "Подпишитесь на все события, группы или отдельные события. Мы рекомендуем подписаться на минимальное количество событий, которое требуется для функционирования вашего приложения.",
"All events": "Все события",
"All {{ groupName }} events": "Все события {{ groupName }}",
- "Delete webhook": "Удалить вебхук",
- "Subscribed events": "События, на которые оформлена подписка",
- "Edit webhook": "Редактировать вебхук",
"Webhook created": "Вебхук создан",
"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-полезной нагрузкой практически в реальном времени.",
- "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}} легко интегрироваться с тысячами других бизнес-инструментов. Автоматизируйте рабочие процессы, синхронизируйте данные и многое другое.",
"Never logged in": "Никогда не входил",
"Online now": "Сейчас в сети",
@@ -1614,7 +1612,175 @@
"Open": "Открыть",
"Loading": "Загрузка",
"Error loading data": "Не удалось загрузить данные",
- "Couldn't move the template, try again?": "Не удалось переместить шаблон. Попробовать снова?",
- "Create a nested doc": "Создать вложенный документ",
- "Upload file": "Загрузить файл"
+ "API key copied": "Ключ API скопирован",
+ "Search results": "Результаты поиска",
+ "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 {{emojiName}}. All existing uses of this emoji will be updated automatically.": "Загрузите новое изображение, чтобы заменить текущее для {{emojiName}}. Все существующие случаи использования этого эмодзи будут обновлены автоматически.",
+ "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": "Ваш запрос на изменение адреса почты",
+ "Here’s 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": "Код подтверждения входа",
+ "Here’s 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": "руководстве"
}