From d0abf84aa8b97d6d7be75014c1b3e716fc066075 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 1 May 2026 09:14:01 +0500 Subject: [PATCH] 1.7.0 (#32) * bump version to 1.7.0 * update HMR mode; make a single dev container for every service; etc * update translations --- .env | 15 +-- .github/workflows/build.yml | 28 ++-- Dockerfile | 47 +++++-- Dockerfile.dev | 24 ++++ Dockerfile.prod | 43 ------ README.md | 6 +- docker-compose.prod.yml | 140 ------------------- docker-compose.yml | 83 ++++++++---- outline | 2 +- patches/hmr.patch | 122 +++++++++++++++++ patches/vite.patch | 34 ----- services/Dockerfile | 17 +++ services/avatar.png | Bin 0 -> 7113 bytes services/entrypoint.sh | 40 ++++++ services/package.json | 13 ++ services/server.js | 230 ++++++++++++++++++++++++++++++++ translation/ru.json | 258 +++++++++++++++++++++++++++++------- 17 files changed, 772 insertions(+), 330 deletions(-) create mode 100644 Dockerfile.dev delete mode 100644 Dockerfile.prod delete mode 100644 docker-compose.prod.yml create mode 100644 patches/hmr.patch delete mode 100644 patches/vite.patch create mode 100644 services/Dockerfile create mode 100644 services/avatar.png create mode 100644 services/entrypoint.sh create mode 100644 services/package.json create mode 100644 services/server.js 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 0000000000000000000000000000000000000000..77997b1704443df8034d4270515c5bc924a41177 GIT binary patch literal 7113 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 MxAoNCpzR|52k24b3IG5A literal 0 HcmV?d00001 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": "руководстве" }