mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 17:13:58 +00:00
Compare commits
181 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6109bd1d | ||
|
|
037f4a25b5 | ||
|
|
4755bd3e17 | ||
|
|
05d0f62403 | ||
|
|
3efcbfd358 | ||
|
|
2b5d521923 | ||
|
|
631caf911c | ||
|
|
0aba424357 | ||
|
|
194f144940 | ||
|
|
c045fcf698 | ||
|
|
451df9f01c | ||
|
|
ce27525d97 | ||
|
|
9c48900092 | ||
|
|
173dbb3448 | ||
|
|
24e0c44d91 | ||
|
|
f65701eaaf | ||
|
|
2f766d075d | ||
|
|
2c94ef9493 | ||
|
|
fdc23c658f | ||
|
|
e5d6142061 | ||
|
|
b7d2477840 | ||
|
|
4bc80d1439 | ||
|
|
8a9c0a6e20 | ||
|
|
df1666cffa | ||
|
|
afdd9b9500 | ||
|
|
3b9acd487c | ||
|
|
82d1fd63a5 | ||
|
|
75be62c801 | ||
|
|
ab4c7beded | ||
|
|
be8120b14e | ||
|
|
33160db454 | ||
|
|
9e850e17f3 | ||
|
|
3ac0b0f0a4 | ||
|
|
cce7cc6cd4 | ||
|
|
bce18df898 | ||
|
|
1442aee756 | ||
|
|
00e1892704 | ||
|
|
2ecd748fca | ||
|
|
0cd01fce41 | ||
|
|
40327d59d6 | ||
|
|
b45f60e20a | ||
|
|
f5b61c3388 | ||
|
|
ff0bd2e819 | ||
|
|
8e04130969 | ||
|
|
c8b544802a | ||
|
|
01500ca70c | ||
|
|
7168f14f0a | ||
|
|
01d0b08a4e | ||
|
|
41dc87edf9 | ||
|
|
93e5bcc1e2 | ||
|
|
3c90fa07c3 | ||
|
|
851b1fb613 | ||
|
|
9047aacc0a | ||
|
|
773a9042a7 | ||
|
|
926c94ea8a | ||
|
|
bf5988580e | ||
|
|
b9ec85779f | ||
|
|
acba429e1a | ||
|
|
8c5de8446c | ||
|
|
9d2dae1b63 | ||
|
|
32249d99e2 | ||
|
|
2109c05ad4 | ||
|
|
f6f665ff60 | ||
|
|
b680076534 | ||
|
|
b1792469b1 | ||
|
|
3aa13197e4 | ||
|
|
dfdbceebe2 | ||
|
|
a698e34772 | ||
|
|
ce038b0b0f | ||
|
|
d67aa3ebd3 | ||
|
|
4ba72b3919 | ||
|
|
bfe3fad256 | ||
|
|
3e53962fb5 | ||
|
|
a59b310dbf | ||
|
|
0c0f5a8ec4 | ||
|
|
099de84cd1 | ||
|
|
8a4e1feed8 | ||
|
|
2fdf202089 | ||
|
|
abdfa03f84 | ||
|
|
c8e3248bcd | ||
|
|
4371af77c2 | ||
|
|
2ba72de1de | ||
|
|
6a5ad2d90d | ||
|
|
f79cabe74c | ||
|
|
662678275a | ||
|
|
8e4eb2646a | ||
|
|
e313366b49 | ||
|
|
3cddff7b27 | ||
|
|
817ca2546e | ||
|
|
eefdfc44b9 | ||
|
|
73a9b88bb4 | ||
|
|
a65e3b75a4 | ||
|
|
8b6a0a2f3a | ||
|
|
92e23bf7d4 | ||
|
|
4fe94c9b68 | ||
|
|
59062ce11a | ||
|
|
fb7e7af6c9 | ||
|
|
ddf84fa6f2 | ||
|
|
f47626049c | ||
|
|
1e7c4b8c21 | ||
|
|
ad2e8b18a9 | ||
|
|
12bd199f94 | ||
|
|
d828185efc | ||
|
|
68d322957f | ||
|
|
5b146f0b03 | ||
|
|
67c0e391fd | ||
|
|
b420cf4850 | ||
|
|
e56a33efd0 | ||
|
|
860ab68c04 | ||
|
|
67f7d914f4 | ||
|
|
9af3a4de77 | ||
|
|
bb2ef93a1b | ||
|
|
62b9c711a2 | ||
|
|
53407f090d | ||
|
|
b19ad89eb0 | ||
|
|
809b75ca22 | ||
|
|
d09227b1e7 | ||
|
|
54af41276c | ||
|
|
271ded5e49 | ||
|
|
d6837f9013 | ||
|
|
ef205bf2ac | ||
|
|
c8350782dd | ||
|
|
2c64e85e90 | ||
|
|
78943da92b | ||
|
|
29441cdfba | ||
|
|
1510e1e6f6 | ||
|
|
ab60c80c39 | ||
|
|
3e7645004f | ||
|
|
7ea9970648 | ||
|
|
86c56cf827 | ||
|
|
e417bc1047 | ||
|
|
e1476cf100 | ||
|
|
189ea4a29b | ||
|
|
3e6f9d7bb2 | ||
|
|
2d1a09dbe0 | ||
|
|
7b33892a76 | ||
|
|
9e8f9cb513 | ||
|
|
9af77e7e3a | ||
|
|
61aee16cf6 | ||
|
|
0110d880aa | ||
|
|
6a2673d411 | ||
|
|
a88d2fc275 | ||
|
|
2209e37857 | ||
|
|
4ba2b2953e | ||
|
|
37c8217d4f | ||
|
|
bd627b55fa | ||
|
|
5aebfa8ebf | ||
|
|
1eecaa03bf | ||
|
|
72a7a74915 | ||
|
|
02dad883cd | ||
|
|
5688679fc4 | ||
|
|
1f5946c464 | ||
|
|
e8ff208b32 | ||
|
|
a0e0d776b9 | ||
|
|
ce96504718 | ||
|
|
75600a2598 | ||
|
|
61ed74ec16 | ||
|
|
d426181971 | ||
|
|
eb820ba2fd | ||
|
|
a7016ad7c5 | ||
|
|
d3074e3707 | ||
|
|
d582398826 | ||
|
|
8f2ec5b04c | ||
|
|
42334bcce5 | ||
|
|
854416086e | ||
|
|
8351dad390 | ||
|
|
c4d2467cb3 | ||
|
|
118d48ca9e | ||
|
|
eeac0a6703 | ||
|
|
4b30653a3d | ||
|
|
bfaf442935 | ||
|
|
79e3d46127 | ||
|
|
29ee19bd8a | ||
|
|
98dbba4f1f | ||
|
|
d9dabe352a | ||
|
|
40f0a4021a | ||
|
|
d106ed9c5e | ||
|
|
53aa7a1c5a | ||
|
|
4ff3363b9b | ||
|
|
689dd9d434 | ||
|
|
50cd1d16f4 |
196 changed files with 10716 additions and 3655 deletions
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
|
@ -17,4 +17,11 @@ updates:
|
|||
open-pull-requests-limit: 30
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
dependency-type: "development"
|
||||
cooldown:
|
||||
default-days: 1 # fallback for anything not covered below
|
||||
semver-major-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 1
|
||||
include:
|
||||
- "*"
|
||||
|
|
|
|||
119
.github/workflows/backend-tests.yml
vendored
119
.github/workflows/backend-tests.yml
vendored
|
|
@ -32,8 +32,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -66,24 +66,7 @@ jobs:
|
|||
run: pnpm build
|
||||
-
|
||||
name: Run the backend tests
|
||||
env:
|
||||
# --report-on-fatalerror and friends write a Node diagnostic report
|
||||
# (V8 stack, libuv handles, OS info) on fatal errors that bypass JS
|
||||
# handlers — the failure mode we've been chasing on Windows + Node
|
||||
# 24 since PR #7663. Reports land in node-report/ and are uploaded
|
||||
# as an artifact if the step fails.
|
||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||
run: |
|
||||
mkdir -p "${{ github.workspace }}/node-report"
|
||||
pnpm test
|
||||
- name: Upload Node diagnostic reports on failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||
path: node-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: pnpm run test:vitest
|
||||
|
|
@ -105,8 +88,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -124,7 +107,7 @@ jobs:
|
|||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -153,19 +136,7 @@ jobs:
|
|||
ep_table_of_contents
|
||||
-
|
||||
name: Run the backend tests
|
||||
env:
|
||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||
run: |
|
||||
mkdir -p "${{ github.workspace }}/node-report"
|
||||
pnpm test
|
||||
- name: Upload Node diagnostic reports on failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||
path: node-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
run: pnpm test
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: pnpm run test:vitest
|
||||
|
|
@ -180,14 +151,25 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||
node: ${{ fromJSON('[24]') }}
|
||||
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
|
||||
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
|
||||
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
|
||||
# backend suite's heavy localhost connection churn -- the long-standing Windows
|
||||
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
|
||||
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
|
||||
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
|
||||
# because setup-node's default check-latest:false reuses the runner's
|
||||
# pre-cached 24.15.0 for a bare "24".
|
||||
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
|
||||
# back to plain "24" once the fix is across the supported 24.x baseline.
|
||||
node: ${{ fromJSON('["24.16.0"]') }}
|
||||
name: Windows without plugins
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -218,23 +200,9 @@ jobs:
|
|||
name: Run the backend tests
|
||||
shell: bash
|
||||
working-directory: src
|
||||
env:
|
||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||
run: |
|
||||
mkdir -p "${{ github.workspace }}/node-report"
|
||||
# --exit forces process.exit(failures) after the suite completes,
|
||||
# closing the post-suite event-loop drain window where Windows +
|
||||
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
||||
# runs still surface real handle leaks via natural drain.
|
||||
pnpm test -- --exit
|
||||
- name: Upload Node diagnostic reports on failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||
path: node-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
# --exit makes mocha call process.exit() after the run so a leaked handle
|
||||
# cannot hang the job on Windows.
|
||||
run: pnpm test -- --exit
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: pnpm run test:vitest
|
||||
|
|
@ -248,15 +216,26 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||
node: ${{ fromJSON('[24]') }}
|
||||
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
|
||||
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
|
||||
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
|
||||
# backend suite's heavy localhost connection churn -- the long-standing Windows
|
||||
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
|
||||
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
|
||||
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
|
||||
# because setup-node's default check-latest:false reuses the runner's
|
||||
# pre-cached 24.15.0 for a bare "24".
|
||||
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
|
||||
# back to plain "24" once the fix is across the supported 24.x baseline.
|
||||
node: ${{ fromJSON('["24.16.0"]') }}
|
||||
name: Windows with Plugins
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -315,23 +294,9 @@ jobs:
|
|||
name: Run the backend tests
|
||||
shell: bash
|
||||
working-directory: src
|
||||
env:
|
||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
||||
run: |
|
||||
mkdir -p "${{ github.workspace }}/node-report"
|
||||
# --exit forces process.exit(failures) after the suite completes,
|
||||
# closing the post-suite event-loop drain window where Windows +
|
||||
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
||||
# runs still surface real handle leaks via natural drain.
|
||||
pnpm test -- --exit
|
||||
- name: Upload Node diagnostic reports on failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
||||
path: node-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
# --exit makes mocha call process.exit() after the run so a leaked handle
|
||||
# cannot hang the job on Windows.
|
||||
run: pnpm test -- --exit
|
||||
- name: Run the new vitest tests
|
||||
working-directory: src
|
||||
run: pnpm run test:vitest
|
||||
|
|
|
|||
6
.github/workflows/build-and-deploy-docs.yml
vendored
6
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -36,15 +36,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache vitepress build
|
||||
with:
|
||||
path: doc/.vitepress/cache
|
||||
|
|
|
|||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
|
|
|||
22
.github/workflows/deb-package.yml
vendored
22
.github/workflows/deb-package.yml
vendored
|
|
@ -15,6 +15,24 @@ on:
|
|||
- 'src/node/utils/run_cmd.ts'
|
||||
- 'src/static/js/pluginfw/**'
|
||||
- 'settings.json.template'
|
||||
# Also build + smoke-test the package on PRs that touch the production
|
||||
# footprint. The smoke step boots the packaged server and waits for /health,
|
||||
# which is the only check that catches "server starts then exits before
|
||||
# binding the port" startup regressions (e.g. a dependency bump whose change
|
||||
# lets the event loop drain mid-boot). Previously this workflow ran only on
|
||||
# push to develop, so such a regression was caught *after* merge — by which
|
||||
# point develop was already red. Running it pre-merge blocks the PR instead.
|
||||
# The release/apt-publish jobs are tag-guarded, so PRs run the build job only.
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packaging/**'
|
||||
- '.github/workflows/deb-package.yml'
|
||||
- 'src/package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/node/server.ts'
|
||||
- 'src/node/utils/run_cmd.ts'
|
||||
- 'src/static/js/pluginfw/**'
|
||||
- 'settings.json.template'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
|
|
@ -44,7 +62,7 @@ jobs:
|
|||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
|
|
@ -328,7 +346,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout etherpad source (for packaging/apt/key.asc)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -15,6 +15,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v5
|
||||
|
|
|
|||
38
.github/workflows/docker.yml
vendored
38
.github/workflows/docker.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -151,6 +151,32 @@ jobs:
|
|||
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
-
|
||||
# Regression test: Etherpad container must boot without external
|
||||
# network access (e.g., air-gapped host or restrictive runtime policy).
|
||||
name: Regression — boot with disabled network (--network none)
|
||||
working-directory: etherpad
|
||||
run: |
|
||||
docker run --rm -d \
|
||||
--network none \
|
||||
--name test-offline ${{ env.TEST_TAG }}
|
||||
docker logs -f test-offline &
|
||||
ok=0
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
|
||||
echo "container exited prematurely while offline"
|
||||
docker logs test-offline || true
|
||||
break
|
||||
}
|
||||
case ${status} in
|
||||
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
|
||||
starting) sleep 2;;
|
||||
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
|
||||
esac
|
||||
done
|
||||
docker rm -f test-offline >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
build-test-local-plugin:
|
||||
# Regression coverage for #7687: the Docker image's
|
||||
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
||||
|
|
@ -164,7 +190,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -235,7 +261,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -332,7 +358,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -390,7 +416,7 @@ jobs:
|
|||
enable-url-completion: true
|
||||
- name: Check out ether-charts
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: ether-charts
|
||||
repository: ether/ether-charts
|
||||
|
|
|
|||
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
name: Downstream smoke
|
||||
|
||||
# Boots a real Etherpad from the PR and verifies the separate downstream clients
|
||||
# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
|
||||
# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
|
||||
# per-client matrix activates as each client flips `enabled:true` in clients.json.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "doc/**"
|
||||
- "docs/**"
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against the default branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Boot + downstream clients
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
PNPM_HOME: ~/.pnpm-store
|
||||
APIKEY: downstream-smoke-key
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003 (apikey auth)
|
||||
run: |
|
||||
# The template ships a literal "port": 9001 and sso auth; rewrite both.
|
||||
# (PORT env is ignored once the settings file specifies a port.)
|
||||
sed -e 's#"port": 9001,#"port": 9003,#' \
|
||||
-e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
|
||||
settings.json.template > settings.json
|
||||
# Fail fast if the template format drifted and sed silently no-op'd.
|
||||
grep -q '"port": 9003,' settings.json \
|
||||
|| { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
grep -q '"authenticationMethod": "apikey"' settings.json \
|
||||
|| { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
printf '%s' "$APIKEY" > APIKEY.txt
|
||||
pnpm run prod > /tmp/ep.log 2>&1 &
|
||||
echo $! > /tmp/ep.pid
|
||||
echo "booted pid $(cat /tmp/ep.pid)"
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
|
||||
echo "server up after ${i} tries"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
|
||||
|
||||
- name: Self-check — authenticated create + read roundtrip
|
||||
run: |
|
||||
K="$APIKEY"
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
|
||||
grep -q '"code":0' /tmp/create.json
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
|
||||
grep -q '"text":"hi' /tmp/get.json
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
# Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
|
||||
# use the node+pnpm already set up above.
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
env:
|
||||
SMOKE_URL: http://localhost:9003
|
||||
SMOKE_APIKEY: ${{ env.APIKEY }}
|
||||
run: bash src/tests/downstream/run-clients.sh
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then
|
||||
kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
|
||||
echo "killed $(cat /tmp/ep.pid)"
|
||||
fi
|
||||
6
.github/workflows/frontend-admin-tests.yml
vendored
6
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: pnpm i
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
|
|||
24
.github/workflows/frontend-tests.yml
vendored
24
.github/workflows/frontend-tests.yml
vendored
|
|
@ -20,15 +20,15 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -92,15 +92,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -168,15 +168,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -269,15 +269,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
|
|||
4
.github/workflows/handleRelease.yml
vendored
4
.github/workflows/handleRelease.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
27
.github/workflows/installer-test.yml
vendored
27
.github/workflows/installer-test.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
name: shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run shellcheck on installer.sh
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -70,13 +70,30 @@ jobs:
|
|||
|
||||
- name: Smoke test - start Etherpad and curl /api
|
||||
shell: bash
|
||||
# Hard backstop: if teardown ever fails to reap the server the step
|
||||
# fails in minutes instead of burning to GitHub's 6h job ceiling.
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
||||
run: |
|
||||
set -eu
|
||||
# Enable job control so the backgrounded launcher gets its own
|
||||
# process group, letting us reap the whole pnpm -> node tree below.
|
||||
set -m
|
||||
cd "$ETHERPAD_DIR"
|
||||
pnpm run prod >/tmp/etherpad.log 2>&1 &
|
||||
PID=$!
|
||||
# `pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node),
|
||||
# so killing $PID alone orphans the node server, which keeps the step's
|
||||
# output pipe open and hangs CI. Kill the entire process group, with a
|
||||
# SIGKILL fallback in case SIGTERM is swallowed (e.g. a live flush timer
|
||||
# keeping the event loop alive), so the step always exits cleanly.
|
||||
reap() {
|
||||
kill -TERM "-$PID" 2>/dev/null || true
|
||||
sleep 5
|
||||
kill -KILL "-$PID" 2>/dev/null || true
|
||||
}
|
||||
trap reap EXIT
|
||||
# Wait up to 60s for the API to come up.
|
||||
ok=0
|
||||
for i in $(seq 1 60); do
|
||||
|
|
@ -90,17 +107,14 @@ jobs:
|
|||
if [ "$ok" != "1" ]; then
|
||||
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
|
||||
tail -200 /tmp/etherpad.log >&2 || true
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
installer-windows:
|
||||
name: end-to-end install (windows-latest)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -131,6 +145,7 @@ jobs:
|
|||
|
||||
- name: Smoke test - start Etherpad and curl /api
|
||||
shell: pwsh
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
||||
run: |
|
||||
|
|
|
|||
12
.github/workflows/load-test.yml
vendored
12
.github/workflows/load-test.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -62,8 +62,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -125,8 +125,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/perform-type-check.yml
vendored
4
.github/workflows/perform-type-check.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/rate-limit.yml
vendored
4
.github/workflows/rate-limit.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/etherpad-lite
|
||||
path: etherpad
|
||||
|
|
@ -42,12 +42,12 @@ jobs:
|
|||
git checkout develop
|
||||
git reset --hard origin/develop
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/ether.github.com
|
||||
path: ether.github.com
|
||||
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
with:
|
||||
ruby-version: 2.7
|
||||
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.4
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.5
|
||||
with:
|
||||
version: 2.0.18
|
||||
- name: Prepare release
|
||||
|
|
|
|||
36
.github/workflows/releaseEtherpad.yml
vendored
36
.github/workflows/releaseEtherpad.yml
vendored
|
|
@ -1,9 +1,33 @@
|
|||
# PARKED — npm publish of the core package is not part of the standard release.
|
||||
#
|
||||
# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes
|
||||
# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose:
|
||||
# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it;
|
||||
# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install,
|
||||
# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core;
|
||||
# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
|
||||
# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC
|
||||
# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0
|
||||
# while 3.x shipped fine without it.
|
||||
#
|
||||
# It is therefore gated behind an explicit `confirm: true` dispatch input so a
|
||||
# stray run fails fast with a clear message instead of a confusing 404. To
|
||||
# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first
|
||||
# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings ->
|
||||
# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`.
|
||||
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
|
||||
name: releaseEtherpad.yaml
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # for npm OIDC trusted publishing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
PNPM_HOME: ~/.pnpm-store
|
||||
|
|
@ -12,8 +36,16 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Guard — refuse unless explicitly confirmed
|
||||
if: ${{ inputs.confirm != true }}
|
||||
run: |
|
||||
echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional"
|
||||
echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)."
|
||||
echo "::error::Re-run with confirm=true only after the owner configures a trusted"
|
||||
echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section."
|
||||
exit 1
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||
|
|
@ -22,7 +54,7 @@ jobs:
|
|||
registry-url: https://registry.npmjs.org/
|
||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||
run: npm install -g npm@latest
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/snap-build.yml
vendored
4
.github/workflows/snap-build.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
name: Wrapper unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run snap/tests/run-all.sh
|
||||
run: bash snap/tests/run-all.sh
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
needs: wrapper-tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install --classic snapcraft
|
||||
|
|
|
|||
2
.github/workflows/snap-publish.yml
vendored
2
.github/workflows/snap-publish.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
snap-file: ${{ steps.build.outputs.snap }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build snap
|
||||
id: build
|
||||
|
|
|
|||
2
.github/workflows/update-plugins.yml
vendored
2
.github/workflows/update-plugins.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out etherpad-lite
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out latest release
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Check out latest release tag
|
||||
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
[pr_reviewer]
|
||||
run_on_pr_sync = true
|
||||
|
||||
[pr_description]
|
||||
run_on_pr_sync = true
|
||||
32
AGENTS.MD
32
AGENTS.MD
|
|
@ -211,6 +211,38 @@ pnpm run plugins ls # List installed
|
|||
### Settings
|
||||
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
|
||||
|
||||
### Prerequisites (check these before dispatching)
|
||||
- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching.
|
||||
- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)*
|
||||
|
||||
### Cutting a release
|
||||
1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes.
|
||||
2. The **Prepare release** step runs `bin/release.ts`, which:
|
||||
- sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`);
|
||||
- bumps the version in all four `package.json` files;
|
||||
- commits `bump version`, merges `develop` → `master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`;
|
||||
- builds and stages the versioned docs into the website repo (see **Documentation** below).
|
||||
3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit.
|
||||
4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows:
|
||||
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
|
||||
- `docker.yml` → builds & pushes the Docker images;
|
||||
- `snap-publish.yml` → publishes the snap.
|
||||
5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
|
||||
|
||||
### Documentation
|
||||
|
||||
Two distinct things, both important:
|
||||
|
||||
**1. Per-PR doc updates — your responsibility in every behaviour-change PR.**
|
||||
The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`.
|
||||
|
||||
**2. Release-time versioned docs publishing — automated, no manual step.**
|
||||
During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`.
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
This project uses pnpm workspaces. The workspaces are:
|
||||
|
|
|
|||
110
CHANGELOG.md
110
CHANGELOG.md
|
|
@ -1,3 +1,113 @@
|
|||
# 3.3.2
|
||||
|
||||
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
|
||||
|
||||
### Security
|
||||
|
||||
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
|
||||
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
|
||||
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
|
||||
|
||||
### Notable enhancements
|
||||
|
||||
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
|
||||
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
|
||||
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
|
||||
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
|
||||
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
|
||||
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
|
||||
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
|
||||
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
|
||||
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
|
||||
- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974).
|
||||
- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977).
|
||||
|
||||
# 3.3.1
|
||||
|
||||
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
|
||||
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
|
||||
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
|
||||
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
|
||||
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
|
||||
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
|
||||
|
||||
# 3.3.0
|
||||
|
||||
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
|
||||
|
||||
### Notable enhancements
|
||||
|
||||
- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients.
|
||||
- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only).
|
||||
- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders.
|
||||
|
||||
### Security hardening
|
||||
|
||||
A defence-in-depth pass across the API, token, export, and deployment surfaces:
|
||||
|
||||
- **HTTP API request handling, random IDs, and plugin loading (#7906).** `pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side.
|
||||
- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).** `ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way.
|
||||
- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test.
|
||||
- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI.
|
||||
- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `<html>` dir is unchanged.
|
||||
- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view").
|
||||
- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).** `?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`.
|
||||
- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author` *attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author.
|
||||
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
|
||||
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
|
||||
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
|
||||
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
|
||||
- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged.
|
||||
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS.
|
||||
- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
|
||||
- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files.
|
||||
- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860).
|
||||
- `ueberdb2` 6.1.2 → 6.1.8, `mssql` 12.5.3 → 12.5.5, `nodemailer` 8.0.7 → 8.0.10, `mysql2` 3.22.3 → 3.22.5 (#7915), `undici` 8.3.0 → 8.4.1 (#7914), `pdfkit` 0.18.0 → 0.19.0 (#7916), `oidc-provider` 9.8.3 → 9.8.4, `@elastic/elasticsearch` 9.4.1 → 9.4.2, `lru-cache` 11.5.0 → 11.5.1, `rate-limiter-flexible` 11.1.0 → 11.1.1, `semver` 7.8.1 → 7.8.2, `js-cookie` 3.0.7 → 3.0.8, `tsx` 4.22.3 → 4.22.4, `@radix-ui/react-switch` 1.2.6 → 1.3.0 (#7913), `@tanstack/react-query` 5.100.11 → 5.101.0 (+ devtools), plus `i18next`, `react-router-dom`, and several dev-dependency group bumps (#7912).
|
||||
|
||||
### Localisation
|
||||
|
||||
- Multiple updates from translatewiki.net.
|
||||
|
||||
# 3.2.0
|
||||
|
||||
3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 0–12 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
## Pull requests
|
||||
|
||||
* PRs MUST include a non-empty description explaining what the change does and why
|
||||
* PRs without a description should be flagged as incomplete
|
||||
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
|
||||
* PRs should be issued against the **develop** branch: we never pull directly into **master**
|
||||
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
|
||||
|
|
@ -123,7 +125,7 @@ Documentation should be kept up-to-date. This means, whenever you add a new API
|
|||
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
|
||||
|
||||
## Testing
|
||||
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
||||
Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
||||
|
||||
Back-end tests can be run from the `src` directory, via `npm test`.
|
||||
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
|
||||
|
|
|
|||
16
Dockerfile
16
Dockerfile
|
|
@ -7,6 +7,11 @@
|
|||
# docker build --build-arg BUILD_ENV=copy .
|
||||
ARG BUILD_ENV=git
|
||||
|
||||
# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
|
||||
# 11.1.x enforces the minimum-release-age supply-chain policy during install,
|
||||
# which the frozen-lockfile Docker build can't satisfy, so the image stays on
|
||||
# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
|
||||
# the build stage below — see ether/etherpad#7911.
|
||||
ARG PnpmVersion=11.0.6
|
||||
|
||||
FROM node:24-alpine AS adminbuild
|
||||
|
|
@ -28,6 +33,17 @@ RUN pnpm run build:ui
|
|||
FROM node:24-alpine AS build
|
||||
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
|
||||
|
||||
# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
|
||||
# note above). pnpm would otherwise try to self-provision the pinned version on
|
||||
# invocation — including the informational `pnpm --version` probe Etherpad runs
|
||||
# at startup — which fails closed with no network and breaks air-gapped boots
|
||||
# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
|
||||
# instead. Inherited by the development and production runtime stages, so it
|
||||
# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
|
||||
# shell. It does not change which pnpm runs the build-time install (still the
|
||||
# installed 11.0.x), so the frozen-lockfile build is unaffected.
|
||||
ENV pnpm_config_pm_on_fail=ignore
|
||||
|
||||
# Set these arguments when building the image from behind a proxy
|
||||
ARG http_proxy=
|
||||
ARG https_proxy=
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ volumes:
|
|||
|
||||
### Docker container
|
||||
|
||||
Find [here](doc/docker.adoc) information on running Etherpad in a container.
|
||||
Find [here](doc/docker.md) information on running Etherpad in a container.
|
||||
|
||||
## Plugins
|
||||
|
||||
|
|
@ -247,8 +247,8 @@ git -P tag --list "v*" --merged
|
|||
```
|
||||
4. Select the version
|
||||
```sh
|
||||
git checkout v2.2.5
|
||||
git switch -c v2.2.5
|
||||
git checkout v3.2.0
|
||||
git switch -c v3.2.0
|
||||
```
|
||||
5. Upgrade Etherpad
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:api && vite",
|
||||
|
|
@ -14,39 +14,40 @@
|
|||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-query-devtools": "^5.100.11",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@types/react": "^19.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-toast": "^1.2.19",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.7",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.63.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"i18next": "^26.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14",
|
||||
"vite": "^8.1.4",
|
||||
"vite-plugin-babel": "^1.7.3",
|
||||
"zustand": "^5.0.13"
|
||||
"zustand": "^5.0.14"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
|
||||
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
|
||||
"no-results": "No authors match this search.",
|
||||
"confirm-dialog-title": "Confirm author erasure",
|
||||
"confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.",
|
||||
"confirm-preview-title": "Erase author {{name}}",
|
||||
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
|
||||
"confirm-irreversible": "This cannot be undone.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,37 @@ textarea.settings:focus {
|
|||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* --- env-var banner --- */
|
||||
/* Shown only when settings.json contains ${VAR} placeholders. The
|
||||
typical reader is a Docker/K8s operator who has just been surprised
|
||||
by env-var substitution semantics, so the copy must explain rather
|
||||
than warn — visual weight matches a note, not an error. */
|
||||
.settings-envvar-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #f5f7ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 6px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.settings-envvar-banner svg {
|
||||
flex-shrink: 0;
|
||||
color: #4f46e5;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.settings-envvar-banner strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.settings-envvar-banner p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- mode toggle --- */
|
||||
.settings-mode-toggle {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -32,12 +32,19 @@ export const App = () => {
|
|||
|
||||
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
|
||||
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
|
||||
// When the server explicitly rejects us for not being admin, we must
|
||||
// NOT reconnect on the subsequent `disconnect` event — otherwise the
|
||||
// socket cycles forever (connect → admin_auth_error → server
|
||||
// disconnect → SPA auto-reconnect → …). The flag is reset on each
|
||||
// successful connect.
|
||||
let authErrored = false;
|
||||
|
||||
pluginsSocket.on('connect', () => {
|
||||
useStore.getState().setPluginsSocket(pluginsSocket);
|
||||
});
|
||||
|
||||
settingSocket.on('connect', () => {
|
||||
authErrored = false;
|
||||
useStore.getState().setSettingsSocket(settingSocket);
|
||||
useStore.getState().setShowLoading(false)
|
||||
settingSocket.emit('load');
|
||||
|
|
@ -46,7 +53,7 @@ export const App = () => {
|
|||
|
||||
settingSocket.on('disconnect', (reason) => {
|
||||
useStore.getState().setShowLoading(true)
|
||||
if (reason === 'io server disconnect') settingSocket.connect();
|
||||
if (reason === 'io server disconnect' && !authErrored) settingSocket.connect();
|
||||
});
|
||||
|
||||
settingSocket.on('settings', (settings: any) => {
|
||||
|
|
@ -75,6 +82,22 @@ export const App = () => {
|
|||
const detail = payload?.message ?? '';
|
||||
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
|
||||
}
|
||||
});
|
||||
|
||||
// Backend emits this when the connecting socket does not have an
|
||||
// admin session. Previously the server just dropped silently, so the
|
||||
// SPA would sit on a loading screen with no clue. Surface it AND
|
||||
// suppress the automatic reconnect — without this flag the SPA would
|
||||
// immediately reconnect to a socket that will reject it again.
|
||||
settingSocket.on('admin_auth_error', (payload?: {message?: string}) => {
|
||||
authErrored = true;
|
||||
const {setToastState} = useStore.getState();
|
||||
setToastState({
|
||||
open: true,
|
||||
title: payload?.message || t('admin_settings.toast.auth_error'),
|
||||
success: false,
|
||||
});
|
||||
useStore.getState().setShowLoading(false);
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
export type Mode = 'form' | 'raw';
|
||||
export type Mode = 'form' | 'raw' | 'effective';
|
||||
|
||||
type Props = {
|
||||
mode: Mode;
|
||||
onChange: (mode: Mode) => void;
|
||||
// When false, the Effective tab is hidden. We hide it for installs that
|
||||
// aren't using env-var substitution at all — there's no useful difference
|
||||
// between the raw file and the effective in-memory config for them.
|
||||
showEffective?: boolean;
|
||||
};
|
||||
|
||||
export const ModeToggle = ({ mode, onChange }: Props) => {
|
||||
export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
|
||||
|
|
@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => {
|
|||
>
|
||||
<Trans i18nKey="admin_settings.mode.raw" />
|
||||
</button>
|
||||
{showEffective && (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'effective'}
|
||||
data-testid="mode-toggle-effective"
|
||||
className={mode === 'effective' ? 'active' : ''}
|
||||
onClick={() => onChange('effective')}
|
||||
title={t('admin_settings.mode.effective_tooltip')}
|
||||
>
|
||||
<Trans i18nKey="admin_settings.mode.effective" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {Trans, useTranslation} from "react-i18next";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
|
||||
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
|
||||
import {useStore} from "../store/store.ts";
|
||||
import {SearchField} from "../components/SearchField.tsx";
|
||||
|
|
@ -153,16 +154,20 @@ export const AuthorPage = () => {
|
|||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||
<Dialog.Content className="dialog-confirm-content">
|
||||
<VisuallyHidden asChild>
|
||||
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
|
||||
</VisuallyHidden>
|
||||
<VisuallyHidden asChild>
|
||||
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
|
||||
</VisuallyHidden>
|
||||
{dialog.phase === 'loading-preview' && <div>
|
||||
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
|
||||
</div>}
|
||||
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
|
||||
const p = dialog.preview;
|
||||
return <div>
|
||||
<Dialog.Title asChild>
|
||||
<h3>{t('ep_admin_authors:confirm-preview-title',
|
||||
{name: p.name || p.authorID})}</h3>
|
||||
</Dialog.Title>
|
||||
<h3>{t('ep_admin_authors:confirm-preview-title',
|
||||
{name: p.name || p.authorID})}</h3>
|
||||
<p>{t('ep_admin_authors:confirm-preview-counters', {
|
||||
tokenMappings: p.removedTokenMappings,
|
||||
externalMappings: p.removedExternalMappings,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {useStore} from "../store/store.ts";
|
|||
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
||||
import {useDebounce} from "../utils/useDebounce.ts";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
|
||||
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
|
||||
import {useForm} from "react-hook-form";
|
||||
import type {TFunction} from "i18next";
|
||||
|
|
@ -165,7 +166,10 @@ export const PadPage = () => {
|
|||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||
<Dialog.Content className="dialog-confirm-content">
|
||||
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
|
||||
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
|
||||
<Dialog.Description asChild>
|
||||
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
|
||||
</Dialog.Description>
|
||||
<div className="settings-button-bar">
|
||||
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
|
||||
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
|
||||
|
|
@ -178,7 +182,10 @@ export const PadPage = () => {
|
|||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||
<Dialog.Content className="dialog-confirm-content">
|
||||
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
|
||||
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
|
||||
<Dialog.Description asChild>
|
||||
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
|
||||
</Dialog.Description>
|
||||
<div className="settings-button-bar">
|
||||
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
|
||||
</div>
|
||||
|
|
@ -191,6 +198,7 @@ export const PadPage = () => {
|
|||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||
<Dialog.Content className="dialog-confirm-content">
|
||||
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
|
||||
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
|
||||
<form onSubmit={handleSubmit(onPadCreate)}>
|
||||
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
|
||||
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
|
||||
|
|
@ -410,7 +418,7 @@ export const PadPage = () => {
|
|||
</button>
|
||||
<button
|
||||
className="pm-btn pm-btn-primary pm-btn--sm"
|
||||
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
|
||||
onClick={() => window.open(`../../p/${encodeURIComponent(pad.padName)}`, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,42 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useStore } from '../store/store';
|
||||
import { isJSONClean, cleanComments } from '../utils/utils';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { IconButton } from '../components/IconButton';
|
||||
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
|
||||
import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react';
|
||||
import { FormView } from '../components/settings/FormView';
|
||||
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
|
||||
|
||||
const TAB_INDENT = ' ';
|
||||
|
||||
// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is
|
||||
// running with env-var substitution (overwhelmingly Docker / Kubernetes).
|
||||
// We use this to gate the Docker-aware UX (the explanatory banner and the
|
||||
// Effective-config tab) so non-container installs see the existing UI
|
||||
// unchanged. Conservative on purpose — false negatives just keep the old
|
||||
// behaviour.
|
||||
const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/;
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const settingsSocket = useStore(state => state.settingsSocket);
|
||||
const settings = useStore(state => state.settings) ?? '';
|
||||
const resolved = useStore(state => state.resolved);
|
||||
|
||||
const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]);
|
||||
|
||||
const [mode, setMode] = useState<Mode>('form');
|
||||
const [exposeExperimental] = useState(false);
|
||||
|
||||
// The Effective tab is only meaningful when there is a `resolved`
|
||||
// payload AND the file uses substitution. Falling back to Raw on
|
||||
// either condition keeps the toggle honest if the user opens this
|
||||
// page against an older server.
|
||||
const canShowEffective = usesEnvVars && resolved != null;
|
||||
useEffect(() => {
|
||||
if (mode === 'effective' && !canShowEffective) setMode('raw');
|
||||
}, [mode, canShowEffective]);
|
||||
|
||||
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
|
@ -57,49 +77,80 @@ export const SettingsPage = () => {
|
|||
settingsSocket.emit('saveSettings', settings);
|
||||
};
|
||||
|
||||
const effectiveJson = useMemo(() => {
|
||||
if (resolved == null) return '';
|
||||
try { return JSON.stringify(resolved, null, 2); } catch { return ''; }
|
||||
}, [resolved]);
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<h1><Trans i18nKey="admin_settings.current" /></h1>
|
||||
|
||||
<ModeToggle mode={mode} onChange={setMode} />
|
||||
{usesEnvVars && (
|
||||
<div
|
||||
className="settings-envvar-banner"
|
||||
role="note"
|
||||
data-testid="settings-envvar-banner"
|
||||
>
|
||||
<Info size={18} aria-hidden="true" />
|
||||
<div>
|
||||
<strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong>
|
||||
<p><Trans i18nKey="admin_settings.envvar_banner.body" /></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'form'
|
||||
? <FormView onSwitchToRaw={() => setMode('raw')} />
|
||||
: (
|
||||
<textarea
|
||||
value={settings}
|
||||
className="settings"
|
||||
data-testid="settings-raw-textarea"
|
||||
spellCheck={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={v => useStore.getState().setSettings(v.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} />
|
||||
|
||||
{mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />}
|
||||
{mode === 'raw' && (
|
||||
<textarea
|
||||
value={settings}
|
||||
className="settings"
|
||||
data-testid="settings-raw-textarea"
|
||||
spellCheck={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={v => useStore.getState().setSettings(v.target.value)}
|
||||
/>
|
||||
)}
|
||||
{mode === 'effective' && (
|
||||
<textarea
|
||||
value={effectiveJson}
|
||||
className="settings"
|
||||
data-testid="settings-effective-textarea"
|
||||
spellCheck={false}
|
||||
readOnly
|
||||
aria-readonly="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="settings-button-bar">
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="save-settings-button"
|
||||
icon={<Save />}
|
||||
title={<Trans i18nKey="admin_settings.current_save.value" />}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="test-settings-button"
|
||||
icon={<ShieldCheck />}
|
||||
title={<Trans i18nKey="admin_settings.current_test.value" />}
|
||||
onClick={testJSON}
|
||||
/>
|
||||
{exposeExperimental && (
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="prettify-settings-button"
|
||||
icon={<AlignLeft />}
|
||||
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
||||
onClick={prettifyJSON}
|
||||
/>
|
||||
{mode !== 'effective' && (
|
||||
<>
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="save-settings-button"
|
||||
icon={<Save />}
|
||||
title={<Trans i18nKey="admin_settings.current_save.value" />}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="test-settings-button"
|
||||
icon={<ShieldCheck />}
|
||||
title={<Trans i18nKey="admin_settings.current_test.value" />}
|
||||
onClick={testJSON}
|
||||
/>
|
||||
{exposeExperimental && (
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
data-testid="prettify-settings-button"
|
||||
icon={<AlignLeft />}
|
||||
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
||||
onClick={prettifyJSON}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
className="settingsButton"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import {useStore} from "../store/store.ts";
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import {VisuallyHidden} from '@radix-ui/react-visually-hidden';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import brand from './brand.svg'
|
||||
|
||||
export const LoadingScreen = ()=>{
|
||||
const showLoading = useStore(state => state.showLoading)
|
||||
const {t} = useTranslation()
|
||||
|
||||
return <Dialog.Root open={showLoading}><Dialog.Portal>
|
||||
<Dialog.Overlay className="loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
|
||||
<VisuallyHidden asChild><Dialog.Title>{t('admin.loading')}</Dialog.Title></VisuallyHidden>
|
||||
<VisuallyHidden asChild><Dialog.Description>{t('admin.loading_description')}</Dialog.Description></VisuallyHidden>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
|
||||
<div className="mt-4 text-[--fg-color]">
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
# Contributor Guidelines
|
||||
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
|
||||
|
||||
**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**
|
||||
|
||||
## Pull requests
|
||||
|
||||
* PRs MUST include a non-empty description explaining what the change does and why
|
||||
* PRs without a description should be flagged as incomplete
|
||||
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
|
||||
* PRs should be issued against the **develop** branch: we never pull directly into **master**
|
||||
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
|
||||
* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)
|
||||
* contain meaningful and detailed **commit messages** in the form:
|
||||
```
|
||||
submodule: description
|
||||
|
||||
longer description of the change you have made, eventually mentioning the
|
||||
number of the issue that is being fixed, in the form: Fixes #someIssueNumber
|
||||
```
|
||||
* if the PR is a **bug fix**:
|
||||
* The commit that fixes the bug should **include a regression test** that
|
||||
would fail if the bug fix was reverted. Adding the regression test in the
|
||||
same commit as the bug fix makes it easier for a reviewer to verify that the
|
||||
test is appropriate for the bug fix.
|
||||
* If there is a bug report, **the pull request description should include the
|
||||
text "`Fixes #xxx`"** so that the bug report is auto-closed when the PR is
|
||||
merged. It is less useful to say the same thing in a commit message because
|
||||
GitHub will spam the bug report every time the commit is rebased, and
|
||||
because a bug number alone becomes meaningless in forks. (A full URL would
|
||||
be better, but ideally each commit is readable on its own without the need
|
||||
to examine an external reference to understand motivation or context.)
|
||||
* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**
|
||||
* if you want to remove a feature, **deprecate it instead**:
|
||||
* write an issue with your deprecation plan
|
||||
* output a `WARN` in the log informing that the feature is going to be removed
|
||||
* remove the feature in the next version
|
||||
* if you want to add a new feature, put it under a **feature flag**:
|
||||
* once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early
|
||||
* expose a mechanism for enabling/disabling the feature
|
||||
* the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration
|
||||
* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it
|
||||
|
||||
## How to write a bug report
|
||||
|
||||
* Please be polite, we all are humans and problems can occur.
|
||||
* Please add as much information as possible, for example
|
||||
* client os(s) and version(s)
|
||||
* browser(s) and version(s), is the problem reproducible on different clients
|
||||
* special environments like firewalls or antivirus
|
||||
* host os and version
|
||||
* npm and nodejs version
|
||||
* Logfiles if available
|
||||
* steps to reproduce
|
||||
* what you expected to happen
|
||||
* what actually happened
|
||||
* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.
|
||||
|
||||
If you send logfiles, please set the loglevel switch DEBUG in your settings.json file:
|
||||
|
||||
```
|
||||
/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */
|
||||
"loglevel": "DEBUG",
|
||||
```
|
||||
|
||||
The logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.
|
||||
|
||||
## General goals of Etherpad
|
||||
To make sure everybody is going in the same direction:
|
||||
* easy to install for admins and easy to use for people
|
||||
* easy to integrate into other apps, but also usable as standalone
|
||||
* lightweight and scalable
|
||||
* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.
|
||||
Also, keep it maintainable. We don't wanna end up as the monster Etherpad was!
|
||||
|
||||
## How to work with git?
|
||||
* Don't work in your master branch.
|
||||
* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)
|
||||
* Don't use the online edit function of github (this only creates ugly and not working commits!)
|
||||
* Try to make clean commits that are easy readable (including descriptive commit messages!)
|
||||
* Test before you push. Sounds easy, it isn't!
|
||||
* Don't check in stuff that gets generated during build or runtime
|
||||
* Make small pull requests that are easy to review but make sure they do add value by themselves / individually
|
||||
|
||||
## Coding style
|
||||
* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)
|
||||
* Never ever use tabs
|
||||
* Indentation: 2 spaces
|
||||
* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!
|
||||
* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)
|
||||
* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!
|
||||
* If you do make changes, document them! (see below)
|
||||
* Use protocol independent urls "//"
|
||||
|
||||
## Branching model / git workflow
|
||||
see git flow http://nvie.com/posts/a-successful-git-branching-model/
|
||||
|
||||
### `master` branch
|
||||
* the stable
|
||||
* This is the branch everyone should use for production stuff
|
||||
|
||||
### `develop`branch
|
||||
* everything that is READY to go into master at some point in time
|
||||
* This stuff is tested and ready to go out
|
||||
|
||||
### release branches
|
||||
* stuff that should go into master very soon
|
||||
* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)
|
||||
* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.
|
||||
|
||||
### hotfix branches
|
||||
* fixes for bugs in master
|
||||
|
||||
### feature branches (in your own repos)
|
||||
* these are the branches where you develop your features in
|
||||
* If it's ready to go out, it will be merged into develop
|
||||
|
||||
Over the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop
|
||||
|
||||
## Documentation
|
||||
The docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.
|
||||
|
||||
Documentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.
|
||||
|
||||
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
|
||||
|
||||
## Testing
|
||||
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
||||
|
||||
Back-end tests can be run from the `src` directory, via `npm test`.
|
||||
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
|
||||
|
||||
|
|
@ -4,9 +4,9 @@
|
|||
* Compact every pad on the instance to reclaim database space.
|
||||
*
|
||||
* Usage:
|
||||
* node bin/compactAllPads.js # collapse all history on every pad
|
||||
* node bin/compactAllPads.js --keep N # keep last N revisions per pad
|
||||
* node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes
|
||||
* pnpm run --filter bin compactAllPads # collapse all history on every pad
|
||||
* pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad
|
||||
* pnpm run --filter bin compactAllPads --dry-run # list pads + rev counts, no writes
|
||||
*
|
||||
* Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is
|
||||
* deliberately no instance-wide HTTP endpoint, because doing this over a
|
||||
|
|
@ -170,9 +170,9 @@ export const parseArgs = (argv: string[]): CompactAllOpts | null => {
|
|||
// so the test harness can use `runCompactAll` directly without network.
|
||||
const usage = () => {
|
||||
console.error('Usage:');
|
||||
console.error(' node bin/compactAllPads.js');
|
||||
console.error(' node bin/compactAllPads.js --keep <N>');
|
||||
console.error(' node bin/compactAllPads.js --dry-run');
|
||||
console.error(' pnpm run --filter bin compactAllPads');
|
||||
console.error(' pnpm run --filter bin compactAllPads --keep <N>');
|
||||
console.error(' pnpm run --filter bin compactAllPads --dry-run');
|
||||
process.exit(2);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Compact a pad's revision history to reclaim database space.
|
||||
*
|
||||
* Usage:
|
||||
* node bin/compactPad.js <padID> # collapse all history
|
||||
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
|
||||
* pnpm run --filter bin compactPad <padID> # collapse all history
|
||||
* pnpm run --filter bin compactPad <padID> --keep N # keep only the last N revisions
|
||||
*
|
||||
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
|
||||
* compactPad HTTP API so admins can trigger it from the CLI without
|
||||
|
|
@ -41,8 +41,8 @@ const apiPost = async (p: string): Promise<any> => {
|
|||
|
||||
const usage = () => {
|
||||
console.error('Usage:');
|
||||
console.error(' node bin/compactPad.js <padID>');
|
||||
console.error(' node bin/compactPad.js <padID> --keep <N>');
|
||||
console.error(' pnpm run --filter bin compactPad <padID>');
|
||||
console.error(' pnpm run --filter bin compactPad <padID> --keep <N>');
|
||||
process.exit(2);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* Compact every pad on the instance that has not been edited recently.
|
||||
*
|
||||
* Usage:
|
||||
* node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days
|
||||
* node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions
|
||||
* node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write
|
||||
* pnpm run --filter bin compactStalePads --older-than 90 # collapse history on pads not edited in 90 days
|
||||
* pnpm run --filter bin compactStalePads --older-than 90 --keep 50 # keep last 50 revisions
|
||||
* pnpm run --filter bin compactStalePads --older-than 90 --dry-run # list, don't write
|
||||
*
|
||||
* Composes `listAllPads` → `getLastEdited` → `compactPad`. Same shape as
|
||||
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
|
||||
|
|
@ -255,9 +255,9 @@ export const parseArgs = (argv: string[]): CompactStaleOpts | null => {
|
|||
|
||||
const usage = () => {
|
||||
console.error('Usage:');
|
||||
console.error(' node bin/compactStalePads.js --older-than <days>');
|
||||
console.error(' node bin/compactStalePads.js --older-than <days> --keep <N>');
|
||||
console.error(' node bin/compactStalePads.js --older-than <days> --dry-run');
|
||||
console.error(' pnpm run --filter bin compactStalePads --older-than <days>');
|
||||
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --keep <N>');
|
||||
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --dry-run');
|
||||
process.exit(2);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# WARNING: since Etherpad 1.7.0 (2018-08-17), this script is DEPRECATED, and
|
||||
# will be removed/modified in a future version.
|
||||
# It's left here just for documentation.
|
||||
# The branching policies for releases have been changed.
|
||||
#
|
||||
# This script is used to publish a new release/version of etherpad on github
|
||||
#
|
||||
# Work that is done by this script:
|
||||
# ETHER_REPO:
|
||||
# - Add text to CHANGELOG.md
|
||||
# - Replace version of etherpad in src/package.json
|
||||
# - Create a release branch and push it to github
|
||||
# - Merges this release branch into master branch
|
||||
# - Creating the windows build and the docs
|
||||
# ETHER_WEB_REPO:
|
||||
# - Creating a new branch with the docs and the windows build
|
||||
# - Replacing the version numbers in the index.html
|
||||
# - Push this branch and merge it to master
|
||||
# ETHER_REPO:
|
||||
# - Create a new release on github
|
||||
|
||||
printf "WARNING: since Etherpad 1.7.0 this script is DEPRECATED, and will be removed/modified in a future version.\n\n"
|
||||
while true; do
|
||||
read -p "Do you want to continue? This is discouraged. [y/N]" yn
|
||||
case $yn in
|
||||
[Yy]* ) break;;
|
||||
[Nn]* ) exit;;
|
||||
* ) printf "Please answer yes or no.\n\n";;
|
||||
esac
|
||||
done
|
||||
|
||||
ETHER_REPO="https://github.com/ether/etherpad.git"
|
||||
ETHER_WEB_REPO="https://github.com/ether/ether.github.com.git"
|
||||
TMP_DIR="/tmp/"
|
||||
|
||||
echo "WARNING: You can only run this script if your github api token is allowed to create and merge branches on $ETHER_REPO and $ETHER_WEB_REPO."
|
||||
echo "This script automatically changes the version number in package.json and adds a text to CHANGELOG.md."
|
||||
echo "When you use this script you should be in the branch that you want to release (develop probably) on latest version. Any changes that are currently not committed will be committed."
|
||||
echo "-----"
|
||||
|
||||
# Get the latest version
|
||||
LATEST_GIT_TAG=$(git tag | tail -n 1)
|
||||
|
||||
# Current environment
|
||||
echo "Current environment: "
|
||||
echo "- branch: $(git branch | grep '* ')"
|
||||
echo "- last commit date: $(git show --quiet --pretty=format:%ad)"
|
||||
echo "- current version: $LATEST_GIT_TAG"
|
||||
echo "- temp dir: $TMP_DIR"
|
||||
|
||||
# Get new version number
|
||||
# format: x.x.x
|
||||
echo -n "Enter new version (x.x.x): "
|
||||
read VERSION
|
||||
|
||||
# Get the message for the changelogs
|
||||
read -p "Enter new changelog entries (press enter): "
|
||||
tmp=$(mktemp)
|
||||
"${EDITOR:-vi}" $tmp
|
||||
changelogText=$(<$tmp)
|
||||
echo "$changelogText"
|
||||
rm $tmp
|
||||
|
||||
if [ "$changelogText" != "" ]; then
|
||||
changelogText="# $VERSION\n$changelogText"
|
||||
fi
|
||||
|
||||
# get the token for the github api
|
||||
echo -n "Enter your github api token: "
|
||||
read API_TOKEN
|
||||
|
||||
function check_api_token {
|
||||
echo "Checking if github api token is valid..."
|
||||
CURL_RESPONSE=$(curl --silent -i https://api.github.com/user?access_token=$API_TOKEN | iconv -f utf8)
|
||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Invalid github api token" && exit 1
|
||||
}
|
||||
|
||||
function modify_files {
|
||||
# Add changelog text to first line of CHANGELOG.md
|
||||
|
||||
msg=""
|
||||
# source: https://unix.stackexchange.com/questions/9784/how-can-i-read-line-by-line-from-a-variable-in-bash#9789
|
||||
while IFS= read -r line
|
||||
do
|
||||
# replace newlines with literal "\n" for using with sed
|
||||
msg+="$line\n"
|
||||
done < <(printf '%s\n' "${changelogText}")
|
||||
|
||||
sed -i "1s/^/${msg}\n/" CHANGELOG.md
|
||||
[[ $? != 0 ]] && echo "Aborting: Error modifying CHANGELOG.md" && exit 1
|
||||
|
||||
# Replace version number of etherpad in package.json
|
||||
sed -i -r "s/(\"version\"[ ]*: \").*(\")/\1$VERSION\2/" src/package.json
|
||||
[[ $? != 0 ]] && echo "Aborting: Error modifying package.json" && exit 1
|
||||
}
|
||||
|
||||
function create_release_branch {
|
||||
echo "Creating new release branch..."
|
||||
git rev-parse --verify release/$VERSION 2>/dev/null
|
||||
if [ $? == 0 ]; then
|
||||
echo "Aborting: Release branch already present"
|
||||
exit 1
|
||||
fi
|
||||
git checkout -b release/$VERSION
|
||||
[[ $? != 0 ]] && echo "Aborting: Error creating release branch" && exit 1
|
||||
|
||||
echo "Committing CHANGELOG.md and package.json"
|
||||
git add CHANGELOG.md
|
||||
git add src/package.json
|
||||
git commit -m "Release version $VERSION"
|
||||
|
||||
echo "Pushing release branch to github..."
|
||||
git push -u $ETHER_REPO release/$VERSION
|
||||
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
|
||||
}
|
||||
|
||||
function merge_release_branch {
|
||||
echo "Merging release to master branch on github..."
|
||||
API_JSON=$(printf '{"base": "master","head": "release/%s","commit_message": "Merge new release into master branch!"}' $VERSION)
|
||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/merges?access_token=$API_TOKEN | iconv -f utf8)
|
||||
echo $CURL_RESPONSE
|
||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch on github" && exit 1
|
||||
}
|
||||
|
||||
function create_builds {
|
||||
echo "Cloning etherpad-lite repo and ether.github.com repo..."
|
||||
cd $TMP_DIR
|
||||
rm -rf etherpad-lite ether.github.com
|
||||
git clone $ETHER_REPO --branch master
|
||||
git clone $ETHER_WEB_REPO
|
||||
echo "Creating windows build..."
|
||||
cd etherpad-lite
|
||||
bin/buildForWindows.sh
|
||||
[[ $? != 0 ]] && echo "Aborting: Error creating build for windows" && exit 1
|
||||
echo "Creating docs..."
|
||||
make docs
|
||||
[[ $? != 0 ]] && echo "Aborting: Error generating docs" && exit 1
|
||||
}
|
||||
|
||||
function push_builds {
|
||||
cd $TMP_DIR/etherpad-lite/
|
||||
echo "Copying windows build and docs to website repo..."
|
||||
GIT_SHA=$(git rev-parse HEAD | cut -c1-10)
|
||||
mv etherpad-win.zip $TMP_DIR/ether.github.com/downloads/etherpad-win-$VERSION-$GIT_SHA.zip
|
||||
|
||||
mv out/doc $TMP_DIR/ether.github.com/doc/v$VERSION
|
||||
|
||||
cd $TMP_DIR/ether.github.com/
|
||||
sed -i "s/etherpad-win.*\.zip/etherpad-win-$VERSION-$GIT_SHA.zip/" index.html
|
||||
sed -i "s/$LATEST_GIT_TAG/$VERSION/g" index.html
|
||||
git checkout -b release_$VERSION
|
||||
[[ $? != 0 ]] && echo "Aborting: Error creating new release branch" && exit 1
|
||||
git add doc/
|
||||
git add downloads/
|
||||
git commit -a -m "Release version $VERSION"
|
||||
git push -u $ETHER_WEB_REPO release_$VERSION
|
||||
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
|
||||
}
|
||||
|
||||
function merge_web_branch {
|
||||
echo "Merging release to master branch on github..."
|
||||
API_JSON=$(printf '{"base": "master","head": "release_%s","commit_message": "Release version %s"}' $VERSION $VERSION)
|
||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/ether.github.com/merges?access_token=$API_TOKEN | iconv -f utf8)
|
||||
echo $CURL_RESPONSE
|
||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch" && exit 1
|
||||
}
|
||||
|
||||
function publish_release {
|
||||
echo -n "Do you want to publish a new release on github (y/n)? "
|
||||
read PUBLISH_RELEASE
|
||||
if [ $PUBLISH_RELEASE = "y" ]; then
|
||||
# create a new release on github
|
||||
API_JSON=$(printf '{"tag_name": "%s","target_commitish": "master","name": "Release %s","body": "%s","draft": false,"prerelease": false}' $VERSION $VERSION $changelogText)
|
||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/releases?access_token=$API_TOKEN | iconv -f utf8)
|
||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
||||
[[ $HTTP_STATUS != "201" ]] && echo "Aborting: Error publishing release on github" && exit 1
|
||||
else
|
||||
echo "No release published on github!"
|
||||
fi
|
||||
}
|
||||
|
||||
function todo_notification {
|
||||
echo "Release procedure was successful, but you have to do some steps manually:"
|
||||
echo "- Update the wiki at https://github.com/ether/etherpad/wiki"
|
||||
echo "- Create a pull request on github to merge the master branch back to develop"
|
||||
echo "- Announce the new release on the mailing list, blog.etherpad.org and Twitter"
|
||||
}
|
||||
|
||||
# Call functions
|
||||
check_api_token
|
||||
modify_files
|
||||
create_release_branch
|
||||
merge_release_branch
|
||||
create_builds
|
||||
push_builds
|
||||
merge_web_branch
|
||||
publish_release
|
||||
todo_notification
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
|
||||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||
import util from "node:util";
|
||||
import fs from 'node:fs';
|
||||
import log4js from 'log4js';
|
||||
import readline from 'readline';
|
||||
|
|
@ -69,8 +68,7 @@ const unescape = (val: string) => {
|
|||
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
|
||||
|
||||
log('initializing db');
|
||||
const initDb = await util.promisify(db.init.bind(db));
|
||||
await initDb(null);
|
||||
await db.init();
|
||||
log('done');
|
||||
|
||||
log(`Opening ${sqlFile}...`);
|
||||
|
|
@ -86,8 +84,7 @@ const unescape = (val: string) => {
|
|||
value = value.substring(0, value.length - 2);
|
||||
console.log(`key: ${key} val: ${value}`);
|
||||
console.log(`unval: ${unescape(value)}`);
|
||||
// @ts-ignore
|
||||
db.set(key, unescape(value), null);
|
||||
await db.set(key, unescape(value));
|
||||
keyNo++;
|
||||
if (keyNo % 1000 === 0) log(` ${keyNo}`);
|
||||
}
|
||||
|
|
@ -96,9 +93,7 @@ const unescape = (val: string) => {
|
|||
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||
'depended on dbms this may take some time..\n');
|
||||
|
||||
const closeDB = util.promisify(db.close.bind(db));
|
||||
// @ts-ignore
|
||||
await closeDB(null);
|
||||
await db.close();
|
||||
log(`finished, imported ${keyNo} keys.`);
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -74,10 +74,20 @@ const handleSync = async ()=>{
|
|||
}
|
||||
}
|
||||
|
||||
handleSync().then(()=>{
|
||||
handleSync().then(async ()=>{
|
||||
// Closing flushes any buffered writes to the target DB and clears
|
||||
// ueberdb2's keep-alive timer (added in 6.1.x). Without this the migrated
|
||||
// data may not be fully persisted and the process would hang forever
|
||||
// instead of exiting once the sync is done.
|
||||
await ueberdb2.close()
|
||||
await ueberdb1.close()
|
||||
console.log("Done syncing dbs")
|
||||
}).catch(e=>{
|
||||
process.exit(0)
|
||||
}).catch(async e=>{
|
||||
console.log(`Error syncing db ${e}`)
|
||||
await ueberdb2.close().catch(()=>{})
|
||||
await ueberdb1.close().catch(()=>{})
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,26 +35,16 @@ process.on('unhandledRejection', (err) => { throw err; });
|
|||
const keys = await dirty.findKeys('*', '')
|
||||
|
||||
console.log(`Found ${keys.length} records, processing now.`);
|
||||
const p: Promise<void>[] = [];
|
||||
let numWritten = 0;
|
||||
for (const key of keys) {
|
||||
let value = await dirty.get(key);
|
||||
let bcb, wcb;
|
||||
p.push(new Promise((resolve, reject) => {
|
||||
bcb = (err:any) => { if (err != null) return reject(err); };
|
||||
wcb = (err:any) => {
|
||||
if (err != null) return reject(err);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${length}`);
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
db.set(key, value, bcb, wcb);
|
||||
const value = await dirty.get(key);
|
||||
await db.set(key, value);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
|
||||
}
|
||||
await Promise.all(p);
|
||||
console.log(`Wrote all ${numWritten} records`);
|
||||
|
||||
await db.close(null);
|
||||
await dirty.close(null);
|
||||
await db.close();
|
||||
await dirty.close();
|
||||
console.log('Finished.');
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bin",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.2",
|
||||
"description": "",
|
||||
"main": "checkAllPads.js",
|
||||
"directories": {
|
||||
|
|
@ -9,12 +9,12 @@
|
|||
"dependencies": {
|
||||
"ep_etherpad-lite": "workspace:../src",
|
||||
"log4js": "^6.9.1",
|
||||
"semver": "^7.8.1",
|
||||
"tsx": "^4.22.3",
|
||||
"ueberdb2": "^6.1.2"
|
||||
"semver": "^7.8.5",
|
||||
"tsx": "^4.23.0",
|
||||
"ueberdb2": "6.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"checkAllPads": "node --import tsx checkAllPads.ts",
|
||||
"compactPad": "node --import tsx compactPad.ts",
|
||||
"compactAllPads": "node --import tsx compactAllPads.ts",
|
||||
"compactStalePads": "node --import tsx compactStalePads.ts",
|
||||
"createUserSession": "node --import tsx createUserSession.ts",
|
||||
"deletePad": "node --import tsx deletePad.ts",
|
||||
"repairPad": "node --import tsx repairPad.ts",
|
||||
|
|
|
|||
|
|
@ -26,13 +26,19 @@ export default defineConfig({
|
|||
text: 'About',
|
||||
items: [
|
||||
{ text: 'Docker', link: '/docker.md' },
|
||||
{ text: 'Configuration', link: '/configuration.md' },
|
||||
{ text: 'Deployment', link: '/deployment.md' },
|
||||
{ text: 'Database', link: '/database.md' },
|
||||
{ text: 'Localization', link: '/localization.md' },
|
||||
{ text: 'Cookies', link: '/cookies.md' },
|
||||
{ text: 'Plugins', link: '/plugins.md' },
|
||||
{ text: 'Stats', link: '/stats.md' },
|
||||
{text: 'Skins', link: '/skins.md' },
|
||||
{ text: 'Accessibility', link: '/accessibility.md' },
|
||||
{text: 'Demo', link: '/demo.md' },
|
||||
{text: 'CLI', link: '/cli.md'},
|
||||
{ text: 'Development', link: '/development.md' },
|
||||
{ text: 'FAQ', link: '/faq.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
91
doc/accessibility.md
Normal file
91
doc/accessibility.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Accessibility
|
||||
|
||||
Etherpad aims to be usable by everyone, including people who rely on a
|
||||
keyboard, a screen reader, or other assistive technology. The editor follows
|
||||
common conventions so that selecting, formatting, and navigating text works the
|
||||
way you would expect in other applications, and the toolbar can be reached and
|
||||
operated without a mouse.
|
||||
|
||||
If you find a feature that is not accessible, please let us know by opening an
|
||||
issue so it can be improved.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
The following shortcuts are built into the editor. On macOS use the Command
|
||||
(`Cmd`) key wherever `Ctrl` is listed.
|
||||
|
||||
::: tip
|
||||
Most shortcuts can be individually enabled or disabled through the
|
||||
`padShortcutEnabled` settings, so a deployment may have customised which of
|
||||
these are active.
|
||||
:::
|
||||
|
||||
### Editor
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Bold | `Ctrl` + `B` |
|
||||
| Italic | `Ctrl` + `I` |
|
||||
| Underline | `Ctrl` + `U` |
|
||||
| Strikethrough | `Ctrl` + `5` |
|
||||
| Ordered (numbered) list | `Ctrl` + `Shift` + `N` or `Ctrl` + `Shift` + `1` |
|
||||
| Unordered (bulleted) list | `Ctrl` + `Shift` + `L` |
|
||||
| Indent line or selection | `Tab` |
|
||||
| Outdent line or selection | `Shift` + `Tab` |
|
||||
| Undo | `Ctrl` + `Z` |
|
||||
| Redo | `Ctrl` + `Y` or `Ctrl` + `Shift` + `Z` |
|
||||
| Save a named revision | `Ctrl` + `S` |
|
||||
| Duplicate the current line(s) | `Ctrl` + `Shift` + `D` |
|
||||
| Delete the current line(s) | `Ctrl` + `Shift` + `K` |
|
||||
| Clear authorship colors on the pad or selection | `Ctrl` + `Shift` + `C` |
|
||||
| Show the authors of the current line | `Ctrl` + `Shift` + `2` |
|
||||
| Focus the toolbar (see below) | `Alt` + `F9` |
|
||||
| Focus the chat input | `Alt` + `C` |
|
||||
|
||||
Text selection, cut (`Ctrl` + `X`), copy (`Ctrl` + `C`), paste
|
||||
(`Ctrl` + `V`), and the arrow keys behave as they do in any standard text
|
||||
editor.
|
||||
|
||||
### Timeslider
|
||||
|
||||
The timeslider (revision history) provides its own shortcuts:
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Play / pause history playback | `Space` |
|
||||
| Step back one revision | `Left Arrow` |
|
||||
| Step forward one revision | `Right Arrow` |
|
||||
| Jump back to the previous starred revision | `Shift` + `Left Arrow` |
|
||||
| Jump forward to the next starred revision | `Shift` + `Right Arrow` |
|
||||
|
||||
## Toolbar navigation
|
||||
|
||||
The toolbar holds the formatting controls (bold, italic, lists, and so on) and
|
||||
can be reached and operated entirely from the keyboard:
|
||||
|
||||
* Press `Alt` + `F9` from the editor to move focus to the first button in the
|
||||
toolbar.
|
||||
* Use the `Left Arrow` and `Right Arrow` keys to move between buttons. `Tab`
|
||||
also moves to the next focusable control.
|
||||
* Press `Enter` to activate the focused button.
|
||||
* Press `Alt` + `F9` again, or `Escape`, to return focus to the pad.
|
||||
|
||||
Pressing `Escape` while a toolbar dropdown (such as the settings or color
|
||||
picker) is open closes that dropdown first.
|
||||
|
||||
## Screen readers
|
||||
|
||||
Etherpad provides as much screen reader support as possible. Support quality
|
||||
varies between platforms and browsers, so the following combinations are
|
||||
recommended:
|
||||
|
||||
* On Windows, Firefox with [NVDA](https://www.nvaccess.org/) currently gives the
|
||||
best experience.
|
||||
|
||||
To reduce verbose feedback while typing collaboratively in NVDA, open the
|
||||
keyboard settings (`NVDA` + `Ctrl` + `K`) and turn off **Speak typed characters**
|
||||
and **Speak typed words**.
|
||||
|
||||
Support in other screen readers and browsers (for example Orca on Linux, or
|
||||
Chrome) is more limited. Contributions to improve coverage on these platforms
|
||||
are very welcome.
|
||||
|
|
@ -29,13 +29,22 @@ In `settings.json`:
|
|||
"requireSignature": false,
|
||||
"trustedKeysPath": null
|
||||
},
|
||||
"adminEmail": null
|
||||
"adminEmail": null,
|
||||
// SMTP transport for the admin notification emails. host=null keeps
|
||||
// log-only behaviour ("(would send email)"); set host+from to deliver.
|
||||
"mail": {
|
||||
"host": null,
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"from": null,
|
||||
"auth": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
|
||||
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. All tiers are implemented. Higher tiers are silently downgraded if the install method does not allow them (only `"git"` installs can run the write tiers `manual` / `auto` / `autonomous`). |
|
||||
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
|
||||
| `updates.channel` | `"stable"` | Reserved. Stable releases only. |
|
||||
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
|
||||
|
|
@ -49,6 +58,11 @@ In `settings.json`:
|
|||
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
|
||||
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
|
||||
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
||||
| `mail.host` | `null` | Top-level SMTP host. **`null` keeps log-only behaviour** — notifications are logged as `(would send email)` and never delivered. Set a host (and `mail.from`) to deliver over SMTP via nodemailer. The `nodemailer` dependency is lazy-loaded, so installs that leave `mail.host` unset pay no runtime cost. |
|
||||
| `mail.port` | `587` | SMTP port. |
|
||||
| `mail.secure` | `false` | `true` for an implicit-TLS connection (typically port 465); `false` uses STARTTLS upgrade when offered. |
|
||||
| `mail.from` | `null` | Envelope/From address. **Required for delivery** — if `mail.from` is unset (even with a host) the updater falls back to log-only `(would send email)`. |
|
||||
| `mail.auth` | `null` | SMTP credentials object `{ "user": "...", "pass": "..." }`, passed through to nodemailer. Leave `null` for unauthenticated relays. |
|
||||
|
||||
## What "outdated" means
|
||||
|
||||
|
|
@ -56,14 +70,26 @@ In `settings.json`:
|
|||
|
||||
## Email cadence (when `adminEmail` is set)
|
||||
|
||||
These are the "nudge" emails sent by the periodic checker when the instance is behind:
|
||||
|
||||
| Trigger | First send | Repeat |
|
||||
| --- | --- | --- |
|
||||
| Outdated (minor or more behind) detected | Immediate | Monthly while still outdated |
|
||||
| Outdated (minor or more behind) detected | Immediate | Every 30 days while still outdated (`SEVERE_INTERVAL`) |
|
||||
| Up to date | No email | — |
|
||||
|
||||
The write tiers also email about **apply outcomes** so admins learn about failures without watching the UI:
|
||||
|
||||
| Outcome | When | Dedupe |
|
||||
| --- | --- | --- |
|
||||
| `update-preflight-failed` | An auto/autonomous apply was blocked at preflight (e.g. `node-engine-mismatch`, dirty tree, low disk). Subject: *Auto-update to `<tag>` blocked at preflight*. | Deduped on `<outcome>:<targetTag>` — one email per outcome per target tag. |
|
||||
| `update-rolled-back` | An apply failed mid-flow and Etherpad auto-recovered to the previous version. Subject: *Auto-update to `<tag>` rolled back*. | Deduped on `<outcome>:<targetTag>`. |
|
||||
| `update-rollback-failed` | **Terminal.** The apply failed *and* the rollback failed — manual intervention required. Subject: *Auto-update FAILED and could not be rolled back — manual intervention required*. | **Always sends**, bypassing dedupe, because the admin must learn about it even if a transient failure shared the same key. |
|
||||
|
||||
A different outcome or a different target tag resets the dedupe key and fires a fresh email. Manual (Tier 2) failures surface in the admin UI banner; the outcome emails are tied to the auto/autonomous flows.
|
||||
|
||||
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
|
||||
|
||||
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
|
||||
SMTP delivery is wired via [nodemailer](https://nodemailer.com/) (lazy-loaded). When `mail.host` and `mail.from` are both set, emails are delivered over SMTP. When either is unset the updater falls back to logging each message as `(would send email)` — the dedupe state still advances correctly, so admins are not bombarded once SMTP is configured. An SMTP send failure is caught and logged (`email send failed: …`) and never disrupts the updater state machine.
|
||||
|
||||
## Pad-side notice
|
||||
|
||||
|
|
@ -76,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
|
|||
|
||||
## Disabling everything
|
||||
|
||||
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
|
||||
Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)).
|
||||
|
||||
On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables.
|
||||
|
||||
## Privacy
|
||||
|
||||
|
|
@ -93,7 +121,7 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
|
|||
|
||||
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
||||
|
||||
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
|
||||
Every install method gets the Tier 1 banner. The install method gates whether the write tiers (manual click, auto, autonomous) can run: only `"git"` installs are supported for the write tiers — other methods are silently downgraded to notify.
|
||||
|
||||
## Tier 2 — manual click
|
||||
|
||||
|
|
@ -110,7 +138,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
|
|||
### What clicking "Apply update" does
|
||||
|
||||
1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically).
|
||||
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
|
||||
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, no lock held, target tag exists at the configured remote, signature verifies (if `requireSignature: true`), and the target's Node engine matches the running Node. The Node-engine check runs *after* signature verification (so the `engines.node` range comes from a trusted tag): Etherpad reads `engines.node` from the target tag's `package.json` via `git show <tag>:package.json` and refuses the update via `semver.satisfies` if the running Node does not satisfy it. On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
|
||||
3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
|
||||
4. **Execute** — `git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
|
||||
5. **Exit 75** — the supervisor restarts on the new version.
|
||||
|
|
@ -121,6 +149,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
|
|||
| What went wrong | Resulting state | Admin action |
|
||||
| --- | --- | --- |
|
||||
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
|
||||
| Target tag requires a newer (or different) Node than the one running | `preflight-failed` (reason `node-engine-mismatch`) | Fails cleanly at preflight with a detail like *"target requires Node >=X, running Y"*. No drain, no `git checkout`, no restart, nothing to roll back — the install is untouched. Upgrade Node to a version that satisfies the target's `engines.node`, then **Acknowledge** and retry. |
|
||||
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
|
||||
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
|
||||
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
|
||||
|
|
@ -181,7 +210,7 @@ A single `grace-start` notification fires per scheduled tag:
|
|||
|
||||
> [Etherpad] Auto-update scheduled for 2.7.2
|
||||
|
||||
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
|
||||
with the `scheduledFor` timestamp. Delivery follows the same SMTP path as every other notification: when `mail.host` and `mail.from` are set the message is sent via nodemailer, otherwise it logs as `(would send email)`. Cadence and dedupe update correctly either way.
|
||||
|
||||
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Changeset Library
|
||||
|
||||
The [changeset
|
||||
library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)
|
||||
library](https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts)
|
||||
provides tools to create, read, and apply changesets.
|
||||
|
||||
## Changeset
|
||||
|
|
@ -21,6 +21,42 @@ A transmitted changeset looks like this:
|
|||
'Z:z>1|2=m=b*0|1+1$\n'
|
||||
```
|
||||
|
||||
### Reading a changeset
|
||||
|
||||
`unpack()` splits a changeset string into its parts:
|
||||
|
||||
```javascript
|
||||
const unpacked = Changeset.unpack('Z:z>1|2=m=b*0|1+1$\n');
|
||||
// { oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' }
|
||||
```
|
||||
|
||||
`oldLen` is the document length before the change and `newLen` the length after.
|
||||
`ops` is the list of operations, and `charBank` holds the characters inserted by
|
||||
those operations.
|
||||
|
||||
Iterate the operations with `deserializeOps()`, which yields one `Op` at a time:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(unpacked.ops)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '=', chars: 22, lines: 2, attribs: '' }
|
||||
// Op { opcode: '=', chars: 11, lines: 0, attribs: '' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
```
|
||||
|
||||
There are three kinds of operation, each applied starting from the current
|
||||
position in the text:
|
||||
|
||||
- `=` keeps text (it may still change the text's attributes, e.g. make it bold).
|
||||
- `-` removes text.
|
||||
- `+` inserts text (taking the characters from the changeset's `charBank`).
|
||||
|
||||
`opcode` is the operation type; `chars` and `lines` are how much text it covers;
|
||||
and `attribs` are the attributes applied, written as `*` references into the
|
||||
pad's attribute pool. In the example above the final op inserts one character
|
||||
(the newline from `charBank`) carrying attribute `*0`.
|
||||
|
||||
## Attribute Pool
|
||||
|
||||
```javascript
|
||||
|
|
@ -36,6 +72,59 @@ are used many times.
|
|||
There is one attribute pool per pad, and it includes every current and
|
||||
historical attribute used in the pad.
|
||||
|
||||
A pool can be serialized to and from a plain object with `toJsonable()` and
|
||||
`fromJsonable()`:
|
||||
|
||||
```javascript
|
||||
const pool = new AttributePool();
|
||||
pool.fromJsonable({
|
||||
numToAttrib: {
|
||||
0: ['author', 'a.kVnWeomPADAT2pn9'],
|
||||
1: ['bold', 'true'],
|
||||
2: ['italic', 'true'],
|
||||
},
|
||||
nextNum: 3,
|
||||
});
|
||||
|
||||
pool.getAttrib(1); // [ 'bold', 'true' ]
|
||||
pool.getAttribKey(1); // 'bold'
|
||||
pool.getAttribValue(1); // 'true'
|
||||
```
|
||||
|
||||
Each attribute is a `[key, value]` pair — `['bold', 'true']`, or
|
||||
`['author', '<authorId>']`. A character can carry several attributes (bold *and*
|
||||
italic), but only one value per key (so it cannot belong to two authors).
|
||||
|
||||
## Attributed text (atext)
|
||||
|
||||
A pad's content is stored as *attributed text* (`atext`): the plain text plus an
|
||||
attribute string describing which attributes apply to each span.
|
||||
|
||||
```javascript
|
||||
const atext = {
|
||||
text: 'bold text\nitalic text\nnormal text\n\n',
|
||||
attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2',
|
||||
};
|
||||
```
|
||||
|
||||
The attribute string is a sequence of `+` operations — the same encoding used by
|
||||
changesets — which you can read with `deserializeOps()`:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(atext.attribs)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '+', chars: 9, lines: 0, attribs: '*0*1' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0*1*2' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 2, lines: 2, attribs: '' }
|
||||
```
|
||||
|
||||
Read against the pool above, the first nine characters (`bold text`) carry
|
||||
attributes `*0*1` (author + bold), the following newline carries `*0`, and so on.
|
||||
|
||||
## Further Reading
|
||||
|
||||
Detailed information about the changesets & Easysync protocol:
|
||||
|
|
|
|||
|
|
@ -465,6 +465,20 @@ also use this to handle existing types.
|
|||
`collab_client.js` has a pretty extensive list of message types, if you want to
|
||||
take a look.
|
||||
|
||||
## handleClientTimesliderMessage_`name`
|
||||
|
||||
Called from: `src/static/js/broadcast.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. payload - the data that got sent with the message (use it for custom message
|
||||
content)
|
||||
|
||||
This is the timeslider analog of `handleClientMessage_name`. It gets called
|
||||
every time the timeslider receives a message of type `name`. Use it to handle
|
||||
custom message types (or react to existing ones) while a user is viewing the
|
||||
timeslider rather than editing the pad.
|
||||
|
||||
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
|
||||
|
||||
Called from: src/static/js/ace2_inner.js
|
||||
|
|
@ -496,6 +510,34 @@ Things in context:
|
|||
This hook is provided to allow a plugin to handle key events.
|
||||
The return value should be true if you have handled the event.
|
||||
|
||||
## acePaste
|
||||
|
||||
Called from: `src/static/js/ace2_inner.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. editorInfo - information about the user who is making the change
|
||||
2. rep - information about where the change is being made
|
||||
3. documentAttributeManager - information about attributes in the document
|
||||
4. e - the fired paste event
|
||||
|
||||
This hook is called when content is pasted into the editor, before Etherpad
|
||||
processes the pasted content. Use it to inspect or react to paste events.
|
||||
|
||||
## aceDrop
|
||||
|
||||
Called from: `src/static/js/ace2_inner.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. editorInfo - information about the user who is making the change
|
||||
2. rep - information about where the change is being made
|
||||
3. documentAttributeManager - information about attributes in the document
|
||||
4. e - the fired drop event
|
||||
|
||||
This hook is called when content is dropped into the editor via drag-and-drop.
|
||||
Use it to inspect or react to drop events.
|
||||
|
||||
## collectContentLineText
|
||||
|
||||
Called from: `src/static/js/contentcollector.js`
|
||||
|
|
|
|||
|
|
@ -856,6 +856,39 @@ exports.exportHTMLAdditionalContent = async (hookName, {padId}) => {
|
|||
};
|
||||
```
|
||||
|
||||
## exportHTMLSend
|
||||
|
||||
Called from: `src/node/handler/ExportHandler.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. html - the full HTML body of the pad that is about to be sent to the client
|
||||
as an HTML export.
|
||||
|
||||
This hook is invoked via `aCallFirst` for HTML exports, just before the HTML is
|
||||
sent in the response. It lets a plugin rewrite or replace the exported HTML
|
||||
body. Return the modified HTML string; if a non-empty value is returned, it
|
||||
replaces the HTML that would otherwise be sent.
|
||||
|
||||
## exportConvert
|
||||
|
||||
Called from: `src/node/handler/ExportHandler.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. srcFile - the path to the source file (the intermediate file Etherpad has
|
||||
produced) that needs to be converted.
|
||||
2. destFile - the path to the destination file Etherpad expects the converted
|
||||
output to be written to.
|
||||
3. req - the express request object.
|
||||
4. res - the express response object.
|
||||
|
||||
This hook is invoked via `aCallAll` for non-HTML export formats. It lets a
|
||||
plugin handle the export format conversion itself instead of letting Etherpad
|
||||
fall back to its bundled LibreOffice converter. If any plugin returns a value
|
||||
(making the hook result non-empty), Etherpad assumes the conversion was handled
|
||||
by the plugin and skips the built-in converter.
|
||||
|
||||
## stylesForExport
|
||||
Called from: `src/node/utils/ExportHtml.js`
|
||||
|
||||
|
|
@ -1101,3 +1134,66 @@ Context properties:
|
|||
value with the user's actual author ID before this hook runs).
|
||||
* `padId`: The pad's real (not read-only) identifier.
|
||||
* `pad`: The pad's Pad object.
|
||||
|
||||
## ccRegisterBlockElements
|
||||
|
||||
Called from: `src/node/handler/ImportHandler.ts`,
|
||||
`src/node/utils/ImportEtherpad.ts`, and `src/static/js/contentcollector.ts`
|
||||
|
||||
Things in context: None
|
||||
|
||||
This is the **server-side companion** to the client-only
|
||||
`aceRegisterBlockElements` hook (see the client-side hooks documentation), and
|
||||
it is the half that plugin authors commonly forget to implement. Registering a
|
||||
block element only on the client means imports and server-side content
|
||||
collection do not treat your element as block-level.
|
||||
|
||||
The return value of this hook should be an array of tag names. Those tags are
|
||||
added to the set of block-level elements that the content collector and the
|
||||
import path (`.etherpad` and HTML/document imports) recognise, so the
|
||||
collected/imported content is segmented into the correct lines. Plugins that
|
||||
declare block elements via `aceRegisterBlockElements` should declare the same
|
||||
elements here too.
|
||||
|
||||
```js
|
||||
exports.ccRegisterBlockElements = () => ['pre', 'blockquote'];
|
||||
```
|
||||
|
||||
## createServer
|
||||
|
||||
Called from: `src/node/server.ts`
|
||||
|
||||
Things in context: None
|
||||
|
||||
Invoked via `aCallAll` once during Etherpad startup, after the HTTP server has
|
||||
been created. Use it to run any plugin initialisation that needs to happen when
|
||||
the server comes up.
|
||||
|
||||
## restartServer
|
||||
|
||||
Called from: `src/node/hooks/express/adminsettings.ts` (and the plugin
|
||||
installer)
|
||||
|
||||
Things in context: None
|
||||
|
||||
Invoked via `aCallAll` when the server is restarted from the admin interface
|
||||
(for example after settings are saved or a plugin is installed/uninstalled). Use
|
||||
it to re-initialise plugin state that needs to survive an admin-triggered
|
||||
restart.
|
||||
|
||||
## clientReady
|
||||
|
||||
> **Deprecated:** use the `userJoin` hook instead. This hook is fired with an
|
||||
> awkward context (the raw `CLIENT_READY` message rather than a structured set
|
||||
> of context properties) and is kept only for backwards compatibility.
|
||||
|
||||
Called from: `src/node/handler/PadMessageHandler.ts`
|
||||
|
||||
Things in context:
|
||||
|
||||
1. message - the raw `CLIENT_READY` message sent by the client as it joins a
|
||||
pad.
|
||||
|
||||
Invoked via `aCallAll` while a client is joining a pad, before the author and
|
||||
session are fully set up. Because the context is just the raw client message,
|
||||
new plugins should use `userJoin` instead.
|
||||
|
|
|
|||
|
|
@ -306,6 +306,18 @@ Returns the Author Name of the author
|
|||
|
||||
-> can't be deleted cause this would involve scanning all the pads where this author was
|
||||
|
||||
#### anonymizeAuthor(authorID)
|
||||
* API >= 1.3.1
|
||||
|
||||
Erases an author's identity across all pads they contributed to (GDPR Article 17, the "right to erasure"). The author's name and external/token mappings are removed and their chat messages are cleared, so the author can no longer be re-identified from pad data.
|
||||
|
||||
This endpoint is **disabled by default** and is gated on `gdprAuthorErasure.enabled = true` in `settings.json`. If GDPR author erasure is not enabled, the call returns an error and performs no changes. See [doc/privacy.md](../privacy.md) for the privacy/data-retention context.
|
||||
|
||||
*Example returns:*
|
||||
* `{code: 0, message:"ok", data: {affectedPads: 3, removedTokenMappings: 1, removedExternalMappings: 1, clearedChatMessages: 7}}`
|
||||
* `{code: 1, message:"anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true in settings.json to enable GDPR Art. 17 erasure", data: null}`
|
||||
* `{code: 1, message:"authorID is required", data: null}`
|
||||
|
||||
### Session
|
||||
Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.
|
||||
|
||||
|
|
@ -609,7 +621,7 @@ token is ignored.
|
|||
* `{code: 1, message:"invalid deletionToken", data: null}`
|
||||
|
||||
#### copyPad(sourceID, destinationID[, force=false])
|
||||
* API >= 1.2.8
|
||||
* API >= 1.2.9
|
||||
|
||||
copies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.
|
||||
|
||||
|
|
@ -629,7 +641,7 @@ Note that all the revisions will be lost! In most of the cases one should use `c
|
|||
* `{code: 1, message:"padID does not exist", data: null}`
|
||||
|
||||
#### movePad(sourceID, destinationID[, force=false])
|
||||
* API >= 1.2.8
|
||||
* API >= 1.2.9
|
||||
|
||||
moves a pad. If force is true and the destination pad exists, it will be overwritten.
|
||||
|
||||
|
|
@ -646,7 +658,7 @@ collapses the pad's revision history to reclaim database space (issue #6194). Wr
|
|||
|
||||
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
|
||||
|
||||
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**
|
||||
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first (for example via the `/p/:padID/export/etherpad` export endpoint) if you need a full-history backup.**
|
||||
|
||||
*Example returns:*
|
||||
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
|
||||
|
|
@ -664,10 +676,10 @@ returns the read only link of a pad
|
|||
* `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}`
|
||||
* `{code: 1, message:"padID does not exist", data: null}`
|
||||
|
||||
#### getPadID(readOnlyID)
|
||||
#### getPadID(roID)
|
||||
* API >= 1.2.10
|
||||
|
||||
returns the id of a pad which is assigned to the readOnlyID
|
||||
returns the id of the pad mapped to the given read-only id. The query parameter is named `roID` (the read-only id returned by `getReadOnlyID`).
|
||||
|
||||
*Example returns:*
|
||||
* `{code: 0, message:"ok", data: {padID: "p.s8oes9dhwrvt0zif"}}`
|
||||
|
|
|
|||
141
doc/cli.md
141
doc/cli.md
|
|
@ -1,68 +1,151 @@
|
|||
# CLI
|
||||
|
||||
You can find different tools for migrating things, checking your Etherpad health in the bin directory.
|
||||
One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.
|
||||
In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.
|
||||
Etherpad ships a set of operator tools in the `bin/` directory for migrating
|
||||
data, reclaiming database space, repairing damaged pads, managing sessions and
|
||||
managing plugins. They are TypeScript scripts run through `tsx`, and each one
|
||||
is registered as a pnpm script in `bin/package.json`. Invoke them from the
|
||||
Etherpad root with:
|
||||
|
||||
```
|
||||
pnpm run --filter bin <script> [args]
|
||||
```
|
||||
|
||||
For example `pnpm run --filter bin checkPad my-pad`. Running the `.ts` files
|
||||
directly with `node bin/foo.js` will **not** work — there are no compiled `.js`
|
||||
files and the scripts need the `tsx` loader.
|
||||
|
||||
## Running vs. stopped
|
||||
|
||||
Some tools talk to a **running** Etherpad over its HTTP API (they read the API
|
||||
key from `APIKEY.txt` and connect to `ip`/`port` from `settings.json`). Others
|
||||
open the database **directly** and must be run while Etherpad is **stopped**,
|
||||
otherwise you risk a database lock or a corrupt write. Each tool below is
|
||||
labelled accordingly.
|
||||
|
||||
## Database migration (`migrateDB`)
|
||||
|
||||
`migrateDB` copies every record from one database to another. It takes two
|
||||
settings files — `--file1` is the **source**, `--file2` is the **target** —
|
||||
each describing a database with a `dbType` and `dbSettings`. Both paths are
|
||||
resolved relative to the Etherpad root.
|
||||
|
||||
In this example we migrate from the old `dirty` db to the new `rustydb` engine.
|
||||
Create a source descriptor `source.json` in the Etherpad root:
|
||||
|
||||
````json
|
||||
{
|
||||
"dbType": "dirty",
|
||||
"dbSettings": {
|
||||
"filename": "./var/dirty.db"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
and a target descriptor `target.json`:
|
||||
|
||||
````json
|
||||
{
|
||||
"dbType": "rustydb",
|
||||
"dbSettings": {
|
||||
"filename": "./var/rusty.db"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
Then run:
|
||||
|
||||
```
|
||||
pnpm run --filter bin migrateDB --file1 source.json --file2 target.json
|
||||
```
|
||||
|
||||
````json
|
||||
{
|
||||
"dbType": "rustydb",
|
||||
"dbSettings": {
|
||||
"filename": "./var/rusty2.db"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
|
||||
After that we need to move the data from dirty to rustydb.
|
||||
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
|
||||
After some time the data is copied over to the new database. Run this with
|
||||
Etherpad **stopped**.
|
||||
|
||||
## Pad compaction
|
||||
|
||||
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
|
||||
Long-lived pads with heavy edit history accumulate revisions in the database.
|
||||
Three CLIs reclaim that space, in increasing scope. All of them drive a
|
||||
**running** Etherpad over the `compactPad` HTTP API.
|
||||
|
||||
| Tool | Targets | When to use |
|
||||
| --- | --- | --- |
|
||||
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
|
||||
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
|
||||
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
|
||||
| `pnpm run --filter bin compactPad <padID>` | one pad | you know which pad is fat |
|
||||
| `pnpm run --filter bin compactAllPads` | every pad | bulk reclaim across the whole instance |
|
||||
| `pnpm run --filter bin compactStalePads --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
|
||||
|
||||
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
|
||||
All three are gated on `cleanup.enabled = true` in `settings.json` and are
|
||||
**destructive**: history is collapsed (or trimmed). Export anything you can't
|
||||
afford to lose with the `getEtherpad` API first.
|
||||
|
||||
Common flags:
|
||||
|
||||
- `--keep N` — retain the last N revisions instead of collapsing all history.
|
||||
- `--dry-run` — list pads and revision counts without writing.
|
||||
- `--dry-run` — list pads and revision counts without writing (`compactAllPads`
|
||||
and `compactStalePads` only).
|
||||
- `--older-than N` — (`compactStalePads` only, **required**) only consider pads
|
||||
not edited in the last N days.
|
||||
|
||||
### Examples
|
||||
|
||||
````
|
||||
# Compact a specific pad, collapsing all history.
|
||||
node bin/compactPad.js my-pad
|
||||
pnpm run --filter bin compactPad my-pad
|
||||
|
||||
# Keep only the last 50 revisions of one pad.
|
||||
node bin/compactPad.js my-pad --keep 50
|
||||
pnpm run --filter bin compactPad my-pad --keep 50
|
||||
|
||||
# Compact every pad on the instance (per-pad failures don't stop the run).
|
||||
node bin/compactAllPads.js
|
||||
node bin/compactAllPads.js --dry-run
|
||||
pnpm run --filter bin compactAllPads
|
||||
pnpm run --filter bin compactAllPads --dry-run
|
||||
|
||||
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
|
||||
node bin/compactStalePads.js --older-than 90 --keep 50
|
||||
node bin/compactStalePads.js --older-than 90 --dry-run
|
||||
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
|
||||
pnpm run --filter bin compactStalePads --older-than 90 --dry-run
|
||||
````
|
||||
|
||||
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
|
||||
`compactStalePads` is the right tool for periodic operator runs on long-lived
|
||||
instances — hot pads that users are still navigating in timeslider stay
|
||||
untouched (staleness is even re-checked right before each compaction), and only
|
||||
the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault)
|
||||
are counted but do not abort the bulk run; the exit code reflects whether
|
||||
anything failed.
|
||||
|
||||
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).
|
||||
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive
|
||||
over the wire (issues #6194, #7642).
|
||||
|
||||
## Pad maintenance and debugging
|
||||
|
||||
| Tool | Purpose | Args | Etherpad |
|
||||
| --- | --- | --- | --- |
|
||||
| `pnpm run --filter bin checkPad <padID>` | Check one pad's revisions for data corruption. | `<padID>` | stopped |
|
||||
| `pnpm run --filter bin checkAllPads` | Check every pad on the instance for data corruption. | none | stopped |
|
||||
| `pnpm run --filter bin repairPad <padID>` | Repair a pad by extracting all of its data, deleting it and re-inserting it. | `<padID>` | stopped (the script refuses to be useful otherwise) |
|
||||
| `pnpm run --filter bin rebuildPad <padID> <rev> [newPadID]` | Rebuild a damaged pad into a new pad at a known-good revision. The new pad defaults to `<padID>-rebuilt` and must not already exist. | `<padID> <rev> [newPadID]` | stopped |
|
||||
| `node --import tsx extractPadData.ts <padID>` | Export one pad's data to a `<padID>.db` dirtyDB file so a bug can be reproduced in a dev environment. (No pnpm alias — run with the `tsx` loader directly from `bin/`.) | `<padID>` | stopped |
|
||||
|
||||
`checkPad`/`checkAllPads` report corruption but do not modify anything;
|
||||
`repairPad` and `rebuildPad` write. As always, back up first.
|
||||
|
||||
## Database tools
|
||||
|
||||
| Tool | Purpose | Args | Etherpad |
|
||||
| --- | --- | --- | --- |
|
||||
| `pnpm run --filter bin migrateDB --file1 <src.json> --file2 <dst.json>` | Copy all records from a source database to a target database (see above). | `--file1 <src.json> --file2 <dst.json>` | stopped |
|
||||
| `pnpm run --filter bin migrateDirtyDBtoRealDB` | One-shot migration of `var/dirty.db` into the real database configured in `settings.json`. Back up `dirty.db` first; may need more memory (e.g. `node --max-old-space-size=4096`). | none (reads target db from `settings.json`) | stopped |
|
||||
| `pnpm run --filter bin importSqlFile <sqlFile>` | Import a SQL dump (rows of `REPLACE INTO store VALUES (...)`) into the configured database. | `<sqlFile>` | stopped |
|
||||
|
||||
## Session and pad management
|
||||
|
||||
| Tool | Purpose | Args | Etherpad |
|
||||
| --- | --- | --- | --- |
|
||||
| `pnpm run --filter bin deletePad <padID>` | Delete a single pad. | `<padID>` | running |
|
||||
| `pnpm run --filter bin deleteAllGroupSessions` | Delete all group sessions (useful when a misconfiguration has wedged group access). | none | running |
|
||||
| `pnpm run --filter bin createUserSession` | Create a throwaway group, pad, author and session, printing a `sessionID` you can set as a cookie — handy for debugging session-based configs. | none | running |
|
||||
|
||||
## Plugin tools
|
||||
|
||||
| Tool | Purpose | Args | Etherpad |
|
||||
| --- | --- | --- | --- |
|
||||
| `pnpm run --filter bin plugins <action> [names…]` | Manage installed plugins. Actions: `i`/`install`, `rm`/`remove`, `ls`/`list`, `up`/`update`. Install also accepts `--path <dir>` and `--github <repo>` sources. | `<action> [names…]` | stopped |
|
||||
| `pnpm run --filter bin checkPlugin <ep_name> [autofix\|autocommit\|autopush]` | Lint a plugin checkout (a sibling `../ep_name` directory) against the plugin conventions; optional modes auto-fix, commit, or commit+push+publish (the last is dangerous). | `<ep_name> [mode]` | n/a |
|
||||
| `pnpm run --filter bin stalePlugins` | List plugins in the registry not updated in over two years, with maintainer email. Requires `privacy.pluginCatalog` enabled. | none | n/a |
|
||||
|
|
|
|||
146
doc/configuration.md
Normal file
146
doc/configuration.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Configuration
|
||||
|
||||
This page explains how Etherpad is configured and documents the
|
||||
reverse-proxy and subpath behaviour in detail. It is **not** an exhaustive list
|
||||
of every setting — for that, see the fully-commented
|
||||
[`settings.json.template`](https://github.com/ether/etherpad/blob/develop/settings.json.template),
|
||||
which is the authoritative reference.
|
||||
|
||||
## Where settings live
|
||||
|
||||
Etherpad reads its configuration from `settings.json` in the installation root.
|
||||
A new install copies `settings.json.template` to `settings.json` on first run.
|
||||
|
||||
* **Override the file location** by passing the `-s` / `--settings` flag to
|
||||
the launcher, e.g. `bin/run.sh -s /etc/etherpad/settings.json`. This lets
|
||||
you run multiple instances from one installation. (`bin/run.sh` forwards the
|
||||
flag to `pnpm run prod`, which is the supported entrypoint — there is no
|
||||
`server.js`; the runtime is `src/node/server.ts`, loaded via `tsx`.)
|
||||
* **Environment-variable substitution** — any string value may reference an
|
||||
environment variable using the syntax `"${ENV_VAR}"` or
|
||||
`"${ENV_VAR:default}"`. The variable name **must** be quoted, even when the
|
||||
resolved value is a number or boolean. A few rules worth remembering:
|
||||
* `"${PORT:9001}"` → the value of `PORT`, or `9001` if unset.
|
||||
* `"${MINIFY:true}"` → the boolean `true`/`false`, not the string.
|
||||
* `"${UNSET_VAR:null}"` → `null`; `"${UNSET_VAR:}"` → the empty string.
|
||||
* Substitution happens at load time, in memory only — env vars never
|
||||
overwrite `settings.json` on disk.
|
||||
|
||||
When running in Docker, almost every setting is wired to an environment
|
||||
variable in the shipped `settings.json.docker`. See the
|
||||
[Docker page](/docker.md) for the full env-var list.
|
||||
|
||||
## Trusting a reverse proxy
|
||||
|
||||
If Etherpad runs behind NGINX, Traefik, HAProxy, a Kubernetes ingress, or any
|
||||
other reverse proxy, set:
|
||||
|
||||
```json
|
||||
"trustProxy": true
|
||||
```
|
||||
|
||||
This makes Etherpad trust the standard `X-Forwarded-*` headers, so it:
|
||||
|
||||
* uses the real client IP (from `X-Forwarded-For`) in logs and rate limits
|
||||
instead of the proxy's IP;
|
||||
* respects the forwarded protocol and host, so the `secure` flag is set on
|
||||
cookies when the proxy terminates TLS (required for `SameSite=None`).
|
||||
|
||||
Leave it at the default `false` when Etherpad is reachable directly on a public
|
||||
IP — otherwise any client could forge these headers.
|
||||
|
||||
## Running under a subpath / ingress
|
||||
|
||||
Etherpad can be served under a URL-path prefix (for example
|
||||
`https://example.com/etherpad/`) without recompiling anything. The prefix is
|
||||
discovered per-request from upstream headers, so the same Etherpad process works
|
||||
whether it is mounted at the root or under a path.
|
||||
|
||||
Three headers are checked, **in this order**; the first non-empty value (after
|
||||
sanitization) wins:
|
||||
|
||||
| Order | Header | Origin | Requires `trustProxy: true`? |
|
||||
| ----- | ------ | ------ | ---------------------------- |
|
||||
| 1 | `x-proxy-path` | Etherpad's own convention | No — always honoured |
|
||||
| 2 | `X-Forwarded-Prefix` | HAProxy / Traefik / Spring | Yes |
|
||||
| 3 | `X-Ingress-Path` | Kubernetes / Home Assistant ingress | Yes |
|
||||
|
||||
`x-proxy-path` is always honoured because an operator must deliberately
|
||||
configure their proxy to send Etherpad's custom header. The two standard
|
||||
headers (`X-Forwarded-Prefix`, `X-Ingress-Path`) are honoured **only when
|
||||
`trustProxy` is `true`**, because otherwise a client on a public IP could forge
|
||||
them.
|
||||
|
||||
Once detected, the prefix is woven into the responses that would otherwise
|
||||
break under a subpath:
|
||||
|
||||
* `manifest.json` (PWA install metadata);
|
||||
* the social-media meta tags (`og:url` / `og:image`), unless an explicit
|
||||
`publicURL` is configured;
|
||||
* the bootstrap script entrypoint and the asset / reconnect links in the pad,
|
||||
index, and timeslider pages.
|
||||
|
||||
### Sanitization
|
||||
|
||||
The header value is treated as untrusted input even when read from a trusted
|
||||
header, because it ends up inside HTML, JS, CSS, and HTTP `Location` headers.
|
||||
The sanitizer (`src/node/utils/sanitizeProxyPath.ts`):
|
||||
|
||||
* strips every character outside `[A-Za-z0-9_./-]`;
|
||||
* collapses a leading `//+` to a single `/`, so the value can never be read as
|
||||
a protocol-relative URL;
|
||||
* prepends `/` if the result doesn't already start with one;
|
||||
* **rejects** any value containing a `..` path segment (returns empty).
|
||||
|
||||
The output is therefore always either empty, or a string that starts with
|
||||
exactly one `/` and contains only `[A-Za-z0-9_./-]`.
|
||||
|
||||
### Example: Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
etherpad-prefix:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/etherpad"
|
||||
etherpad-headers:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Forwarded-Prefix: "/etherpad"
|
||||
```
|
||||
|
||||
Apply both middlewares to the router and set `trustProxy: true` in
|
||||
`settings.json`.
|
||||
|
||||
### Example: NGINX
|
||||
|
||||
```nginx
|
||||
location /etherpad/ {
|
||||
proxy_pass http://127.0.0.1:9001/;
|
||||
proxy_set_header X-Proxy-Path /etherpad;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Here `X-Proxy-Path` is used, which works regardless of `trustProxy`. Use
|
||||
`X-Forwarded-Prefix` instead if you prefer the standard header (and set
|
||||
`trustProxy: true`).
|
||||
|
||||
## Self-update, email, database, and metrics
|
||||
|
||||
These areas have their own pages:
|
||||
|
||||
* **Self-update** and **outbound email** (`adminEmail`, `mail.*` SMTP) —
|
||||
see [Updates](/admin/updates.md). The corresponding Docker env vars
|
||||
(`MAIL_HOST`, `MAIL_FROM`, …) are listed on the [Docker page](/docker.md).
|
||||
* **Database** — choose a backend with `dbType` / `dbSettings`. The
|
||||
supported drivers and example settings are documented in
|
||||
[`settings.json.template`](https://github.com/ether/etherpad-lite/blob/develop/settings.json.template),
|
||||
and the Docker equivalents (`DB_TYPE`, `DB_HOST`, …) are listed on the
|
||||
[Docker page](/docker.md). The on-disk keyspace layout is described in
|
||||
[`doc/database.adoc`](https://github.com/ether/etherpad-lite/blob/develop/doc/database.adoc).
|
||||
* **Metrics** — Etherpad exposes Prometheus-compatible metrics; see
|
||||
[Stats](/stats.md).
|
||||
204
doc/database.md
Normal file
204
doc/database.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Database structure
|
||||
|
||||
## Keys and their values
|
||||
|
||||
### groups
|
||||
A list of all existing groups (a JSON object with groupIDs as keys and `1` as values).
|
||||
|
||||
### pad:$PADID
|
||||
Contains all information about pads
|
||||
|
||||
- **atext** - the latest attributed text
|
||||
- **pool** - the attribute pool
|
||||
- **head** - the number of the latest revision
|
||||
- **chatHead** - the number of the latest chat entry
|
||||
- **public** - flag that disables security for this pad
|
||||
- **passwordHash** - string that contains a salted sha512 sum of this pad's password
|
||||
|
||||
### pad:$PADID:revs:$REVNUM
|
||||
Saves a revision $REVNUM of pad $PADID
|
||||
|
||||
- **meta**
|
||||
- **author** - the autorID of this revision
|
||||
- **timestamp** - the timestamp of when this revision was created
|
||||
- **changeset** - the changeset of this revision
|
||||
|
||||
### pad:$PADID:chat:$CHATNUM
|
||||
Saves a chat entry with num $CHATNUM of pad $PADID
|
||||
|
||||
- **text** - the text of this chat entry
|
||||
- **userId** - the authorID of this chat entry
|
||||
- **time** - the timestamp of this chat entry
|
||||
|
||||
### pad2readonly:$PADID
|
||||
Translates a padID to a readonlyID
|
||||
|
||||
### readonly2pad:$READONLYID
|
||||
Translates a readonlyID to a padID
|
||||
|
||||
### token2author:$TOKENID
|
||||
Translates a token to an authorID
|
||||
|
||||
### globalAuthor:$AUTHORID
|
||||
Information about an author
|
||||
|
||||
- **name** - the name of this author as shown in the pad
|
||||
- **colorID** - the colorID of this author as shown in the pad
|
||||
|
||||
### mapper2group:$MAPPER
|
||||
Maps an external application identifier to an internal group
|
||||
|
||||
### mapper2author:$MAPPER
|
||||
Maps an external application identifier to an internal author
|
||||
|
||||
### group:$GROUPID
|
||||
a group of pads
|
||||
|
||||
- **pads** - object with pad names in it, values are 1
|
||||
|
||||
### session:$SESSIONID
|
||||
a session between an author and a group
|
||||
|
||||
- **groupID** - the groupID the session belongs too
|
||||
- **authorID** - the authorID the session belongs too
|
||||
- **validUntil** - the timestamp until this session is valid
|
||||
|
||||
### author2sessions:$AUTHORID
|
||||
saves the sessions of an author
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
### group2sessions:$GROUPID
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
# Connecting to a database backend
|
||||
|
||||
Etherpad stores everything in a single key/value table through
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), so the same data model works
|
||||
across many backends. The backend is selected with `dbType` in `settings.json`,
|
||||
and backend-specific connection options go in `dbSettings`.
|
||||
|
||||
The default `dirty` backend writes to a local file (`var/dirty.db`) and needs no
|
||||
setup, which is convenient for development but not recommended for production.
|
||||
For a production instance, point Etherpad at a real database such as MySQL/MariaDB,
|
||||
PostgreSQL or Redis. Etherpad creates its own table on first run; you only need
|
||||
to provision an empty database and a user with access to it.
|
||||
|
||||
## MySQL / MariaDB
|
||||
|
||||
Create the database and a user, then grant access:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE `etherpad` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
CREATE USER 'etherpad'@'localhost' IDENTIFIED BY 'a-secure-password';
|
||||
GRANT CREATE,ALTER,SELECT,INSERT,UPDATE,DELETE ON `etherpad`.* TO 'etherpad'@'localhost';
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "mysql",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad",
|
||||
"charset": "utf8mb4"
|
||||
}
|
||||
```
|
||||
|
||||
Setting `charset` to `utf8mb4` is strongly recommended so that the full range of
|
||||
Unicode (including emoji) is stored correctly. To connect over a local socket
|
||||
instead of TCP, replace `host`/`port` with `"socketPath": "/var/run/mysqld/mysqld.sock"`.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Create the user and a database owned by it:
|
||||
|
||||
```sql
|
||||
CREATE USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
CREATE DATABASE etherpad OWNER etherpad;
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "postgres",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad"
|
||||
}
|
||||
```
|
||||
|
||||
The `dbSettings` object is passed straight to the `node-postgres` connection
|
||||
pool, so any option it accepts (including a single `"connectionString"`) works.
|
||||
On Debian/Ubuntu you can use peer authentication over the local socket by
|
||||
setting `"host": "/var/run/postgresql"` and an empty password, provided the
|
||||
operating-system user that runs Etherpad matches the PostgreSQL role.
|
||||
|
||||
## Redis
|
||||
|
||||
Install Redis and make sure it persists data to disk. Configure `settings.json`
|
||||
with either discrete fields or a single connection URL:
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "a-secure-redis-password"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"url": "redis://:a-secure-redis-password@localhost:6379"
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating from MySQL to PostgreSQL
|
||||
|
||||
[pgloader](https://pgloader.io/) can copy an existing Etherpad database from
|
||||
MySQL to PostgreSQL. Stop Etherpad first so the source database is quiescent.
|
||||
|
||||
```bash
|
||||
sudo apt-get install postgresql pgloader
|
||||
|
||||
# Create the target role and database
|
||||
sudo -u postgres createuser etherpad
|
||||
sudo -u postgres createdb -O etherpad etherpad
|
||||
|
||||
# Describe and run the migration
|
||||
cat > pgloader.load <<'EOF'
|
||||
LOAD DATABASE
|
||||
FROM mysql://etherpad:MYSQL_PASSWORD@127.0.0.1/etherpad
|
||||
INTO postgresql:///etherpad
|
||||
WITH preserve index names, prefetch rows = 100
|
||||
ALTER SCHEMA 'etherpad' RENAME TO 'public';
|
||||
EOF
|
||||
|
||||
pgloader --verbose pgloader.load
|
||||
```
|
||||
|
||||
Afterwards set the PostgreSQL user's password and make sure it can read and
|
||||
write the migrated table:
|
||||
|
||||
```sql
|
||||
ALTER USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
GRANT pg_read_all_data TO etherpad;
|
||||
GRANT pg_write_all_data TO etherpad;
|
||||
```
|
||||
|
||||
Then point `settings.json` at PostgreSQL as shown above and start Etherpad.
|
||||
|
||||
::: tip
|
||||
To move data between *any* two backends supported by ueberDB, you can also
|
||||
use the `migrateDB` CLI tool, which reads every record from a source database
|
||||
descriptor and writes it to a target one. See the [CLI chapter](./cli.md).
|
||||
:::
|
||||
359
doc/deployment.md
Normal file
359
doc/deployment.md
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
# Deployment
|
||||
|
||||
This page collects working configurations for deploying Etherpad in production:
|
||||
running it behind a reverse proxy, hosting it under a subdirectory, terminating
|
||||
HTTPS natively, running it as a system service, and deploying it on Kubernetes.
|
||||
|
||||
Etherpad listens on port `9001` by default. Throughout this page the upstream
|
||||
Etherpad server is assumed to be reachable at `http://127.0.0.1:9001`.
|
||||
|
||||
## Running behind a reverse proxy
|
||||
|
||||
The recommended production setup is to run Etherpad on `127.0.0.1:9001` and put a
|
||||
reverse proxy in front of it to terminate TLS, serve a virtual host, and forward
|
||||
requests.
|
||||
|
||||
Etherpad uses WebSockets (via socket.io). The load-bearing part of every proxy
|
||||
config below is the WebSocket upgrade: the proxy **must** forward the `Upgrade`
|
||||
and `Connection` headers, or real-time editing will silently fail back to slow
|
||||
long-polling (or break entirely).
|
||||
|
||||
When Etherpad runs behind a proxy you should also set `trustProxy: true` in your
|
||||
settings so that Etherpad honours the `X-Forwarded-*` headers (correct client IP,
|
||||
secure-cookie flag, etc.). See the `trustProxy` section in the [Configuration documentation](./configuration.md) for the full details of which headers are trusted.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Map the Upgrade header so WebSockets work. Place this in the http context.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name pad.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/etherpad.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/etherpad.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass_header Server;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect plain HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name pad.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
Enable `mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel` and `mod_headers`.
|
||||
The `mod_proxy_wstunnel` `upgrade=websocket` syntax requires Apache 2.4.47 or
|
||||
newer.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName pad.example.com
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/etherpad/etherpad.crt
|
||||
SSLCertificateKeyFile /etc/ssl/etherpad/etherpad.key
|
||||
|
||||
ProxyVia On
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
|
||||
# WebSocket traffic (socket.io) must be matched first.
|
||||
<Location "/socket.io">
|
||||
ProxyPass "ws://127.0.0.1:9001/socket.io" upgrade=websocket timeout=30
|
||||
ProxyPassReverse "ws://127.0.0.1:9001/socket.io"
|
||||
</Location>
|
||||
|
||||
<Location "/">
|
||||
ProxyPass "http://127.0.0.1:9001/" retry=0 timeout=30
|
||||
ProxyPassReverse "http://127.0.0.1:9001/"
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
Caddy v2 proxies WebSocket connections automatically and obtains/renews a
|
||||
certificate for you, so the configuration is minimal:
|
||||
|
||||
```caddy
|
||||
pad.example.com {
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik
|
||||
|
||||
Traefik v2 also proxies WebSockets transparently. For a Docker deployment, attach
|
||||
these labels to the Etherpad container:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.etherpad.rule=Host(`pad.example.com`)"
|
||||
- "traefik.http.routers.etherpad.entrypoints=websecure"
|
||||
- "traefik.http.routers.etherpad.tls.certresolver=myresolver"
|
||||
- "traefik.http.services.etherpad.loadbalancer.server.port=9001"
|
||||
- "traefik.http.services.etherpad.loadbalancer.passhostheader=true"
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
HAProxy detects the `Connection: Upgrade` exchange automatically and switches to
|
||||
tunnel mode once the WebSocket is established. The important value is
|
||||
`timeout tunnel`, which governs the lifetime of the upgraded connection.
|
||||
|
||||
```haproxy
|
||||
frontend http
|
||||
mode http
|
||||
bind *:80
|
||||
bind *:443 ssl crt /etc/haproxy/certs/etherpad.pem alpn h2,http/1.1
|
||||
http-request redirect scheme https code 301 unless { ssl_fc }
|
||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||
default_backend etherpad
|
||||
|
||||
backend etherpad
|
||||
mode http
|
||||
option forwardfor
|
||||
timeout client 25s
|
||||
timeout server 25s
|
||||
timeout tunnel 3600s
|
||||
server pad 127.0.0.1:9001
|
||||
```
|
||||
|
||||
## Hosting under a subdirectory
|
||||
|
||||
To serve Etherpad from a path such as `https://example.com/pad` rather than from
|
||||
the root of a domain, the proxy must send the `X-Proxy-Path` header so that
|
||||
Etherpad rewrites its own asset and API URLs to include the prefix. This header
|
||||
is honoured regardless of the `trustProxy` setting — see the [Configuration documentation](./configuration.md).
|
||||
|
||||
```nginx
|
||||
location /pad/ {
|
||||
rewrite ^/pad/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Proxy-Path /pad;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
```
|
||||
|
||||
## Native HTTPS without a proxy
|
||||
|
||||
Etherpad can terminate TLS itself using Node's native HTTPS server, with no
|
||||
reverse proxy required. Configure the `ssl` block in `settings.json`:
|
||||
|
||||
```json
|
||||
"ssl": {
|
||||
"key": "/path-to-your/etherpad-server.key",
|
||||
"cert": "/path-to-your/etherpad-server.crt",
|
||||
"ca": ["/path-to-your/intermediate-cert1.crt", "/path-to-your/intermediate-cert2.crt"]
|
||||
}
|
||||
```
|
||||
|
||||
* `key` — path to the private key file.
|
||||
* `cert` — path to the certificate file.
|
||||
* `ca` — an (optional) array of intermediate/chain certificate paths.
|
||||
|
||||
Restart Etherpad after editing the settings. It will now serve HTTPS on its
|
||||
configured port.
|
||||
|
||||
For local testing you can generate a self-signed certificate with a single
|
||||
command:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -nodes -days 365 \
|
||||
-keyout etherpad-server.key -out etherpad-server.crt \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
Make sure the files are readable only by the user that runs Etherpad:
|
||||
|
||||
```bash
|
||||
chmod 400 etherpad-server.key etherpad-server.crt
|
||||
chown etherpad etherpad-server.key etherpad-server.crt
|
||||
```
|
||||
|
||||
::: tip
|
||||
Self-signed certificates trigger browser warnings and are only suitable for
|
||||
testing. For production, obtain a free, trusted certificate from
|
||||
[Let's Encrypt](https://letsencrypt.org/), or terminate TLS at a reverse proxy
|
||||
(see above) and let it manage certificate issuance and renewal.
|
||||
:::
|
||||
|
||||
## Running as a service (systemd)
|
||||
|
||||
On a modern Linux distribution, run Etherpad as a `systemd` service so it starts
|
||||
on boot and restarts automatically on failure.
|
||||
|
||||
Create a dedicated unprivileged user and install Etherpad into its home
|
||||
directory (for example `/opt/etherpad`), owned by that user. Etherpad refuses to
|
||||
start as root.
|
||||
|
||||
Create `/etc/systemd/system/etherpad.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Etherpad collaborative editor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=etherpad
|
||||
Group=etherpad
|
||||
WorkingDirectory=/opt/etherpad
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/pnpm run prod
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Adjust `WorkingDirectory` to your install path and the `ExecStart` path to
|
||||
wherever `pnpm` lives (`which pnpm`). Then enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now etherpad.service
|
||||
|
||||
# check status and follow logs
|
||||
sudo systemctl status etherpad.service
|
||||
sudo journalctl -u etherpad.service -f
|
||||
```
|
||||
|
||||
## Kubernetes (Istio)
|
||||
|
||||
The following manifest deploys Etherpad behind an Istio ingress gateway. It
|
||||
defines three resources: a `Gateway` (TLS + hostname), a `VirtualService`
|
||||
(routing with WebSocket-friendly timeouts), and a `DestinationRule` (sticky
|
||||
sessions via the socket.io `io` cookie).
|
||||
|
||||
It assumes:
|
||||
|
||||
* Istio >= 1.18
|
||||
* A `Service` named `etherpad` in the `etherpad` namespace, on port `9001`
|
||||
* A TLS secret `etherpad-tls` provisioned in the gateway namespace
|
||||
* You replace `<your-host>` with your own hostname
|
||||
|
||||
::: warning
|
||||
Sticky sessions are necessary but **not** sufficient for a multi-replica
|
||||
Etherpad deployment. Multi-replica also needs the socket.io Redis adapter so
|
||||
that pad state is shared across pods. Without it, two clients editing the same
|
||||
pad but routed to different pods will see divergent state.
|
||||
|
||||
Recommendation: start with `replicas: 1` plus good failover, and only go
|
||||
multi-replica once the Redis adapter is wired up.
|
||||
:::
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: Gateway
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
selector:
|
||||
istio: ingressgateway
|
||||
servers:
|
||||
- port:
|
||||
number: 443
|
||||
name: https
|
||||
protocol: HTTPS
|
||||
tls:
|
||||
mode: SIMPLE
|
||||
credentialName: etherpad-tls
|
||||
hosts:
|
||||
- <your-host>
|
||||
- port:
|
||||
number: 80
|
||||
name: http
|
||||
protocol: HTTP
|
||||
hosts:
|
||||
- <your-host>
|
||||
tls:
|
||||
httpsRedirect: true
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
hosts:
|
||||
- <your-host>
|
||||
gateways:
|
||||
- etherpad
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: /
|
||||
route:
|
||||
- destination:
|
||||
host: etherpad
|
||||
port:
|
||||
number: 9001
|
||||
# No per-request timeout — websockets and long-polling sit on the
|
||||
# connection indefinitely. The default of 15s kills WS upgrades.
|
||||
timeout: 0s
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: DestinationRule
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
host: etherpad
|
||||
trafficPolicy:
|
||||
loadBalancer:
|
||||
# Sticky sessions on the socket.io session cookie. Required so that
|
||||
# long-polling fallback requests land on the same pod that owns the
|
||||
# session state.
|
||||
consistentHash:
|
||||
httpCookie:
|
||||
name: io
|
||||
ttl: 0s # session cookie, expires with the browser tab
|
||||
connectionPool:
|
||||
tcp:
|
||||
maxConnections: 10000
|
||||
http:
|
||||
# Must comfortably exceed socket.io's pingInterval (25s) +
|
||||
# pingTimeout (20s). 1h is conservative.
|
||||
idleTimeout: 3600s
|
||||
h2UpgradePolicy: UPGRADE
|
||||
http1MaxPendingRequests: 1000
|
||||
```
|
||||
240
doc/development.md
Normal file
240
doc/development.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Development
|
||||
|
||||
This page is a contributor-oriented tour of the Etherpad source tree and of a
|
||||
few internals that plugin authors and core contributors commonly need to
|
||||
understand: how the source is laid out, how pads are converted to and from
|
||||
other formats, and how to access the database from server-side code.
|
||||
|
||||
The Etherpad server is written in TypeScript (`.ts`). Most server code lives
|
||||
under `src/node/` and most client code under `src/static/js/`.
|
||||
|
||||
## Source tree overview
|
||||
|
||||
The repository root contains, among others, the following directories:
|
||||
|
||||
```
|
||||
etherpad/
|
||||
|- bin/ # maintenance and build scripts (run.sh, pad tools, docs, release)
|
||||
|- doc/ # this manual, in AsciiDoc and Markdown
|
||||
|- src/ # the Etherpad source code
|
||||
|- packaging/ # OS/distribution packaging helpers
|
||||
|- var/ # runtime data (e.g. the dirty.db database file)
|
||||
```
|
||||
|
||||
`bin/` contains scripts for running and maintaining Etherpad. For example
|
||||
`bin/run.sh` starts the server, and there are TypeScript utilities such as
|
||||
`bin/checkPad.ts`, `bin/deletePad.ts`, `bin/repairPad.ts`,
|
||||
`bin/rebuildPad.ts`, `bin/migrateDB.ts` and `bin/make_docs.ts`.
|
||||
|
||||
The HTML manual is built from the AsciiDoc sources in `doc/` by
|
||||
`bin/make_docs.ts` (exposed as the `makeDocs` script), which shells out to
|
||||
`asciidoctor` and writes the result to `out/doc/`. From the repository root you
|
||||
can run it with `pnpm run makeDocs`. (`asciidoctor` must be installed.)
|
||||
|
||||
The `src/` directory looks like this:
|
||||
|
||||
```
|
||||
src/
|
||||
|- locales/ # translations, managed via https://translatewiki.net
|
||||
|- node/ # server-side code
|
||||
|- static/ # client-side code, CSS and fonts
|
||||
|- templates/ # server-rendered page templates
|
||||
|- ep.json # core plugin/hook registration
|
||||
|- package.json # package name: ep_etherpad-lite
|
||||
```
|
||||
|
||||
### src/node/ (server side)
|
||||
|
||||
```
|
||||
src/node/
|
||||
|- db/ # database access and pad/author/group/session state
|
||||
|- eejs/ # server-side embedded-JS templating
|
||||
|- handler/ # import/export and collaboration message handling
|
||||
|- hooks/ # express route registration and i18n
|
||||
|- security/ # crypto, OAuth2/OIDC, secret rotation
|
||||
|- types/ # shared TypeScript types
|
||||
|- updater/ # in-place self-update machinery
|
||||
|- utils/ # settings, import/export format helpers, toolbar, minification
|
||||
|- server.ts # entry point
|
||||
```
|
||||
|
||||
`db/` contains the modules that read and write pad state. `Pad.ts` manages an
|
||||
individual pad; `PadManager.ts`, `AuthorManager.ts`, `GroupManager.ts`,
|
||||
`SessionManager.ts` and `ReadOnlyManager.ts` manage the corresponding records;
|
||||
`DB.ts` exposes the low-level key/value store (see
|
||||
[Accessing the database from server code / plugins](#accessing-the-database-from-server-code-plugins)); and `API.ts` implements
|
||||
the public HTTP API.
|
||||
|
||||
`handler/` contains the request and message handlers. `PadMessageHandler.ts`
|
||||
drives real-time collaboration, while `ImportHandler.ts` and `ExportHandler.ts`
|
||||
handle import and export.
|
||||
|
||||
`hooks/` contains mostly Express-related code. `i18n.ts` builds the translation
|
||||
files and registers routes to serve them, and `hooks/express/` registers the
|
||||
routes that serve pads, the timeslider, static assets and the admin pages.
|
||||
|
||||
`utils/` contains the import/export format converters (`ImportHtml.ts`,
|
||||
`ExportHtml.ts`, `ExportTxt.ts`, `ExportEtherpad.ts`, `ImportEtherpad.ts`,
|
||||
`ExportHelper.ts`, and native converters such as `ExportPdfNative.ts` and
|
||||
`ImportDocxNative.ts`), the settings parser (`Settings.ts`), the toolbar builder
|
||||
(`toolbar.ts`) and the asset minifier (`Minify.ts`).
|
||||
|
||||
### src/static/ (client side)
|
||||
|
||||
```
|
||||
src/static/
|
||||
|- css/ # stylesheets, including css/pad/icons.css
|
||||
|- font/ # web fonts, including the fontawesome-etherpad icon font
|
||||
|- img/
|
||||
|- js/ # client-side TypeScript
|
||||
|- skins/ # bundled UI skins
|
||||
|- vendor/
|
||||
```
|
||||
|
||||
`js/` contains the client-side editor code. Notable modules include
|
||||
`ace2_inner.ts` and `ace2_common.ts` (the editor core), `contentcollector.ts`,
|
||||
`linestylefilter.ts` and `domline.ts` (content/attribute processing, shared
|
||||
with the server import/export pipeline), `Changeset.ts` and `AttributePool.ts`
|
||||
(the changeset and attribute model), and `collab_client.ts` (the
|
||||
client side of real-time collaboration).
|
||||
|
||||
### src/templates/
|
||||
|
||||
`templates/` contains the server-rendered page templates for the index, the
|
||||
pad, the timeslider and the admin pages, plus the bootstrap scripts that load
|
||||
the client bundles. The templates expose named `eejs` blocks that plugins can
|
||||
hook into to inject custom HTML.
|
||||
|
||||
## How Etherpad converts pads to and from other formats
|
||||
|
||||
Internally a pad is not stored as HTML. A pad is a sequence of lines, and each
|
||||
line carries **attributes** (for example `heading1`, `bullet` or a list number).
|
||||
The set of attributes that a pad can use is stored in its **attribute pool**; the
|
||||
pool only records which attributes exist, not where they are applied. The
|
||||
pool grows over the history of the pad.
|
||||
|
||||
Where an attribute is applied to a line is recorded in an **attribute string**,
|
||||
and a line that carries a line-level attribute is prefixed with a **line marker**
|
||||
(`lmkr`). Attribute strings and changesets are defined by
|
||||
`src/static/js/Changeset.ts` and `src/static/js/AttributePool.ts`.
|
||||
|
||||
### Collecting content
|
||||
|
||||
`src/static/js/contentcollector.ts` is the shared starting point for both the
|
||||
client (when content is typed or pasted) and the server (when content is
|
||||
imported). It walks the incoming DOM/HTML, decides which attributes apply to
|
||||
each line, adds the discovered attributes to the attribute pool, and emits the
|
||||
resulting attribute strings. On import, `src/node/utils/ImportHtml.ts` calls
|
||||
`contentcollector.makeContentCollector(...)` to do exactly this, and the HTML
|
||||
import path in `src/node/handler/ImportHandler.ts` ultimately drives it.
|
||||
|
||||
### From attributes to HTML/text (export)
|
||||
|
||||
On export the flow is, conceptually:
|
||||
|
||||
```
|
||||
contentcollector.ts
|
||||
-> linestylefilter.ts
|
||||
-> ExportHtml.ts / ExportTxt.ts (helped by ExportHelper.ts)
|
||||
-> ExportHandler.ts
|
||||
-> the HTTP API / /export/* route
|
||||
```
|
||||
|
||||
- `src/static/js/linestylefilter.ts` walks each line, reads its attributes,
|
||||
and turns them into the classes/markup the line should render with.
|
||||
- `src/node/utils/ExportHelper.ts` adds export-only logic that does not belong
|
||||
in the live editor. The clearest example is lists: in the editor each list
|
||||
item is rendered as its own line-level block, but a clean export needs the
|
||||
items collapsed into a single properly nested list. The helper performs that
|
||||
reshaping for export only.
|
||||
- `src/node/utils/ExportHtml.ts` and `src/node/utils/ExportTxt.ts` (and
|
||||
`ExportEtherpad.ts` for the native `.etherpad` format) turn the attributed
|
||||
text (`atext`) into the final HTML or plain text.
|
||||
- `src/node/handler/ExportHandler.ts` receives the export request and dispatches
|
||||
on the requested format — for instance, office formats such as `.docx` and
|
||||
`.pdf` are routed through the native converters / LibreOffice rather than
|
||||
through the plain HTML/text path.
|
||||
|
||||
On the client side, edits are turned into changesets by the editor, attributes
|
||||
are translated into CSS classes (so `heading2` becomes
|
||||
`class="heading2"`), and `src/static/js/domline.ts` (`createDomLine`) renders
|
||||
the final DOM for each line.
|
||||
|
||||
## Accessing the database from server code / plugins
|
||||
|
||||
Etherpad stores everything in a single key/value store backed by
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), which abstracts over the
|
||||
configured database (dirtyDB, MySQL/MariaDB, PostgreSQL, SQLite, MongoDB, Redis,
|
||||
and others). Server-side code and plugins access it through
|
||||
`src/node/db/DB.ts`.
|
||||
|
||||
The package name of the core module is, for historical reasons, still
|
||||
`ep_etherpad-lite`, so plugins import the database module like this:
|
||||
|
||||
```javascript
|
||||
const db = require('ep_etherpad-lite/node/db/DB');
|
||||
```
|
||||
|
||||
The exposed methods are asynchronous and return promises (use `await`), not the
|
||||
old callback style. The available methods are `get`, `set`, `remove`, `getSub`,
|
||||
`setSub`, `findKeys` and `findKeysPaged`:
|
||||
|
||||
```javascript
|
||||
// Read a record (returns undefined/null if it does not exist)
|
||||
const value = await db.get('record_key');
|
||||
|
||||
// Create or replace a record
|
||||
await db.set('record_key', data);
|
||||
|
||||
// Read or write a nested value inside a record
|
||||
const colorId = await db.getSub('author_key', ['colorId']);
|
||||
await db.setSub('author_key', ['email'], 'tutti@frutti.org');
|
||||
|
||||
// Delete a record
|
||||
await db.remove('record_key');
|
||||
```
|
||||
|
||||
For example, given the author record:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1}}
|
||||
```
|
||||
|
||||
calling `await db.setSub('author_key', ['email'], 'tutti@frutti.org')` yields:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1},"email":"tutti@frutti.org"}
|
||||
```
|
||||
|
||||
::: warning
|
||||
Keys are namespaced (for example `pad:<padId>`,
|
||||
`pad:<padId>:revs:<rev>`, `globalAuthor:<authorId>`). Prefer the high-level
|
||||
managers (`Pad.ts`, `AuthorManager.ts`, etc.) over direct `DB` access where one
|
||||
exists; reach for `DB` directly only for data your plugin owns, and use a key
|
||||
prefix unique to your plugin to avoid collisions.
|
||||
:::
|
||||
|
||||
## Adding a toolbar icon
|
||||
|
||||
Etherpad's toolbar icons come from the bundled `fontawesome-etherpad` icon
|
||||
font in `src/static/font/`. Toolbar buttons reference an icon by a
|
||||
`buttonicon-<name>` CSS class (see `src/node/utils/toolbar.ts`, which builds
|
||||
each button's class as `buttonicon buttonicon-<name>`), and those classes are
|
||||
defined in `src/static/css/pad/icons.css`. The font itself is generated with
|
||||
[Fontello](http://fontello.com) from `src/static/font/config.json` (whose
|
||||
`css_prefix_text` is `buttonicon-`).
|
||||
|
||||
To add a new icon:
|
||||
|
||||
1. Go to [Fontello](http://fontello.com) and import the existing
|
||||
`src/static/font/config.json` (Fontello's "import" loads the current icon
|
||||
set and pre-selects the icons it contains).
|
||||
2. Select the additional icon(s) you want, then click **Download webfont**.
|
||||
3. From the unzipped download, copy `config.json` and the
|
||||
`font/fontawesome-etherpad.*` files over the ones in `src/static/font/`.
|
||||
4. From the unzipped `css/fontawesome-etherpad.css`, copy the new
|
||||
`.buttonicon-<name>:before { content: '\\eXXX'; }` rules into
|
||||
`src/static/css/pad/icons.css`, replacing the existing block of icon rules.
|
||||
|
||||
The icon is then available wherever a `buttonicon-<name>` class can be used,
|
||||
including toolbar button definitions.
|
||||
|
|
@ -29,6 +29,37 @@ Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, t
|
|||
|
||||
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
|
||||
|
||||
### How `settings.json` and environment variables interact
|
||||
|
||||
This trips people up often enough that it's worth calling out explicitly (see [#7819](https://github.com/ether/etherpad/issues/7819)):
|
||||
|
||||
* `settings.json` inside the container is a **template** containing `${VAR:default}` placeholders.
|
||||
* Environment variable substitution happens at **load time, in memory only** — env vars never overwrite `settings.json` on disk.
|
||||
* `docker exec <container> cat /opt/etherpad-lite/settings.json` will therefore always show the *templated* file (e.g. `"port": "${PORT:9001}"`), regardless of what `PORT` is set to in your environment. The resolved value is what Etherpad uses at runtime; the file is unchanged.
|
||||
* The admin /settings page also reads this file directly, so the raw view shows placeholders too. The page now surfaces a banner and an "Effective" tab that displays the in-memory resolved values when placeholders are present.
|
||||
|
||||
### Persisting admin /settings edits across container recreates
|
||||
|
||||
`settings.json` lives in the container's writable layer by default. That means:
|
||||
|
||||
| Operation | Effect on `settings.json` |
|
||||
|------------------------------------------|------------------------------------------|
|
||||
| `docker restart` | Preserved (writable layer is reused) |
|
||||
| `docker compose restart` | Preserved |
|
||||
| `docker compose down && docker compose up` | **Reset** to the image template |
|
||||
| `docker compose pull && docker compose up` | **Reset** to the new image template |
|
||||
| Watchtower / image auto-update | **Reset** to the new image template |
|
||||
| `docker rm` + `docker run` | **Reset** to the image template |
|
||||
|
||||
If you intend to edit `settings.json` through the admin UI (rather than relying solely on env vars), mount the file from the host so edits survive container recreate:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./settings.json:/opt/etherpad-lite/settings.json
|
||||
```
|
||||
|
||||
(Bootstrap by copying `settings.json.docker` to `./settings.json` on the host before the first `up`.) The default compose example below ships this line commented out — uncomment it if you need persistent on-disk edits.
|
||||
|
||||
### Rebuilding including some plugins
|
||||
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
|
||||
The variable value has to be a space separated, double quoted list of plugin names (see examples).
|
||||
|
|
@ -81,10 +112,31 @@ The `settings.json.docker` available by default allows to control almost every s
|
|||
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
|
||||
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
|
||||
| `PORT` | port which etherpad should bind at | `9001` |
|
||||
| `PUBLIC_URL` | Canonical public origin of this instance, e.g. `https://pad.example.com` (no trailing slash, must include scheme). Used to build absolute URLs in link-preview meta tags. When `null`, falls back to the incoming request's protocol+Host. | `null` |
|
||||
| `ENABLE_DARK_MODE` | Respect the end user's browser dark-mode preference. When enabled this overrides the admin-configured skin variants and skin name for that user. | `true` |
|
||||
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
|
||||
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
|
||||
|
||||
|
||||
### Updates & privacy (offline / air-gapped)
|
||||
|
||||
Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
|
||||
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
|
||||
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
|
||||
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
|
||||
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
|
||||
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
|
||||
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
|
||||
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
|
||||
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
|
||||
|
||||
> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise.
|
||||
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
|
@ -179,6 +231,31 @@ For the editor container, you can also make it full width by adding `full-width-
|
|||
| `DISABLE_IP_LOGGING` | Privacy: disable IP logging | `false` |
|
||||
|
||||
|
||||
### Email (SMTP)
|
||||
|
||||
SMTP transport used by the self-updater and admin notifications. When `MAIL_HOST` is `null` (the default) Etherpad keeps log-only behaviour and sends no real mail; set `MAIL_HOST` and `MAIL_FROM` to send via nodemailer.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------- | ------------------------------------------------------------------------------------------- | ------- |
|
||||
| `MAIL_HOST` | SMTP server hostname. Leave `null` to keep log-only behaviour (no outbound mail). | `null` |
|
||||
| `MAIL_PORT` | SMTP server port. | `587` |
|
||||
| `MAIL_SECURE` | Use a secure (TLS) connection to the SMTP server. | `false` |
|
||||
| `MAIL_FROM` | The `From` address used on outbound mail. Required (together with `MAIL_HOST`) to send mail. | `null` |
|
||||
|
||||
|
||||
### Privacy banner
|
||||
|
||||
Optional privacy banner shown to users. See `settings.json.template` for full field docs.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PRIVACY_BANNER_ENABLED` | Show the privacy banner. | `false` |
|
||||
| `PRIVACY_BANNER_TITLE` | Banner title. | `Privacy notice` |
|
||||
| `PRIVACY_BANNER_BODY` | Banner body text. | `This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.` |
|
||||
| `PRIVACY_BANNER_LEARN_MORE_URL` | Optional URL for a "learn more" link in the banner. | `null` |
|
||||
| `PRIVACY_BANNER_DISMISSAL` | Banner dismissal behaviour, e.g. `dismissible`. | `dismissible` |
|
||||
|
||||
|
||||
### Advanced
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
|
@ -187,6 +264,10 @@ For the editor container, you can also make it full width by adding `full-width-
|
|||
| `COOKIE_SESSION_LIFETIME` | How long (ms) a user can be away before they must log in again. | `864000000` (10 days) |
|
||||
| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time. | `86400000` (1 day) |
|
||||
| `SHOW_SETTINGS_IN_ADMIN_PAGE` | hide/show the settings.json in admin page | `true` |
|
||||
| `AUTHENTICATION_METHOD` | Authentication method used by the server. Use `sso` for the built-in OpenID Connect provider, or `apikey` for the legacy API-key authentication system. | `sso` |
|
||||
| `ENABLE_METRICS` | Enable the Prometheus metrics endpoint used by monitoring plugins to collect metrics about Etherpad. Disable if you do not use any monitoring plugins. | `true` |
|
||||
| `ENABLE_PAD_WIDE_SETTINGS` | Enable creator-owned pad-wide settings and new-pad default seeding from My View. The pad creator gets a "Pad-wide Settings" section to set/enforce defaults; other users see only their own view options. Set to `false` for the legacy single-settings behavior. | `true` |
|
||||
| `GDPR_AUTHOR_ERASURE_ENABLED` | Enable the GDPR Art. 17 author anonymize/erasure REST endpoint and admin UI. Enable only when an operator process exists to authorise erasure requests. | `false` |
|
||||
| `TRUST_PROXY` | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done | `false` |
|
||||
| `IMPORT_MAX_FILE_SIZE` | maximum allowed file size when importing a pad, in bytes. | `52428800` (50 MB) |
|
||||
| `IMPORT_EXPORT_MAX_REQ_PER_IP` | maximum number of import/export calls per IP. | `10` |
|
||||
|
|
@ -208,7 +289,7 @@ For the editor container, you can also make it full width by adding `full-width-
|
|||
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
|
||||
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
|
||||
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
|
||||
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. | `50000` |
|
||||
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. Larger values allow bigger pastes. | `1000000` (1 MB) |
|
||||
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
|
||||
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
|
||||
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |
|
||||
|
|
@ -282,6 +363,13 @@ services:
|
|||
volumes:
|
||||
- plugins:/opt/etherpad-lite/src/plugin_packages
|
||||
- etherpad-var:/opt/etherpad-lite/var
|
||||
# OPTIONAL: persist admin /settings edits across container recreates.
|
||||
# Without this mount, settings.json lives in the image's writable
|
||||
# layer — `docker compose restart` preserves it, but `docker compose
|
||||
# down && up`, `pull`, or watchtower reverts it to the image
|
||||
# template. Uncomment if you intend to edit settings.json through
|
||||
# the /admin UI. See https://github.com/ether/etherpad/issues/7819.
|
||||
# - ./settings.json:/opt/etherpad-lite/settings.json
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
|
|
|
|||
204
doc/faq.md
Normal file
204
doc/faq.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# FAQ
|
||||
|
||||
This page answers common operational questions about running and maintaining
|
||||
an Etherpad instance. It collects material previously kept on the project wiki.
|
||||
|
||||
## How do I install Etherpad?
|
||||
|
||||
There are several supported ways to install Etherpad. Pick whichever suits your
|
||||
environment.
|
||||
|
||||
### Docker
|
||||
|
||||
The official image is published to Docker Hub (`etherpad/etherpad`) and to the
|
||||
GitHub Container Registry (`ghcr.io/ether/etherpad`) with identical tags.
|
||||
|
||||
```bash
|
||||
docker pull etherpad/etherpad
|
||||
docker run -p 9001:9001 etherpad/etherpad
|
||||
```
|
||||
|
||||
See the [Docker chapter](./docker.md) for building personalized images, enabling plugins, and
|
||||
configuring office-format import/export.
|
||||
|
||||
### One-line installer (macOS / Linux / WSL)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh | sh
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
|
||||
```
|
||||
|
||||
The installer clones Etherpad, installs dependencies and builds the frontend.
|
||||
Set `ETHERPAD_RUN=1` to also start it once the install finishes.
|
||||
|
||||
### apt repository (Debian / Ubuntu)
|
||||
|
||||
Etherpad publishes a signed APT repository (`stable` channel). Import the signing
|
||||
key, add the repository and install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://etherpad.org/key.asc \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] https://etherpad.org/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/etherpad.list
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install etherpad
|
||||
```
|
||||
|
||||
The repository provides `amd64` and `arm64` builds. Etherpad depends on
|
||||
Node.js >= 24, so on older distributions you may also need NodeSource's apt
|
||||
repository to satisfy that dependency.
|
||||
|
||||
### From source
|
||||
|
||||
Etherpad requires [Node.js](https://nodejs.org/) >= 24 and `pnpm`.
|
||||
|
||||
```bash
|
||||
git clone -b master https://github.com/ether/etherpad
|
||||
cd etherpad
|
||||
pnpm i
|
||||
pnpm run build:etherpad
|
||||
pnpm run prod
|
||||
```
|
||||
|
||||
Then open `http://localhost:9001`.
|
||||
|
||||
## What URL paths does Etherpad serve?
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/admin` | Administration dashboard (requires admin login). |
|
||||
| `/admin/plugins` | Install, update and remove plugins from the web UI. |
|
||||
| `/admin/settings` | Edit `settings.json` from the web UI. |
|
||||
| `/p/:padID` | Open (or create) the pad with the given `padID`, e.g. `/p/foo`. |
|
||||
| `/p/:padID/timeslider` | Open the pad's history/timeslider view. Append `#N` to jump to a specific revision, e.g. `/p/foo/timeslider#5`. |
|
||||
| `/p/:padID/export/:type` | Export the pad in the given format, e.g. `/p/foo/export/html`. Append `?revs=N` to export a specific revision. |
|
||||
|
||||
Supported export types:
|
||||
|
||||
- **Native (no extra dependencies):** `txt`, `html`, `etherpad`, `docx`, `pdf`.
|
||||
- **Via LibreOffice:** `odt`, `doc`, `rtf` — these require the `soffice` setting
|
||||
to point at a LibreOffice executable. See the office-format notes in the
|
||||
[Docker chapter](./docker.md).
|
||||
|
||||
## How do I list all pads?
|
||||
|
||||
The recommended way is the HTTP API method `listAllPads`, combined with `jq`:
|
||||
|
||||
```bash
|
||||
ETHERPAD_HOST='https://pad.example.com'
|
||||
ETHERPAD_API_KEY='...' # the APIKEY.txt file in the Etherpad root
|
||||
ETHERPAD_API_VERSION='...' # see https://pad.example.com/api
|
||||
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/listAllPads?apikey=${ETHERPAD_API_KEY}" \
|
||||
| jq -r '.data.padIDs[]'
|
||||
```
|
||||
|
||||
For an interactive list with management actions, install the `ep_adminpads2`
|
||||
plugin and browse to `/admin/pads`.
|
||||
|
||||
As a last resort you can query the database directly. The exact query depends on
|
||||
your configured backend; pad records use keys of the form `pad:<padID>` and
|
||||
`pad:<padID>:revs:<n>`. For example, with SQLite:
|
||||
|
||||
```bash
|
||||
sqlite3 ./var/sqlite.db "select key from store where key like 'pad:%'" \
|
||||
| grep -Eo '^pad:[^:]+' \
|
||||
| sed -e 's/pad://' \
|
||||
| sort -u
|
||||
```
|
||||
|
||||
Prefer the API or admin plugin over direct SQL: the schema is an implementation
|
||||
detail and may change.
|
||||
|
||||
## How do I delete or manage pads?
|
||||
|
||||
Use the HTTP API `deletePad` method:
|
||||
|
||||
```bash
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/deletePad?apikey=${ETHERPAD_API_KEY}&padID=foo"
|
||||
```
|
||||
|
||||
The API also offers `copyPad`, `movePad`, `getRevisionsCount` and more — see the
|
||||
[HTTP API chapter](./api/http_api.md).
|
||||
|
||||
For a web UI, install the `ep_adminpads2` plugin and manage pads from
|
||||
`/admin/pads`, where you can search, view and delete pads.
|
||||
|
||||
The `deletePad` CLI tool is also available for operators:
|
||||
|
||||
```bash
|
||||
pnpm run --filter bin deletePad <padID>
|
||||
```
|
||||
|
||||
## How do I back up and restore pads?
|
||||
|
||||
### Back up the whole instance
|
||||
|
||||
All pad data lives in the configured database. Back it up using the tool
|
||||
appropriate to your backend (for example `mysqldump` for MySQL/MariaDB,
|
||||
`pg_dump` for PostgreSQL, or a file copy of `var/*.db` for the file-based
|
||||
`dirty`/`rusty` engines while Etherpad is stopped). A regular, automated dump of
|
||||
the database is the canonical backup for a production instance.
|
||||
|
||||
### Back up a single pad
|
||||
|
||||
Export the pad over HTTP by appending `/export/<type>` to its URL. Plain text,
|
||||
HTML and the round-trippable `etherpad` format are most useful for backups:
|
||||
|
||||
```bash
|
||||
curl -o mypad.txt https://pad.example.com/p/foo/export/txt
|
||||
curl -o mypad.html https://pad.example.com/p/foo/export/html
|
||||
curl -o mypad.etherpad https://pad.example.com/p/foo/export/etherpad
|
||||
```
|
||||
|
||||
The `etherpad` export preserves the pad's full history and can be re-imported,
|
||||
making it the best choice for migrating or archiving an individual pad.
|
||||
|
||||
### Restore or inspect an old revision
|
||||
|
||||
Every state the pad has been in is stored in the database, so you can retrieve
|
||||
an earlier revision without a separate backup:
|
||||
|
||||
- Open `/p/:padID/timeslider` to browse the history and find the revision
|
||||
number you want.
|
||||
- Export a specific revision directly with the `?revs=N` query parameter, e.g.
|
||||
`https://pad.example.com/p/foo/export/html?revs=1000`.
|
||||
|
||||
### Repairing a damaged pad
|
||||
|
||||
If a pad is corrupt, use the CLI repair tools (`checkPad`, `repairPad`,
|
||||
`rebuildPad`) documented in the [CLI chapter](./cli.md). Always back up the database before
|
||||
running write operations.
|
||||
|
||||
## How do I limit history or prune revisions?
|
||||
|
||||
Etherpad keeps the full revision history of every pad, so the database grows
|
||||
over time. To reclaim space, use the pad-compaction CLI tools, which collapse or
|
||||
trim revision history for one pad, every pad, or only stale pads:
|
||||
|
||||
```bash
|
||||
# Collapse all history of one pad
|
||||
pnpm run --filter bin compactPad <padID>
|
||||
|
||||
# Keep only the last 50 revisions of one pad
|
||||
pnpm run --filter bin compactPad <padID> --keep 50
|
||||
|
||||
# Compact every pad on the instance
|
||||
pnpm run --filter bin compactAllPads
|
||||
|
||||
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions
|
||||
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
|
||||
```
|
||||
|
||||
These tools require `cleanup.enabled = true` in `settings.json` and are
|
||||
**destructive** — history is collapsed or trimmed. Export anything you can't
|
||||
afford to lose via the pad's `/export/etherpad` route first. The same primitive
|
||||
is available over the wire as the `compactPad` HTTP API method. See the [CLI chapter](./cli.md) for full details.
|
||||
|
|
@ -13,6 +13,9 @@ hero:
|
|||
- theme: brand
|
||||
text: Install
|
||||
link: /docker
|
||||
- theme: alt
|
||||
text: Configuration
|
||||
link: /configuration
|
||||
- theme: alt
|
||||
text: API documentation
|
||||
link: /api/
|
||||
|
|
|
|||
|
|
@ -58,8 +58,22 @@ alert ('Chat');
|
|||
```
|
||||
to:
|
||||
```js
|
||||
alert(window._('pad.chat'));
|
||||
alert(window.html10n.get('pad.chat'));
|
||||
```
|
||||
|
||||
> **Note:** Etherpad core sets up a `window._` gettext-like shortcut as
|
||||
> `window._ = html10n.get`. Because this assignment does **not** bind `this`,
|
||||
> calling the shortcut bare as `window._('pad.chat')` loses its `this` context
|
||||
> and returns `undefined` (the `get` implementation reads `this.translations`).
|
||||
> Prefer one of the patterns that actually works:
|
||||
>
|
||||
> * Call the method on the object directly: `window.html10n.get('pad.chat')`.
|
||||
> * Bind it once, then reuse: `const _ = window.html10n.get.bind(window.html10n);`
|
||||
> followed by `_('pad.chat')`.
|
||||
>
|
||||
> Wherever possible, prefer marking up your templates with `data-l10n-id`
|
||||
> attributes (see above) instead of translating strings in JavaScript at all —
|
||||
> html10n applies those automatically.
|
||||
### 2. Create translate files in the locales directory of your plugin
|
||||
|
||||
* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"oxc-minify": "^0.132.0",
|
||||
"vitepress": "^2.0.0-alpha.17"
|
||||
"oxc-minify": "^0.139.0",
|
||||
"vitepress": "^2.0.0-alpha.18"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
|
|
|
|||
|
|
@ -267,15 +267,16 @@ below.
|
|||
### Runtime flag
|
||||
|
||||
The passthrough is gated by `settings.enablePluginPadOptions`, default
|
||||
`false`. Operators must opt in via `settings.json`:
|
||||
`true`. Operators who want to lock plugins out of pad-wide state can flip
|
||||
it in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enablePluginPadOptions": true
|
||||
"enablePluginPadOptions": false
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, the server reflects the value to every client via
|
||||
When enabled (the default), the server reflects the value to every client via
|
||||
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
|
||||
(static) and *active* (per-pad request) at the same point.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ and the URL to use if you embed the timeslider in your own page.
|
|||
|
||||
You can choose a skin changing the parameter `skinName` in `settings.json`.
|
||||
|
||||
Since Etherpad **1.7.5**, two skins are included:
|
||||
Two skins are included:
|
||||
|
||||
* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as guidance to develop your own.
|
||||
* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.
|
||||
* `colibris`: the current default skin, used by Etherpad out of the box. This is what you see in a standard installation.
|
||||
* `no-skin`: an unstyled base skin that leaves the default Etherpad appearance unchanged. Use it as a starting point and guidance to develop your own skin.
|
||||
|
|
|
|||
157
doc/stats.md
157
doc/stats.md
|
|
@ -1,18 +1,147 @@
|
|||
# Statistics
|
||||
Etherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.
|
||||
# Statistics and metrics
|
||||
|
||||
We currently measure:
|
||||
Etherpad tracks runtime statistics about the edit machinery, the database
|
||||
layer, and the Node.js process, and can expose them over HTTP for monitoring.
|
||||
|
||||
- totalUsers (counter)
|
||||
- connects (meter)
|
||||
- disconnects (meter)
|
||||
- pendingEdits (counter)
|
||||
- edits (timer)
|
||||
- failedChangesets (meter)
|
||||
- httpRequests (timer)
|
||||
- http500 (meter)
|
||||
- memoryUsage (gauge)
|
||||
There are two endpoints:
|
||||
|
||||
Under the hood, we are happy to rely on [measured](https://github.com/felixge/node-measured) for all our metrics needs.
|
||||
- `GET /stats` — a JSON dump of the internal `measured-core` collection.
|
||||
- `GET /stats/prometheus` — the same kind of data (plus process/runtime
|
||||
metrics) in the [Prometheus](https://prometheus.io/) text exposition format.
|
||||
|
||||
To modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a [`measured.Collection`](https://yaorg.github.io/node-measured/packages/measured-core/Collection.html).
|
||||
## Enabling the endpoints
|
||||
|
||||
Both endpoints are gated behind the `enableMetrics` setting, which defaults to
|
||||
`true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enableMetrics": true
|
||||
}
|
||||
```
|
||||
|
||||
When `enableMetrics` is `false` the routes are **not registered at all** — a
|
||||
request to `/stats` or `/stats/prometheus` returns the normal 404 handling, not
|
||||
an empty response. The admin-panel statistics view is unaffected by this
|
||||
setting.
|
||||
|
||||
## `GET /stats` (JSON)
|
||||
|
||||
Returns the current snapshot of the `measured-core` collection as JSON. The
|
||||
following metrics are collected:
|
||||
|
||||
| Metric | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `totalUsers` | gauge | Number of users currently connected across all pads. |
|
||||
| `activePads` | gauge | Number of pads with at least one connected user. |
|
||||
| `connects` | meter | Rate of new client connections. |
|
||||
| `disconnects` | meter | Rate of client disconnections. |
|
||||
| `rateLimited` | meter | Rate of messages dropped by the per-connection rate limiter. |
|
||||
| `pendingEdits` | counter | Edits received but not yet fully processed. |
|
||||
| `edits` | timer | Time taken to process an incoming `USER_CHANGES` edit (full handler span). |
|
||||
| `failedChangesets` | meter | Rate of changesets that failed to apply. |
|
||||
| `httpRequests` | timer | Duration of HTTP requests served by Express. |
|
||||
| `http500` | meter | Rate of HTTP 500 responses. |
|
||||
| `memoryUsage` | gauge | Process resident set size (`process.memoryUsage().rss`). |
|
||||
| `memoryUsageHeap` | gauge | Process heap usage (`process.memoryUsage().heapUsed`). |
|
||||
| `lastDisconnect` | gauge | Timestamp (ms) of the most recent socket disconnect. |
|
||||
| `ueberdb_*` | gauge | One gauge per [ueberDB](https://github.com/ether/ueberDB) database statistic, e.g. read/write counts and timings (`ueberdb_reads`, `ueberdb_writes`, …). The exact set depends on the configured database driver. |
|
||||
|
||||
Under the hood these are provided by
|
||||
[`measured-core`](https://github.com/yaorg/node-measured/tree/master/packages/measured-core).
|
||||
To read or extend them from a plugin, require the shared collection:
|
||||
|
||||
```js
|
||||
const stats = require('ep_etherpad-lite/node/stats');
|
||||
// stats is a measured-core Collection
|
||||
stats.counter('my_plugin_events').inc();
|
||||
console.log(stats.toJSON());
|
||||
```
|
||||
|
||||
## `GET /stats/prometheus` (Prometheus exposition format)
|
||||
|
||||
Served from a dedicated [`prom-client`](https://github.com/siimon/prom-client)
|
||||
registry. This is the endpoint you point a Prometheus scraper at.
|
||||
|
||||
### Metrics exposed by default
|
||||
|
||||
| Metric | Type | Labels | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `etherpad_total_users` | gauge | — | Total number of connected users. |
|
||||
| `etherpad_active_pads` | gauge | — | Total number of active pads. |
|
||||
| `ueberdb_stats` | gauge | `type` | ueberDB statistics, one series per numeric ueberDB metric (the metric name is carried in the `type` label). |
|
||||
|
||||
In addition, the registry calls `prom-client`'s `collectDefaultMetrics()`, so
|
||||
the standard Node.js / process metrics are also exposed, including (names as
|
||||
emitted by `prom-client`):
|
||||
|
||||
- `process_cpu_user_seconds_total`, `process_cpu_system_seconds_total`,
|
||||
`process_cpu_seconds_total`
|
||||
- `process_resident_memory_bytes`, `process_heap_bytes`,
|
||||
`process_virtual_memory_bytes`
|
||||
- `process_open_fds`, `process_max_fds`
|
||||
- `process_start_time_seconds`
|
||||
- `nodejs_eventloop_lag_seconds` and the `nodejs_eventloop_lag_*` family
|
||||
- `nodejs_active_handles`, `nodejs_active_requests`, `nodejs_active_resources`
|
||||
(and their `_total` variants)
|
||||
- `nodejs_heap_size_total_bytes`, `nodejs_heap_size_used_bytes`,
|
||||
`nodejs_external_memory_bytes`, `nodejs_heap_space_size_*_bytes`
|
||||
- `nodejs_gc_duration_seconds`
|
||||
- `nodejs_version_info`
|
||||
|
||||
The exact default-metric set is determined by `prom-client` and the Node.js
|
||||
version, not by Etherpad.
|
||||
|
||||
### Opt-in scaling-dive metrics (`scalingDiveMetrics`)
|
||||
|
||||
A second, more detailed instrument set was added for the scaling investigation
|
||||
(PR #7756). It is gated behind the `scalingDiveMetrics` setting, which defaults
|
||||
to `false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scalingDiveMetrics": false
|
||||
}
|
||||
```
|
||||
|
||||
When the flag is off, these metrics are never registered and their recording
|
||||
helpers short-circuit to no-ops, so production deployments pay nothing for the
|
||||
instrumentation. When enabled, the following are added to the
|
||||
`/stats/prometheus` output:
|
||||
|
||||
| Metric | Type | Labels | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `etherpad_changeset_apply_duration_seconds` | histogram | — | Time spent applying an incoming `USER_CHANGES` message on the server (apply path only; excludes fan-out to other clients). Buckets: 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5 seconds. |
|
||||
| `etherpad_socket_emits_total` | counter | `type` | Number of socket.io broadcast emits, bucketed by message type. |
|
||||
| `etherpad_pad_users` | gauge | `padId` | Active users connected to each pad, keyed by pad id. |
|
||||
|
||||
**Cardinality caution.** The scaling-dive metrics carry high-cardinality
|
||||
labels:
|
||||
|
||||
- `etherpad_pad_users` adds one time series **per active pad** (`padId`
|
||||
label). On instances with many pads this can produce a large number of
|
||||
series; stale labels are reset on each scrape so drained pads drop out.
|
||||
- `etherpad_socket_emits_total` uses the `type` label. To keep cardinality
|
||||
bounded, only a fixed allowlist of known message types is reported; any
|
||||
other (or missing) type is rolled into a single `other` bucket, so a
|
||||
misbehaving plugin or API caller cannot explode the label space.
|
||||
|
||||
Enable `scalingDiveMetrics` for targeted load-testing or capacity
|
||||
investigations, not as a permanent production default.
|
||||
|
||||
## Scraping with Prometheus
|
||||
|
||||
Add a scrape job pointing at the `/stats/prometheus` endpoint, for example:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: etherpad
|
||||
metrics_path: /stats/prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9001']
|
||||
```
|
||||
|
||||
Make sure `enableMetrics` is `true` (the default) so the endpoint exists. If
|
||||
your instance is reachable from untrusted networks, restrict access to
|
||||
`/stats` and `/stats/prometheus` at your reverse proxy, since they expose
|
||||
operational details about the deployment.
|
||||
|
|
|
|||
|
|
@ -7,15 +7,25 @@ services:
|
|||
volumes:
|
||||
- plugins:/opt/etherpad-lite/src/plugin_packages
|
||||
- etherpad-var:/opt/etherpad-lite/var
|
||||
# OPTIONAL: persist admin /settings edits across container recreates.
|
||||
# Without this mount, settings.json lives in the image's writable
|
||||
# layer — `docker compose restart` preserves it, but `docker compose
|
||||
# down && up`, `pull`, or watchtower reverts it to the image
|
||||
# template. Uncomment if you intend to edit settings.json through
|
||||
# the /admin UI instead of (or in addition to) env vars. See
|
||||
# https://github.com/ether/etherpad/issues/7819.
|
||||
# - ./settings.json:/opt/etherpad-lite/settings.json
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
|
||||
# Required — set a strong value (e.g. in .env). No fallback, so misconfig
|
||||
# surfaces at `docker compose up` rather than at runtime.
|
||||
ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}"
|
||||
DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}
|
||||
DB_HOST: postgres
|
||||
DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
DB_TYPE: "postgres"
|
||||
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
|
|
@ -23,7 +33,9 @@ services:
|
|||
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
|
||||
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
|
||||
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}
|
||||
# Default off: only enable when actually behind a trusted reverse proxy
|
||||
# that sets the X-Forwarded-* headers.
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
|
||||
restart: always
|
||||
ports:
|
||||
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
|
||||
|
|
@ -32,7 +44,7 @@ services:
|
|||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
|
|
|
|||
|
|
@ -0,0 +1,633 @@
|
|||
# Downstream Client Compatibility Tests — Phase 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
|
||||
|
||||
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
|
||||
|
||||
**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
|
||||
|
||||
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
|
||||
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
|
||||
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
|
||||
- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
|
||||
- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
|
||||
- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
|
||||
- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
|
||||
- Modify `src/package.json` — add `vectors:gen` script.
|
||||
|
||||
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Golden-vector generator module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
|
||||
- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
|
||||
|
||||
- [ ] **Step 1: Write the generator module**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||
*
|
||||
* Each vector is a self-contained changeset application: given `initialAText`
|
||||
* and `pool`, applying `changeset` yields `resultAText`. Downstream clients
|
||||
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||
* exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
|
||||
*
|
||||
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||
* pnpm run vectors:gen
|
||||
*/
|
||||
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
|
||||
export type WireVector = {
|
||||
name: string;
|
||||
initialText: string;
|
||||
changeset: string;
|
||||
pool: ReturnType<AttributePool['toJsonable']>;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
const vector = (
|
||||
name: string,
|
||||
initialText: string,
|
||||
build: (pool: AttributePool) => string,
|
||||
): WireVector => {
|
||||
const pool = new AttributePool();
|
||||
const changeset = build(pool);
|
||||
Changeset.checkRep(changeset);
|
||||
return {
|
||||
name,
|
||||
initialText,
|
||||
changeset,
|
||||
pool: pool.toJsonable(),
|
||||
resultText: Changeset.applyToText(changeset, initialText),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVectors = (): WireVector[] => [
|
||||
vector('plain-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||
|
||||
vector('plain-delete', 'abcdef\n', () =>
|
||||
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||
|
||||
vector('formatted-insert', 'abc\n', (pool) =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||
|
||||
vector('multiline-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||
|
||||
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||
return Changeset.compose(cs1, cs2, pool);
|
||||
}),
|
||||
];
|
||||
|
||||
// CLI entry: write the canonical fixture to disk.
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `vectors:gen` script**
|
||||
|
||||
In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
|
||||
|
||||
```json
|
||||
"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
|
||||
git commit -m "test(downstream): add golden wire-vector generator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Commit the generated fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/fixtures/wire-vectors.json`
|
||||
|
||||
- [ ] **Step 1: Generate the fixture**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: `src/tests/fixtures/wire-vectors.json` written.
|
||||
|
||||
- [ ] **Step 2: Eyeball the fixture**
|
||||
|
||||
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/fixtures/wire-vectors.json
|
||||
git commit -m "test(downstream): add committed golden wire-vectors fixture"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fixture stability + self-consistency spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Guards the downstream wire-format contract:
|
||||
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||
* - every vector is internally consistent under core's own Changeset engine.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import {generateVectors} from './generate-vectors';
|
||||
|
||||
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
|
||||
describe(__filename, function () {
|
||||
it('committed fixture matches a fresh regeneration', function () {
|
||||
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const fresh = generateVectors();
|
||||
assert.deepEqual(committed, fresh,
|
||||
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||
});
|
||||
|
||||
it('every vector applies to its result under core Changeset', function () {
|
||||
for (const v of generateVectors()) {
|
||||
Changeset.checkRep(v.changeset);
|
||||
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||
`vector ${v.name} result mismatch`);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
|
||||
```
|
||||
Expected: 2 passing.
|
||||
|
||||
- [ ] **Step 3: Prove the guard bites (temporary edit)**
|
||||
|
||||
Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
|
||||
Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-vectors.ts
|
||||
git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Socket message-sequence spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
|
||||
|
||||
Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pins the socket.io message sequence + shapes that every realtime client
|
||||
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||
* A change here is a wire-protocol change that will break downstream clients.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let socket: any;
|
||||
let padId: string;
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
padId = common.randomString();
|
||||
const pad = await padManager.getPad(padId, 'init\n');
|
||||
await pad.setText('init\n');
|
||||
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||
socket = await common.connect(res);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (socket != null) socket.close();
|
||||
socket = null;
|
||||
});
|
||||
|
||||
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||
const {type, data} = await common.handshake(socket, padId);
|
||||
assert.equal(type, 'CLIENT_VARS');
|
||||
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||
assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
|
||||
});
|
||||
|
||||
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||
const {data: clientVars} = await common.handshake(socket, padId);
|
||||
const rev = clientVars.collab_client_vars.rev;
|
||||
const pool = new AttributePool();
|
||||
const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
|
||||
|
||||
const accepted = common.waitForSocketEvent(socket, 'message');
|
||||
socket.emit('message', {
|
||||
type: 'COLLABROOM',
|
||||
component: 'pad',
|
||||
data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
|
||||
});
|
||||
const msg: any = await accepted;
|
||||
assert.equal(msg.type, 'COLLABROOM');
|
||||
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
|
||||
assert.equal(msg.data.newRev, rev + 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
```
|
||||
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: HTTP API shape spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
|
||||
|
||||
Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||
* endpoints downstream clients call to create pads and round-trip text.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const apiKey = common.apiKey;
|
||||
const padId = common.randomString();
|
||||
const ep = (point: string, qs: string) =>
|
||||
`/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200);
|
||||
apiVersion = res.body.currentVersion;
|
||||
});
|
||||
|
||||
it('createPad returns the standard {code,message,data} envelope', async function () {
|
||||
const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||
assert.equal(res.body.code, 0);
|
||||
});
|
||||
|
||||
it('setText + getText round-trips text through the documented shape', async function () {
|
||||
await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
|
||||
const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.text, 'string');
|
||||
assert.equal(res.body.data.text, 'world\n');
|
||||
});
|
||||
|
||||
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||
const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.revisions, 'number');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the apiKey helper name**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
|
||||
```
|
||||
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
|
||||
```
|
||||
Expected: 3 passing.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-http-api.ts
|
||||
git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Client manifest
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/downstream/clients.json`
|
||||
|
||||
- [ ] **Step 1: Write the manifest**
|
||||
|
||||
Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "etherpad-pad",
|
||||
"repo": "https://github.com/ether/pad.git",
|
||||
"ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
|
||||
"kind": "rust",
|
||||
"enabled": false,
|
||||
"vectorTest": "cargo test --test vectors",
|
||||
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-cli-client",
|
||||
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||
"ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
|
||||
"kind": "node",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-desktop",
|
||||
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||
"ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
|
||||
"kind": "desktop",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate it is well-formed JSON**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
|
||||
```
|
||||
Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/downstream/clients.json
|
||||
git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Downstream-smoke workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/downstream-smoke.yml`
|
||||
|
||||
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
|
||||
|
||||
- [ ] **Step 1: Write the workflow**
|
||||
|
||||
Create `.github/workflows/downstream-smoke.yml`:
|
||||
|
||||
```yaml
|
||||
name: Downstream smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003
|
||||
env:
|
||||
APIKEY: downstream-smoke-key
|
||||
run: |
|
||||
mkdir -p var
|
||||
echo -n "$APIKEY" > APIKEY.txt
|
||||
PORT=9003 pnpm run prod &
|
||||
echo $! > /tmp/ep.pid
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:9003/api/ >/dev/null; then
|
||||
echo "up"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "server did not come up"; exit 1
|
||||
|
||||
- name: Self-check (boot + API roundtrip proves the harness)
|
||||
run: |
|
||||
K=downstream-smoke-key
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
run: |
|
||||
node -e '
|
||||
const fs=require("fs");
|
||||
const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
|
||||
if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
|
||||
fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
|
||||
'
|
||||
# Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
|
||||
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the boot command + port env**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
|
||||
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
|
||||
```
|
||||
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
|
||||
|
||||
- [ ] **Step 3: Lint the workflow YAML**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
|
||||
```
|
||||
Expected: `valid yaml`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/downstream-smoke.yml
|
||||
git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full backend-suite run + push
|
||||
|
||||
**Files:** none (verification + integration)
|
||||
|
||||
- [ ] **Step 1: Run the whole downstream spec group**
|
||||
|
||||
Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
|
||||
```bash
|
||||
cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
|
||||
```
|
||||
Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
|
||||
|
||||
- [ ] **Step 2: Confirm no regression in the fixture guard**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
|
||||
```
|
||||
Expected: exit 0 (regeneration is byte-identical to the committed fixture).
|
||||
|
||||
- [ ] **Step 3: Push the branch and open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/downstream-client-compat-tests
|
||||
gh pr create --base develop \
|
||||
--title "test: downstream client compatibility gate (Phase 1)" \
|
||||
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Watch CI**
|
||||
|
||||
Per the "check CI after PRs" rule, wait ~20s then:
|
||||
```bash
|
||||
gh pr checks --watch
|
||||
```
|
||||
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Layer A golden vectors → Tasks 1–3. ✅
|
||||
- Layer A socket sequence → Task 4. ✅
|
||||
- Layer A HTTP API shapes → Task 5. ✅
|
||||
- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
|
||||
- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
|
||||
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
|
||||
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
|
||||
|
||||
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
|
||||
|
||||
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
|
||||
|
|
@ -197,7 +197,7 @@ GET /admin/openapi.json (CORS: *)
|
|||
|
||||
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
|
||||
per the project's "new features behind a flag, off by default" policy
|
||||
(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off,
|
||||
(CONTRIBUTING.md, AGENTS.MD). When the flag is off,
|
||||
`expressPreSession` returns early and the route is dormant.
|
||||
|
||||
When enabled, the route registers in `expressPreSession`, which runs before
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
# Downstream Client Compatibility Tests — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorming complete)
|
||||
**Target repo:** `ether/etherpad` (core), branch `develop`
|
||||
**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
|
||||
|
||||
## Problem
|
||||
|
||||
The three downstream clients live in **separate repos** and consume core's wire
|
||||
protocols rather than importing core as a library:
|
||||
|
||||
- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
|
||||
and talks the socket.io `message` protocol. No test script today.
|
||||
- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
|
||||
decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
|
||||
- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
|
||||
|
||||
A core PR can change the **HTTP API**, the **socket.io handshake / `message`
|
||||
sequence**, or the **changeset / attribpool wire format** and break these clients
|
||||
**silently**, because their CI never runs against the new core. The goal: a PR
|
||||
against core `develop` (and friends) must detect downstream breakage before merge.
|
||||
|
||||
## Decisions (locked during brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
|
||||
| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
|
||||
| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
|
||||
| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
|
||||
| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
|
||||
|
||||
## Architecture
|
||||
|
||||
Two-layer compatibility gate on every core PR:
|
||||
|
||||
```
|
||||
core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
|
||||
│ • golden-vector assertions (changeset/attribpool roundtrip)
|
||||
│ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
|
||||
│ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
|
||||
│ • HTTP API shape snapshots
|
||||
│
|
||||
└─► Layer B: Downstream smoke (boots a real server, runs real clients)
|
||||
• build + boot Etherpad from the PR on :9003 with a known API key
|
||||
• healthcheck-poll until ready
|
||||
• matrix over manifest: clone client @ pinned ref, set up toolchain,
|
||||
inject core's freshly-generated vectors, run client `test:vectors`,
|
||||
run client smoke: connect → create/open pad → write text →
|
||||
read back via HTTP API → assert equality
|
||||
• tear down server by PID
|
||||
```
|
||||
|
||||
## Layer A — Contract tests (Phase 1, core)
|
||||
|
||||
### Golden vectors
|
||||
- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
|
||||
script, e.g. `pnpm run vectors:gen`).
|
||||
- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
|
||||
`{ name, initialAText, changeset, pool, resultAText }` covering the operation
|
||||
classes clients must decode: plain insert, delete, format/attrib op,
|
||||
multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
|
||||
- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
|
||||
assert it matches the committed fixture exactly (drift requires a deliberate
|
||||
commit, which is the signal a wire change happened).
|
||||
|
||||
### Socket message-sequence test
|
||||
- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
|
||||
against the in-process server, assert the handshake message sequence and the
|
||||
shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast
|
||||
`NEW_CHANGES`. Reuses the existing backend socket test helpers.
|
||||
|
||||
### HTTP API shape snapshots
|
||||
- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
|
||||
(keys / types, not volatile values) of the API endpoints clients call
|
||||
(`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
|
||||
|
||||
These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
|
||||
|
||||
## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
|
||||
|
||||
### Manifest
|
||||
`src/tests/downstream/clients.json`:
|
||||
```json
|
||||
[
|
||||
{ "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "kind": "desktop", "smokeCmd": "..." }
|
||||
]
|
||||
```
|
||||
Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
|
||||
cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
|
||||
authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
|
||||
Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
|
||||
a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
|
||||
their Phase-2 smoke lands — the workflow only runs what's registered.
|
||||
|
||||
### Workflow
|
||||
`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
|
||||
`schedule` against `develop`):
|
||||
1. Build core from the PR, install deps.
|
||||
2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
|
||||
3. Healthcheck-poll the server (bounded timeout) before proceeding.
|
||||
4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
|
||||
(node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
|
||||
`wire-vectors.json` into the client → run `test:vectors` → run smoke.
|
||||
5. Tear down: kill the recorded **PID** (never `pkill -f`).
|
||||
|
||||
### Per-client smoke (Phase 2)
|
||||
Minimal roundtrip exercising the real protocol end-to-end:
|
||||
- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
|
||||
the real tungstenite socket — connect, open pad, send a changeset, read back
|
||||
via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
|
||||
injected fixture.
|
||||
- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
|
||||
`node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
|
||||
using the client lib: connect → write → verify via HTTP `getText`.
|
||||
- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
|
||||
the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
|
||||
in desktop's own CI — it is **not** in the core gate. If Electron is
|
||||
unavoidable here, run under `xvfb-run`.
|
||||
|
||||
## Flakiness mitigations (because smoke runs on every PR)
|
||||
|
||||
- Healthcheck-poll-with-timeout before any client runs.
|
||||
- Bounded timeout + 1 retry per client smoke.
|
||||
- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
|
||||
- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
|
||||
- Pinned manifest refs isolate core CI from clients' own breakage.
|
||||
- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 1 (core only, lands first, immediately useful):** vector generator +
|
||||
`wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
|
||||
`clients.json` manifest. Harness proven with **one** reference client wired in
|
||||
(the Rust `etherpad-pad`, which already has test infra).
|
||||
- **Phase 2 (one client repo at a time):** order `etherpad-pad` →
|
||||
`etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's
|
||||
`test:vectors` + smoke and registers it in the core manifest.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing clients' existing full e2e suites (they stay in their own repos).
|
||||
- ep_kaput (excluded from all sweeps per standing instruction).
|
||||
- Changing the wire protocol itself — this work only *observes* it.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A core PR that alters changeset serialization, the socket message sequence, or a
|
||||
client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
|
||||
- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
|
||||
- Bumping a client's pinned ref is the only way a client's own changes affect core CI.
|
||||
|
|
@ -45,13 +45,13 @@
|
|||
"node": ">=24.0.0",
|
||||
"pnpm": ">=11.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
4276
pnpm-lock.yaml
generated
4276
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,20 +21,38 @@ strictDepBuilds: false
|
|||
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
|
||||
# is no longer read). Force-bump transitive deps with known CVEs.
|
||||
overrides:
|
||||
basic-ftp: '>=5.3.0'
|
||||
'@babel/core@<7.29.6': '>=7.29.6'
|
||||
'@opentelemetry/core@<2.8.0': '>=2.8.0'
|
||||
basic-ftp@<5.3.1: '>=5.3.1 <6.0.0'
|
||||
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
|
||||
diff@>=6.0.0 <8.0.3: '>=8.0.3'
|
||||
esbuild@<0.28.1: '>=0.28.1'
|
||||
flatted: '>=3.4.2'
|
||||
follow-redirects: '>=1.16.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
glob@>=10.2.0 <10.5.0: '>=10.5.0'
|
||||
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
|
||||
ip-address@<10.1.1: '>=10.1.1'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0'
|
||||
lodash: '>=4.18.0'
|
||||
minimatch@>=9.0.0 <9.0.7: '>=9.0.7'
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
qs@>=6.7.0 <6.14.2: '>=6.14.2'
|
||||
qs@>=6.7.0 <6.15.2: '>=6.15.2'
|
||||
serialize-javascript@<7.0.5: '>=7.0.5'
|
||||
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
|
||||
tar@<7.5.11: '>=7.5.11'
|
||||
tar@<7.5.16: '>=7.5.16'
|
||||
uuid@<14.0.0: '>=14.0.0'
|
||||
vite@>=7.0.0 <7.3.2: '>=7.3.2'
|
||||
ws@>=8.0.0 <8.21.0: '>=8.21.0'
|
||||
minimumReleaseAgeExclude:
|
||||
- '@radix-ui/primitive@1.1.5'
|
||||
- '@radix-ui/react-collection@1.1.12'
|
||||
- '@radix-ui/react-context@1.2.0'
|
||||
- '@radix-ui/react-dialog@1.1.19'
|
||||
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||
- '@radix-ui/react-focus-scope@1.1.12'
|
||||
- '@radix-ui/react-presence@1.1.7'
|
||||
- '@radix-ui/react-toast@1.2.19'
|
||||
- mysql2@3.22.6
|
||||
- oidc-provider@9.9.0
|
||||
- sql-escaper@1.4.0
|
||||
|
|
|
|||
|
|
@ -210,15 +210,17 @@
|
|||
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||
* Default "notify" shows a banner when an update is available.
|
||||
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
|
||||
* Air-gapped / offline deployments should set UPDATES_TIER=off to suppress the
|
||||
* periodic check against the GitHub Releases API entirely.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"tier": "${UPDATES_TIER:notify}",
|
||||
"source": "${UPDATES_SOURCE:github}",
|
||||
"channel": "${UPDATES_CHANNEL:stable}",
|
||||
"installMethod": "docker",
|
||||
"checkIntervalHours": 6,
|
||||
"githubRepo": "ether/etherpad",
|
||||
"requireAdminForStatus": false,
|
||||
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
|
||||
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
|
||||
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
|
||||
"preApplyGraceMinutes": 0,
|
||||
"drainSeconds": 60,
|
||||
"rollbackHealthCheckSeconds": 60,
|
||||
|
|
@ -321,7 +323,19 @@
|
|||
* https://etherpad.org/ep_infos
|
||||
*/
|
||||
|
||||
"updateServer": "https://etherpad.org/ep_infos",
|
||||
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
|
||||
|
||||
/*
|
||||
* Outbound network calls. See PRIVACY.md for what each one sends.
|
||||
* - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts)
|
||||
* - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser
|
||||
* (manual install-by-name via CLI still works)
|
||||
* Air-gapped / firewalled deployments should set both to false.
|
||||
*/
|
||||
"privacy": {
|
||||
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
|
||||
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
|
||||
},
|
||||
|
||||
/*
|
||||
* The type of the database.
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
* Default "notify" shows a banner when an update is available. "off" disables the version check.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"tier": "${UPDATES_TIER:notify}",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"installMethod": "auto",
|
||||
|
|
@ -443,17 +443,19 @@
|
|||
* https://etherpad.org/ep_infos
|
||||
*/
|
||||
|
||||
"updateServer": "https://etherpad.org/ep_infos",
|
||||
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
|
||||
|
||||
/*
|
||||
* Outbound network calls. See PRIVACY.md for what each one sends.
|
||||
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
|
||||
* - pluginCatalog=false: disables admin plugin browser
|
||||
* (manual install-by-name via CLI still works)
|
||||
* Air-gapped / firewalled deployments should set both PRIVACY_UPDATE_CHECK and
|
||||
* PRIVACY_PLUGIN_CATALOG to false (or UPDATES_TIER=off, which covers the version check).
|
||||
*/
|
||||
"privacy": {
|
||||
"updateCheck": true,
|
||||
"pluginCatalog": true
|
||||
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
|
||||
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
@ -853,11 +855,11 @@
|
|||
* accepting pad-wide values under plugin-namespaced keys matching
|
||||
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
|
||||
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
|
||||
* connected client just like native pad-wide toggles. Disabled by
|
||||
* default; flip to true once your plugins (e.g. ep_plugin_helpers'
|
||||
* padToggle) require it. See doc/plugins.md.
|
||||
* connected client just like native pad-wide toggles. Enabled by
|
||||
* default; set to false to lock plugins out of pad-wide state. See
|
||||
* doc/plugins.md.
|
||||
**/
|
||||
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:false}",
|
||||
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:true}",
|
||||
|
||||
/*
|
||||
* Optional privacy banner shown once the pad loads. Disabled by default.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"Haytham morsy",
|
||||
"Meno25",
|
||||
"Mido",
|
||||
"Mohammed Qays",
|
||||
"Shbib Al-Subaie",
|
||||
"Tala Ali",
|
||||
"Test Create account",
|
||||
|
|
@ -57,7 +58,7 @@
|
|||
"index.transferSessionNow": "نقل الجلسة الآن",
|
||||
"index.copyLink": "2. نسخ الرابط",
|
||||
"index.copyLinkDescription": "انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
|
||||
"index.copyLinkButton": "نسخ الرابط إلى الحافظة",
|
||||
"index.copyLinkButton": "نُسخت الوصلة إلى الحافظة",
|
||||
"index.transferToSystem": "3. نسخ الجلسة إلى النظام الجديد",
|
||||
"index.transferToSystemDescription": "افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
|
||||
"index.transferSessionDescription": "انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",
|
||||
|
|
@ -123,7 +124,7 @@
|
|||
"pad.importExport.exportword": "مايكروسوفت وورد",
|
||||
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
|
||||
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
|
||||
"pad.importExport.noConverter.innerHTML": "لا يمكنك الاستيراد إلا من تنسيقات النصوص العادية أو تنسيقات HTML. لمزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">تثبيت LibreOffice</a> .",
|
||||
"pad.importExport.noConverter.innerHTML": "يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
|
||||
"pad.modals.connected": "متصل.",
|
||||
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
|
||||
"pad.modals.forcereconnect": "فرض إعادة الاتصال",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
|
||||
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
|
||||
"pad.modals.initsocketfail.explanation": "Не атрымалася падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.explanation": "Не ўдалося падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
|
||||
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
|
||||
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
|
||||
|
|
@ -178,9 +178,9 @@
|
|||
"pad.impexp.importbutton": "Імпартаваць зараз",
|
||||
"pad.impexp.importing": "Імпартаваньне…",
|
||||
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
|
||||
"pad.impexp.convertFailed": "Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.",
|
||||
"pad.impexp.convertFailed": "Не ўдалося імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце рукамі.",
|
||||
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
|
||||
"pad.impexp.uploadFailed": "Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.uploadFailed": "Загрузка не ўдалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.importfailed": "Памылка імпарту",
|
||||
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
|
||||
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
||||
|
|
|
|||
|
|
@ -16,13 +16,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel Správce - Etherpad",
|
||||
"admin.loading": "Načítám…",
|
||||
"admin.loading_description": "Počkejte prosím, než se stránka načte.",
|
||||
"admin.toggle_sidebar": "Přepnout postranní panel",
|
||||
"admin.shout": "Komunikace",
|
||||
"admin_shout.online_one": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.online_other": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.sticky_toggle": "Změnit připevněnou zprávu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Uživatelské jméno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Přihlásit se",
|
||||
"admin_login.failed": "Přihlášení se nezdařilo",
|
||||
"admin_pads.all_pads": "Všechny Pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistit historii",
|
||||
"admin_pads.bulk.clear_selection": "Vymazat výběr",
|
||||
"admin_pads.bulk.delete": "Smazat",
|
||||
"admin_pads.cancel": "Zrušit",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revize",
|
||||
"admin_pads.col.users": "Uživatelé",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Vyberte název pro nový Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrdit nebo zrušit smazání Padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Smazat pad",
|
||||
"admin_pads.empty_never_edited": "prázdné · nikdy neupravené",
|
||||
"admin_pads.error_dialog_description": "Došlo k chybě.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktivní",
|
||||
"admin_pads.filter.all": "Vše",
|
||||
"admin_pads.filter.empty": "Prázdné",
|
||||
"admin_pads.filter.recent": "Tento týden",
|
||||
"admin_pads.filter.stale": "Zastaralé (>1 rok)",
|
||||
"admin_pads.open": "Otevřít",
|
||||
"admin_pads.pagination.next": "Další",
|
||||
"admin_pads.pagination.previous": "Předchozí",
|
||||
"admin_pads.refresh": "Obnovit",
|
||||
"admin_pads.relative.days": "před {{count}} dny",
|
||||
"admin_pads.relative.hours": "před {{count}}h",
|
||||
"admin_pads.relative.just_now": "právě teď",
|
||||
"admin_pads.relative.minutes": "před {{count}} minutami",
|
||||
"admin_pads.relative.months": "před {{count}} měsíci",
|
||||
"admin_pads.relative.weeks": "před {{count}} týdny",
|
||||
"admin_pads.relative.years": "před {{count}} lety",
|
||||
"admin_pads.revisions_count": "{{count}} revizí",
|
||||
"admin_pads.selected_count": "Vybráno {{count}}",
|
||||
"admin_pads.show": "Zobrazit",
|
||||
"admin_pads.sort.name": "Jméno (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Revize",
|
||||
"admin_pads.sort.user_count": "Uživatelé",
|
||||
"admin_pads.stats.across_pads": "napříč všemi pady",
|
||||
"admin_pads.stats.active_users": "Aktivní uživatelé",
|
||||
"admin_pads.stats.empty_pads": "Prázdné pady",
|
||||
"admin_pads.stats.last_activity": "Poslední aktivita:",
|
||||
"admin_pads.stats.no_active_users": "Žádní aktivní uživatelé",
|
||||
"admin_pads.stats.revisions_zero": "0 revizí",
|
||||
"admin_pads.stats.total": "Celkový počet padů",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuálně aktivních",
|
||||
"admin_pads.subtitle": "Přehled všech padů na této instanci Etherpadu. Vyhledat, vyčistit, otevřít.",
|
||||
"admin_plugins": "Správce zásuvných moodulů",
|
||||
"admin_plugins.available": "Dostupné zásuvné moduly",
|
||||
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
|
||||
"admin_plugins.available_fetching": "Načítání...",
|
||||
"admin_plugins.available_install.value": "Instalovat",
|
||||
"admin_plugins.available_search.placeholder": "Vyhledat zásuvné moduly k instalaci",
|
||||
"admin_plugins.check_updates": "Zkontrolovat aktualizace",
|
||||
"admin_plugins.core_count": "{{count}} jádro",
|
||||
"admin_plugins.catalog_disabled": "Katalog pluginů je zakázán vaším operátorem (privacy.pluginCatalog=false). Chcete-li plugin nainstalovat, spusťte příkaz `pnpm run plugins i ep_<name> ` ze serveru.",
|
||||
"admin_plugins.crumbs": "Pluginy",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypnuto:",
|
||||
"admin_plugins.disables.warning_title": "Tento plugin záměrně odstraňuje uvedené funkce Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba při načítání pluginů",
|
||||
"admin_plugins.install_error": "Instalace pluginu {{plugin}} se nezdařila: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nelze nainstalovat {{plugin}}: vyžaduje novější verzi Etherpadu. Prosím, aktualizujte Etherpad a zkuste to znovu.",
|
||||
"admin_plugins.installed": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
|
||||
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
|
||||
|
|
@ -30,23 +97,61 @@
|
|||
"admin_plugins.last-update": "Poslední aktualizace",
|
||||
"admin_plugins.name": "Název",
|
||||
"admin_plugins.page-title": "Správce zásuvných modulů - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Obnovit katalog",
|
||||
"admin_plugins.search_npm": "Hledat na npm",
|
||||
"admin_plugins.sort_ascending": "Seřadit vzestupně",
|
||||
"admin_plugins.sort_descending": "Seřadit sestupně",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizováno",
|
||||
"admin_plugins.sort.name": "Jméno (A–Z)",
|
||||
"admin_plugins.sort.version": "Verze",
|
||||
"admin_plugins.source": "Zdroj pluginu",
|
||||
"admin_plugins.subtitle": "Instalace, aktualizace a odebrání pluginů Etherpad. Změny vyžadují restart serveru.",
|
||||
"admin_plugins.tag_core": "Jádro",
|
||||
"admin_plugins.update_tooltip": "Aktualizovat",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizace",
|
||||
"admin_plugins.update_now": "Aktualizovat",
|
||||
"admin_plugins.version": "Verze",
|
||||
"admin_plugins_info": "Informace o řešení problému",
|
||||
"admin_plugins_info.bindings_label": "{{count}} vazeb",
|
||||
"admin_plugins_info.copy_diagnostics": "Diagnostika kopírování",
|
||||
"admin_plugins_info.copy_value": "Kopírovat {{label}}",
|
||||
"admin_plugins_info.git_sha": "SHA v Gitu",
|
||||
"admin_plugins_info.hooks": "Instalované hooks",
|
||||
"admin_plugins_info.hooks_client": "hooks na straně klienta",
|
||||
"admin_plugins_info.hooks_server": "hooks na straně serveru",
|
||||
"admin_plugins_info.parts": "Nainstalované součásti",
|
||||
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins_info.page-title": "Informace o zásuvných modulech - Etherpad",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuální",
|
||||
"admin_plugins_info.update_available": "Aktualizace k dispozici: {{version}}",
|
||||
"admin_plugins_info.version": "Verze Etherpad",
|
||||
"admin_plugins_info.version_latest": "Poslední dostupná verze",
|
||||
"admin_plugins_info.version_number": "Číslo verze",
|
||||
"admin_settings": "Nastavení",
|
||||
"admin_settings.create_pad": "Vytvořit pad",
|
||||
"admin_settings.current": "Aktuální konfugurace",
|
||||
"admin_settings.current_example-devel": "Příklad ukázkové vývojové šablony",
|
||||
"admin_settings.current_example-prod": "Příklad šablony nastavení výroby",
|
||||
"admin_settings.current_restart.value": "Restartovat Etherpad",
|
||||
"admin_settings.current_save.value": "Uložit nastavení",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Ověření JSON",
|
||||
"admin_settings.current_prettify.value": "Zkrášlit JSON",
|
||||
"admin_settings.toast.saved": "Nastavení bylo úspěšně uloženo.",
|
||||
"admin_settings.toast.save_failed": "Uložení se nezdařilo: soubor settings.json se nepodařilo zapsat.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: zkontrolujte čárky, závorky a uvozovky.",
|
||||
"admin_settings.toast.disconnected": "Nelze uložit: nejsem připojen k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nelze upravovat: nejprve opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Zkrášlováním odstraníte všechny komentáře. Pokračovat?",
|
||||
"admin_settings.mode.form": "Formulář",
|
||||
"admin_settings.mode.effective": "Efektivní",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazení hodnot, které Etherpad aktuálně používá, pouze pro čtení, po substituci proměnných prostředí. Tajné kódy jsou redigovány.",
|
||||
"admin_settings.mode.aria_label": "Režim editoru",
|
||||
"admin_settings.envvar_banner.title": "Tento soubor je šablona, nikoli živá konfigurace.",
|
||||
"admin_settings.page-title": "Nastavení - Etherpad",
|
||||
"index.newPad": "Založ nový Pad",
|
||||
"index.settings": "Nastavení",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"Metalhead64",
|
||||
"Mklehr",
|
||||
"Mukeber",
|
||||
"Nbux",
|
||||
"Nipsky",
|
||||
"Predatorix",
|
||||
"SamTV",
|
||||
|
|
@ -22,13 +23,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||
"admin.loading": "Lade …",
|
||||
"admin.loading_description": "Bitte warten, die Seite wird aktualisiert ...",
|
||||
"admin.toggle_sidebar": "Seitenleiste ein-/ausblenden",
|
||||
"admin.shout": "Kommunikation",
|
||||
"admin_shout.online_one": "Es ist derzeit {{count}} Benutzer online",
|
||||
"admin_shout.online_other": "Es sind aktuell {{count}} Benutzer online",
|
||||
"admin_shout.sticky_toggle": "Nachricht bleibt angeheftet",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Benutzername",
|
||||
"admin_login.password": "Passwort",
|
||||
"admin_login.submit": "Login",
|
||||
"admin_login.failed": "Login fehlgeschlagen.",
|
||||
"admin_pads.all_pads": "Alle Pads",
|
||||
"admin_pads.bulk.cleanup_history": "Verlauf löschen",
|
||||
"admin_pads.bulk.clear_selection": "Auswahl löschen",
|
||||
"admin_pads.bulk.delete": "Löschen",
|
||||
"admin_pads.cancel": "Abbrechen",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Bearbeitungen",
|
||||
"admin_pads.col.users": "Benutzer",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Wähle einen Namen für das neue Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Bestätigen oder stornieren Sie die Löschung des Pads.",
|
||||
"admin_pads.delete_pad_dialog_title": "Pad löschen",
|
||||
"admin_pads.empty_never_edited": "leer · nie bearbeitet",
|
||||
"admin_pads.error_dialog_description": "Ein Fehler ist aufgetreten",
|
||||
"admin_pads.error_prefix": "Fehler",
|
||||
"admin_pads.filter.active": "Aktiv",
|
||||
"admin_pads.filter.all": "Alle",
|
||||
"admin_pads.filter.empty": "Leer",
|
||||
"admin_pads.filter.recent": "Diese Woche",
|
||||
"admin_pads.filter.stale": "Veraltet (> 1 Jahr)",
|
||||
"admin_pads.open": "Offen",
|
||||
"admin_pads.pagination.next": "Weiter",
|
||||
"admin_pads.pagination.previous": "Zurück",
|
||||
"admin_pads.refresh": "Aktualisieren",
|
||||
"admin_pads.relative.days": "vor {{count}} Tag(en)",
|
||||
"admin_pads.relative.hours": "vor {{count}} Stunde(n)",
|
||||
"admin_pads.relative.just_now": "Soeben",
|
||||
"admin_pads.relative.minutes": "vor {{count}} Minute(n)",
|
||||
"admin_pads.relative.months": "vor {{count}} Monat(en)",
|
||||
"admin_pads.relative.weeks": "vor {{count}} Woche(n)",
|
||||
"admin_pads.relative.years": "vor {{count}} Jahr(en)",
|
||||
"admin_pads.revisions_count": "{{count}} Bearbeitungen",
|
||||
"admin_pads.selected_count": "{{count}} ausgewählt",
|
||||
"admin_pads.show": "Anzeigen",
|
||||
"admin_pads.sort.name": "Name (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Bearbeitungen",
|
||||
"admin_pads.sort.user_count": "Nutzer",
|
||||
"admin_pads.stats.across_pads": "über alle Pads",
|
||||
"admin_pads.stats.active_users": "Aktive Nutzer",
|
||||
"admin_pads.stats.empty_pads": "Leere Pads",
|
||||
"admin_pads.stats.last_activity": "Letzte Aktivität",
|
||||
"admin_pads.stats.no_active_users": "Keine aktiven Benutzer",
|
||||
"admin_pads.stats.revisions_zero": "0 Bearbeitungen",
|
||||
"admin_pads.stats.total": "Gesamtanzahl Pads",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuell aktiv",
|
||||
"admin_pads.subtitle": "Überblick über alle Pads auf dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.",
|
||||
"admin_plugins": "Pluginverwaltung",
|
||||
"admin_plugins.available": "Verfügbare Plugins",
|
||||
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
||||
"admin_plugins.available_fetching": "Wird abgerufen...",
|
||||
"admin_plugins.available_install.value": "Installieren",
|
||||
"admin_plugins.available_search.placeholder": "Suche nach Plugins zum Installieren",
|
||||
"admin_plugins.check_updates": "Nach Updates suchen",
|
||||
"admin_plugins.core_count": "{{count}} Kern(e)",
|
||||
"admin_plugins.catalog_disabled": "Plugin-Katalog wurde vom Betreiber deaktiviert (privacy.pluginCatalog=false). Um ein Plugin zu installieren, führen Sie `pnpm run plugins i ep_<name>` auf dem Server aus.",
|
||||
"admin_plugins.crumbs": "Plugins",
|
||||
"admin_plugins.description": "Beschreibung",
|
||||
"admin_plugins.disables.label": "Deaktiviert:",
|
||||
"admin_plugins.disables.warning_title": "Dieses Plugin entfernt absichtlich die aufgeführten Etherpad-Funktionen.",
|
||||
"admin_plugins.error_retrieving": "Fehler beim Abrufen von Plugins",
|
||||
"admin_plugins.install_error": "Installation von {{plugin}} fehlgeschlagen: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Installation von {{plugin}} nicht möglich: Hierfür ist neuere Version von Etherpad notwendig. Bitte führe ein Upgrade von Etherpad durch und versuche es erneut.",
|
||||
"admin_plugins.installed": "Installierte Plugins",
|
||||
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
|
||||
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
|
||||
|
|
@ -36,23 +104,67 @@
|
|||
"admin_plugins.last-update": "Letze Aktualisierung",
|
||||
"admin_plugins.name": "Name",
|
||||
"admin_plugins.page-title": "Plugin Manager - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Katalog neu laden",
|
||||
"admin_plugins.search_npm": "Suche auf npm",
|
||||
"admin_plugins.sort_ascending": "Aufsteigend sortieren",
|
||||
"admin_plugins.sort_descending": "Absteigend sortieren",
|
||||
"admin_plugins.sort.last_updated": "Zuletzt aktualisiert",
|
||||
"admin_plugins.sort.name": "Name (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Plugin-Quelle",
|
||||
"admin_plugins.subtitle": "Installieren, aktualisieren und entfernen Sie Etherpad-Plugins. Änderungen erfordern einen Server-Neustart.",
|
||||
"admin_plugins.tag_core": "Kern",
|
||||
"admin_plugins.update_tooltip": "Update",
|
||||
"admin_plugins.updates_available": "Updates verfügbar",
|
||||
"admin_plugins.update_now": "Update",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Hilfestellung",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopiere Diagnose-Daten",
|
||||
"admin_plugins_info.copy_value": "Kopiere {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Hook-Bindungen",
|
||||
"admin_plugins_info.hooks": "Installierte Hooks",
|
||||
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
|
||||
"admin_plugins_info.hooks_server": "Server-seitige Hooks",
|
||||
"admin_plugins_info.no_hooks": "Keine Hooks gefunden",
|
||||
"admin_plugins_info.parts": "Installierte Teile",
|
||||
"admin_plugins_info.plugins": "Installierte Plugins",
|
||||
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Suche Hook oder Teil…",
|
||||
"admin_plugins_info.subtitle": "Systemdiagnose: installierte Version, registrierte Teile und Hooks.",
|
||||
"admin_plugins_info.tab_client": "Client",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuell",
|
||||
"admin_plugins_info.update_available": "Update verfügbar: {{version}}",
|
||||
"admin_plugins_info.version": "Etherpad Version",
|
||||
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
||||
"admin_plugins_info.version_number": "Versionsnummer",
|
||||
"admin_settings": "Einstellungen",
|
||||
"admin_settings.create_pad": "Pad erstellen",
|
||||
"admin_settings.current": "Derzeitige Konfiguration",
|
||||
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
||||
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
||||
"admin_settings.current_restart.value": "Etherpad neustarten",
|
||||
"admin_settings.current_save.value": "Einstellungen speichern",
|
||||
"admin_settings.invalid_json": "Ungültiges JSON",
|
||||
"admin_settings.current_test.value": "Validiere JSON",
|
||||
"admin_settings.current_prettify.value": "Prettify JSON",
|
||||
"admin_settings.toast.saved": "Einstellungen erfolgreich gespeichert.",
|
||||
"admin_settings.toast.save_failed": "Speichern fehlgeschlagen: settings.json konnte nicht geschrieben werden.",
|
||||
"admin_settings.toast.json_invalid": "Syntaxfehler: Überprüfen Sie Kommas, Klammern und Anführungszeichen.",
|
||||
"admin_settings.toast.disconnected": "Nicht gespeichert: Nicht mit dem Server verbunden.",
|
||||
"admin_settings.toast.validation_ok": "JSON ist gültig.",
|
||||
"admin_settings.toast.validation_failed": "JSON ist ungültig: Bitte beheben Sie Syntaxfehler.",
|
||||
"admin_settings.toast.prettify_failed": "Prettify nicht möglich: Bitte beheben Sie zunächst Syntaxfehler.",
|
||||
"admin_settings.prettify_confirm": "Prettify entfernt alle Kommentare. Weiter?",
|
||||
"admin_settings.mode.form": "Formular",
|
||||
"admin_settings.mode.effective_tooltip": "Read-only-Ansicht der Werte, die Etherpad tatsächlich verwendet, nach Umgebungsvariablen-Ersetzung. Secrets sind geschwärzt.",
|
||||
"admin_settings.mode.aria_label": "Editor-Modus",
|
||||
"admin_settings.envvar_banner.title": "Diese Datei ist eine Vorlage, nicht die Live-Konfiguration.",
|
||||
"admin_settings.envvar_banner.body": "Platzhalter wie ${VAR:default} werden beim Start in den Speicher eingesetzt; sie werden nie in diese Datei zurückgeschrieben. Bearbeiten Sie env vars in Ihrer Umgebung (Docker compose, systemd, .env), um den aufgelösten Wert zu ändern, oder ersetzen Sie den Platzhalter hier durch ein Literal. Wechseln Sie auf die Registerkarte \"Effective\", um zu sehen, was Etherpad gerade verwendet.",
|
||||
"admin_settings.toast.auth_error": "Sie sind nicht als Administrator authentifiziert. Bitte melden Sie sich erneut an.",
|
||||
"admin_settings.section.general": "Allgemein",
|
||||
"admin_settings.parse_error.title": "Parsen von settings.json nicht möglich",
|
||||
"admin_settings.page-title": "Einstellungen - Etherpad",
|
||||
"index.newPad": "Neues Pad",
|
||||
"index.settings": "Einstellungen",
|
||||
|
|
@ -75,7 +187,7 @@
|
|||
"index.labelPad": "Padname (optional)",
|
||||
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
|
||||
"index.createAndShareDocuments": "Erstelle und teile Dokumente in Echtzeit",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Deinem Browser läuft.",
|
||||
"pad.toolbar.bold.title": "Fett (Strg-B)",
|
||||
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
|
||||
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
|
||||
|
|
@ -99,18 +211,31 @@
|
|||
"pad.loading": "Laden …",
|
||||
"pad.noCookie": "Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
|
||||
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
|
||||
"pad.settings.padSettings": "Pad-Einstellungen",
|
||||
"pad.settings.title": "Einstellungen",
|
||||
"pad.settings.padSettings": "Pad-weite Einstellungen",
|
||||
"pad.settings.userSettings": "Nutzereinstellungen",
|
||||
"pad.settings.myView": "Eigene Ansicht",
|
||||
"pad.settings.disablechat": "Chat deaktivieren",
|
||||
"pad.settings.darkMode": "Dunkler Modus",
|
||||
"pad.settings.stickychat": "Chat immer anzeigen",
|
||||
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
|
||||
"pad.settings.colorcheck": "Autorenfarben anzeigen",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Farben inaktiver Autoren ausblenden",
|
||||
"pad.settings.linenocheck": "Zeilennummern",
|
||||
"pad.settings.rtlcheck": "Inhalt von rechts nach links lesen?",
|
||||
"pad.settings.enforcedNotice": "Diese Einstellungen wurden vom Ersteller des Pads gesperrt. Wenden Sie sich an den Ersteller, wenn Sie diese ändern möchten.",
|
||||
"pad.settings.fontType": "Schriftart:",
|
||||
"pad.settings.fontType.normal": "Normal",
|
||||
"pad.settings.language": "Sprache:",
|
||||
"pad.settings.deletePad": "Pad löschen",
|
||||
"pad.delete.confirm": "Möchtest du dieses Pad wirklich löschen?",
|
||||
"pad.deletionToken.modalTitle": "Speichere deinen Pad-Löschtoken.",
|
||||
"pad.deletionToken.modalBody": "Dieses Token ist die einzige Möglichkeit, dieses Pad zu löschen, falls Deine Browsersitzung unterbrochen wird oder Du das Gerät wechselst. Speichere es an einem sicheren Ort – es wird hier nur einmal angezeigt.",
|
||||
"pad.deletionToken.deleteWithToken": "Pad mit Token löschen",
|
||||
"pad.deletionToken.tokenFieldLabel": "Pad-Löschtoken",
|
||||
"pad.deletionToken.tokenValueLabel": "Dein Pad-Löschtoken (schreibgeschützt)",
|
||||
"pad.deletionToken.invalid": "Das angegebene Token ist für dieses Pad nicht gültig.",
|
||||
"pad.deletionToken.notCreator": "Sie sind nicht der Ersteller dieses Pads, daher können Sie es nicht löschen.",
|
||||
"pad.settings.about": "Über",
|
||||
"pad.settings.poweredBy": "Betrieben von",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -123,7 +248,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Export im Etherpad-Format",
|
||||
"pad.importExport.exporthtmla.title": "Exportieren als HTML",
|
||||
"pad.importExport.exportplaina.title": "Export als einfacher Text",
|
||||
"pad.importExport.exportworda.title": "Exportieren als Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Als PDF exportieren",
|
||||
"pad.importExport.exportopena.title": "Export als ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.",
|
||||
"pad.modals.connected": "Verbunden.",
|
||||
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
|
||||
"pad.modals.forcereconnect": "Erneutes Verbinden erzwingen",
|
||||
|
|
@ -151,9 +282,11 @@
|
|||
"pad.modals.rateLimited.explanation": "Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
|
||||
"pad.modals.rejected.explanation": "Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
|
||||
"pad.modals.rejected.cause": "Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
|
||||
"pad.modals.disconnected": "Ihre Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected": "Deine Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected.explanation": "Die Verbindung zum Server wurde unterbrochen.",
|
||||
"pad.modals.disconnected.cause": "Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.",
|
||||
"pad.gritter.unacceptedCommit.title": "Nicht gespeicherte Bearbeitung",
|
||||
"pad.gritter.unacceptedCommit.text": "Deine letzte Änderung wurde noch nicht gespeichert. Stelle die Verbindung wieder her und versuche es erneut.",
|
||||
"pad.share": "Dieses Pad teilen",
|
||||
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
|
||||
"pad.share.link": "Verknüpfung",
|
||||
|
|
@ -166,12 +299,29 @@
|
|||
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
||||
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
|
||||
"pad.historyMode.banner": "Versionsgeschichte ansehen",
|
||||
"pad.historyMode.return": "Zurück zum Live-Modus",
|
||||
"pad.historyMode.revisionLabel": "Revision {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Steuerung Pad-Verlauf",
|
||||
"pad.historyMode.sliderLabel": "Pad-Version",
|
||||
"pad.historyMode.settings.title": "Wiedergabe des Bearbeitungsverlaufs",
|
||||
"pad.historyMode.settings.follow": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"pad.historyMode.settings.followShort": "Folgen",
|
||||
"pad.historyMode.followOn": "Pad-Änderungen verfolgen – klicken Sie hier, um die Verfolgung zu beenden.",
|
||||
"pad.historyMode.followOff": "Änderungen der Pads werden nicht verfolgt – zum Folgen klicken",
|
||||
"pad.historyMode.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat mit Stand von {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autoren bei dieser Revision",
|
||||
"pad.editor.keyboardHint": "Drücken Sie die Escape-Taste, um den Editor zu verlassen. Drücken Sie Alt+F9, um die Symbolleiste aufzurufen.",
|
||||
"pad.editor.toolbar.formatting": "Formatierungsleiste",
|
||||
"pad.editor.toolbar.showMore": "Weitere Schaltflächen anzeigen",
|
||||
"timeslider.toolbar.authors": "Autoren:",
|
||||
"timeslider.toolbar.authorsList": "Keine Autoren",
|
||||
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
||||
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
||||
"timeslider.version": "Version {{version}}",
|
||||
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
|
||||
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
|
||||
"timeslider.forwardRevision": "Eine Version in diesem Pad vorwärtsgehen",
|
||||
|
|
@ -193,6 +343,7 @@
|
|||
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
|
||||
"pad.userlist.entername": "Dein Name?",
|
||||
"pad.userlist.unnamed": "unbenannt",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} verbundener Benutzer, other: {{count}} verbundene Benutzer ]}",
|
||||
"pad.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
|
||||
"pad.impexp.importbutton": "Jetzt importieren",
|
||||
"pad.impexp.importing": "Importiere …",
|
||||
|
|
@ -203,5 +354,6 @@
|
|||
"pad.impexp.importfailed": "Import fehlgeschlagen",
|
||||
"pad.impexp.copypaste": "Bitte kopieren und einfügen",
|
||||
"pad.impexp.exportdisabled": "Der Export im {{type}}-Format ist deaktiviert. Für Einzelheiten kontaktiere bitte deinen Systemadministrator.",
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen."
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen.",
|
||||
"pad.social.description": "Ein kollaboratives Dokument, das jeder in Echtzeit bearbeiten kann."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Panoyê İdarekari - Etherpad",
|
||||
"admin_pads.error_dialog_description": "Yew xeta biye.",
|
||||
"admin_plugins": "Gıredayışê raverberi",
|
||||
"admin_plugins.available": "Mewcud Dekerdeki",
|
||||
"admin_plugins.available_not-found": "Dekerdek nevineya",
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
"admin_settings.current_save.value": "Eyaran qeyd ke",
|
||||
"admin_settings.page-title": "Eyari - Etherpad",
|
||||
"index.newPad": "Bloknoto newe",
|
||||
"index.code": "Kod",
|
||||
"index.createOpenPad": "ya zi be nê nameyi ra yew bloknot vıraze/ake:",
|
||||
"index.openPad": "yew Padê biyayeyi be nê nameyi ra ake:",
|
||||
"pad.toolbar.bold.title": "Qalınd (Ctrl-B)",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||
"admin.loading": "Loading…",
|
||||
"admin.loading_description": "Please wait while the page is loading.",
|
||||
"admin.toggle_sidebar": "Toggle sidebar",
|
||||
"admin.shout": "Communication",
|
||||
"admin_shout.online_one": "There is currently {{count}} user online",
|
||||
|
|
@ -20,7 +21,11 @@
|
|||
"admin_pads.col.revisions": "Revisions",
|
||||
"admin_pads.col.users": "Users",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Choose a name for the new pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirm or cancel pad deletion.",
|
||||
"admin_pads.delete_pad_dialog_title": "Delete pad",
|
||||
"admin_pads.empty_never_edited": "empty · never edited",
|
||||
"admin_pads.error_dialog_description": "An error has occurred.",
|
||||
"admin_pads.error_prefix": "Error",
|
||||
"admin_pads.filter.active": "Active",
|
||||
"admin_pads.filter.all": "All",
|
||||
|
|
@ -132,7 +137,12 @@
|
|||
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
|
||||
"admin_settings.mode.form": "Form",
|
||||
"admin_settings.mode.raw": "Raw",
|
||||
"admin_settings.mode.effective": "Effective",
|
||||
"admin_settings.mode.effective_tooltip": "Read-only view of the values Etherpad is actually using right now, after environment-variable substitution. Secrets are redacted.",
|
||||
"admin_settings.mode.aria_label": "Editor mode",
|
||||
"admin_settings.envvar_banner.title": "This file is a template, not the live config.",
|
||||
"admin_settings.envvar_banner.body": "Placeholders like ${VAR:default} are substituted into memory at startup; they are never written back to this file. Edit env vars in your environment (Docker compose, systemd, .env) to change the resolved value, or replace the placeholder here with a literal. Switch to the Effective tab to see what Etherpad is using right now.",
|
||||
"admin_settings.toast.auth_error": "You are not authenticated as admin. Please log in again.",
|
||||
"admin_settings.section.general": "General",
|
||||
"admin_settings.parse_error.title": "Cannot parse settings.json",
|
||||
"admin_settings.parse_error.cta": "Switch to raw to edit",
|
||||
|
|
@ -217,6 +227,7 @@
|
|||
"index.copyLinkButton": "Copy link to clipboard",
|
||||
"index.transferToSystem": "3. Copy session to new system",
|
||||
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
|
||||
"index.code": "Code",
|
||||
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
|
||||
"index.createOpenPad": "Open pad by name",
|
||||
"index.openPad": "open an existing Pad with the name:",
|
||||
|
|
@ -303,7 +314,7 @@
|
|||
"pad.importExport.exportworda.title": "Export as Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Export as PDF",
|
||||
"pad.importExport.exportopena.title": "Export as ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "You can only import from plain text or HTML formats. For more advanced import features, please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install LibreOffice</a>.",
|
||||
"pad.importExport.noConverter.innerHTML": "You can import plain text, HTML, Microsoft Word (.docx) and Etherpad files directly. To import other formats such as PDF, ODT, DOC or RTF, the server administrator needs to install LibreOffice — see the <a href=\"https://docs.etherpad.org/\">Etherpad documentation</a>.",
|
||||
|
||||
"pad.modals.connected": "Connected.",
|
||||
"pad.modals.reconnecting": "Reconnecting to your pad…",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,27 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
|
||||
"admin.loading": "Ladataan…",
|
||||
"admin.loading_description": "Odota. Sivu latautuu.",
|
||||
"admin.toggle_sidebar": "Näytä/piilota sivupalkki",
|
||||
"admin.shout": "Viestintä",
|
||||
"admin_shout.online_one": "Tällä hetkellä {{count}} käyttäjä on paikalla",
|
||||
"admin_shout.online_other": "Tällä hetkellä {{count}} käyttäjää on paikalla",
|
||||
"admin_shout.sticky_toggle": "Muuta pysyvää viestiä",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Käyttäjänimi",
|
||||
"admin_login.password": "Salasana",
|
||||
"admin_login.submit": "Kirjaudu sisään",
|
||||
"admin_login.failed": "Kirjautuminen epäonnistui",
|
||||
"admin_pads.all_pads": "Kaikki muistiot",
|
||||
"admin_pads.bulk.cleanup_history": "Tyhjennä historia",
|
||||
"admin_pads.bulk.delete": "Poista",
|
||||
"admin_pads.cancel": "Peru",
|
||||
"admin_pads.col.pad": "Muistio",
|
||||
"admin_pads.col.revisions": "Muokkaushistoria",
|
||||
"admin_pads.col.users": "Käyttäjät",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Valitse nimi uudelle muistiolle.",
|
||||
"admin_plugins": "Lisäosien hallinta",
|
||||
"admin_plugins.available": "Saatavilla olevat liitännäiset",
|
||||
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
"Chpol",
|
||||
"Cquoi",
|
||||
"Crochet.david",
|
||||
"Crowwhailord",
|
||||
"Derugon",
|
||||
"Envlh",
|
||||
"Framafan",
|
||||
"Framasky",
|
||||
"Fylip22",
|
||||
"Gomoko",
|
||||
"Goofy",
|
||||
|
|
@ -37,13 +39,73 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Tableau de bord administrateur — Etherpad",
|
||||
"admin.loading": "Chargement en cours…",
|
||||
"admin.loading_description": "Veuillez patienter pendant le chargement de la page.",
|
||||
"admin.toggle_sidebar": "Afficher / masquer la barre latérale",
|
||||
"admin.shout": "Communication",
|
||||
"admin_shout.online_one": "Il y a actuellement {{count}} utilisateur·ice en ligne",
|
||||
"admin_shout.online_other": "Il y a actuellement {{count}} utilisateur·ices en ligne",
|
||||
"admin_shout.sticky_toggle": "Modifier le message épinglé",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Nom d’utilisateur",
|
||||
"admin_login.password": "Mot de passe",
|
||||
"admin_login.submit": "Connexion",
|
||||
"admin_login.failed": "Échec de la connexion",
|
||||
"admin_pads.all_pads": "Tous les blocs-notes",
|
||||
"admin_pads.bulk.cleanup_history": "Effacer l’historique",
|
||||
"admin_pads.bulk.clear_selection": "Effacer la sélection",
|
||||
"admin_pads.bulk.delete": "Supprimer",
|
||||
"admin_pads.cancel": "Annuler",
|
||||
"admin_pads.col.pad": "Bloc-notes",
|
||||
"admin_pads.col.revisions": "Révisions",
|
||||
"admin_pads.col.users": "Utilisateur·ices",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Choisissez un nom pour le nouveau bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirmer ou annuler la suppression du bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_title": "Supprimer le bloc-notes",
|
||||
"admin_pads.empty_never_edited": "vide · jamais modifié",
|
||||
"admin_pads.error_dialog_description": "Une erreur s’est produite.",
|
||||
"admin_pads.error_prefix": "Erreur",
|
||||
"admin_pads.filter.active": "Actif",
|
||||
"admin_pads.filter.all": "Tous",
|
||||
"admin_pads.filter.empty": "Vide",
|
||||
"admin_pads.filter.recent": "Cette semaine",
|
||||
"admin_pads.filter.stale": "Sans modifications (> 1 an)",
|
||||
"admin_pads.open": "Ouvert",
|
||||
"admin_pads.pagination.next": "Suivant",
|
||||
"admin_pads.pagination.previous": "Précédent",
|
||||
"admin_pads.refresh": "Actualiser",
|
||||
"admin_pads.relative.days": "il y a {{count}} jours",
|
||||
"admin_pads.relative.hours": "il y a {{count}} heures",
|
||||
"admin_pads.relative.just_now": "à l’instant",
|
||||
"admin_pads.show": "Afficher",
|
||||
"admin_pads.sort.name": "Nom (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Révisions",
|
||||
"admin_pads.sort.user_count": "Utilisateur·ices",
|
||||
"admin_pads.stats.across_pads": "sur tous les bloc-notes",
|
||||
"admin_pads.stats.active_users": "Utilisateur·ices actif·ves",
|
||||
"admin_pads.stats.empty_pads": "Bloc-notes vides",
|
||||
"admin_pads.stats.last_activity": "Dernière activité :",
|
||||
"admin_pads.stats.no_active_users": "Aucun·e utilisateur·ice actif·ve",
|
||||
"admin_pads.stats.revisions_zero": "0 révisions",
|
||||
"admin_pads.stats.total": "Nombre total de bloc-notes",
|
||||
"admin_pads.stats.users_active": "{{count}} actuellement actifs",
|
||||
"admin_pads.subtitle": "Aperçu de tous les bloc-notes de cette instance Etherpad. Rechercher, nettoyer, ouvrir.",
|
||||
"admin_plugins": "Gestionnaire de greffons",
|
||||
"admin_plugins.available": "Greffons disponibles",
|
||||
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
|
||||
"admin_plugins.available_fetching": "Récupération en cours...",
|
||||
"admin_plugins.available_install.value": "Installer",
|
||||
"admin_plugins.available_search.placeholder": "Rechercher des greffons à installer",
|
||||
"admin_plugins.check_updates": "Vérifier les mises à jour",
|
||||
"admin_plugins.catalog_disabled": "Le catalogue de greffons est désactivé par votre administrateur (privacy.pluginCatalog=false). Pour installer un greffon, exécutez `pnpm run plugins i ep_<name> ` sur le serveur.",
|
||||
"admin_plugins.crumbs": "Greffons",
|
||||
"admin_plugins.description": "Description",
|
||||
"admin_plugins.disables.label": "Désactive :",
|
||||
"admin_plugins.disables.warning_title": "Ce greffon supprime intentionnellement les fonctionnalités Etherpad listées.",
|
||||
"admin_plugins.error_retrieving": "Erreur lors de la récupération des greffons",
|
||||
"admin_plugins.install_error": "Échec de l'installation de {{plugin}} : {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Impossible d'installer {{plugin}} : une version plus récente d'Etherpad est requise. Veuillez mettre à jour Etherpad et réessayer.",
|
||||
"admin_plugins.installed": "Greffons installés",
|
||||
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
|
||||
"admin_plugins.installed_nothing": "Vous n’avez encore installé aucun greffon.",
|
||||
|
|
@ -51,8 +113,25 @@
|
|||
"admin_plugins.last-update": "Dernière mise à jour",
|
||||
"admin_plugins.name": "Nom",
|
||||
"admin_plugins.page-title": "Gestionnaire de greffons — Etherpad",
|
||||
"admin_plugins.reload_catalog": "Recharger le catalogue",
|
||||
"admin_plugins.search_npm": "Rechercher sur npm",
|
||||
"admin_plugins.sort_ascending": "Tri croissant",
|
||||
"admin_plugins.sort_descending": "Tri décroissant",
|
||||
"admin_plugins.sort.last_updated": "Dernière mise à jour",
|
||||
"admin_plugins.sort.name": "Nom (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Source du greffon",
|
||||
"admin_plugins.subtitle": "Installez, mettez à jour et supprimez les greffons d’Etherpad. Toute modification nécessite un redémarrage du serveur.",
|
||||
"admin_plugins.tag_core": "Cœur",
|
||||
"admin_plugins.update_tooltip": "Mise à jour",
|
||||
"admin_plugins.updates_available": "Mises à jour disponibles",
|
||||
"admin_plugins.update_now": "Mettre à jour",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Informations de résolution de problème",
|
||||
"admin_plugins_info.bindings_label": "{{count}} liaisons",
|
||||
"admin_plugins_info.copy_diagnostics": "Copie des diagnostics",
|
||||
"admin_plugins_info.copy_value": "Copier {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hooks": "Crochets installés",
|
||||
"admin_plugins_info.hooks_client": "Crochets côté client",
|
||||
"admin_plugins_info.hooks_server": "Crochets côté serveur",
|
||||
|
|
@ -156,6 +235,16 @@
|
|||
"pad.settings.language": "Langue :",
|
||||
"pad.settings.deletePad": "Supprimer le bloc-notes",
|
||||
"pad.delete.confirm": "Voulez-vous vraiment supprimer ce bloc-notes ?",
|
||||
"pad.deletionToken.modalTitle": "Enregistrez votre jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.modalBody": "Ce jeton est le seul moyen de supprimer ce bloc-notes si vous perdez votre session de navigateur ou si vous changez d'appareil. Conservez-le en lieu sûr — il n'apparaît ici qu'une seule fois.",
|
||||
"pad.deletionToken.copy": "Copier",
|
||||
"pad.deletionToken.copied": "Copié",
|
||||
"pad.deletionToken.acknowledge": "Je l'ai enregistré",
|
||||
"pad.deletionToken.deleteWithToken": "Supprimer le bloc-notes avec un jeton",
|
||||
"pad.deletionToken.tokenFieldLabel": "Jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.tokenValueLabel": "Votre jeton de suppression du bloc-notes (lecture seule)",
|
||||
"pad.deletionToken.invalid": "Ce jeton n'est pas valable pour ce bloc-notes.",
|
||||
"pad.deletionToken.notCreator": "Vous n’êtes pas le créateur du bloc-notes, vous ne pouvez donc pas le supprimer.",
|
||||
"pad.settings.about": "À propos",
|
||||
"pad.settings.poweredBy": "Propulsé par",
|
||||
"pad.importExport.import_export": "Importer/Exporter",
|
||||
|
|
@ -168,7 +257,10 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez uniquement importer du texte brut ou du HTML. Pour des fonctionnalités d'importation plus avancées, veuillez <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer LibreOffice</a>.",
|
||||
"pad.importExport.exportetherpada.title": "Exporter au format Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exporter au format HTML",
|
||||
"pad.importExport.exportplaina.title": "Exporter en texte brut",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez importer directement du texte brut, du HTML,Microsoft Word (.docx) et des fichiers Etherpad. Pour importer d'autres formats tels que PDF, ODT, DOC ou RTF, l'administrateur du serveur doit installer LibreOffice ; consultez la <a href=\"https://docs.etherpad.org/\">documentation Etherpad</a> .",
|
||||
"pad.modals.connected": "Connecté.",
|
||||
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
||||
"pad.modals.forcereconnect": "Forcer la reconnexion",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
},
|
||||
"admin.page-title": "Painéal Riaracháin - Etherpad",
|
||||
"admin.loading": "Ag lódáil…",
|
||||
"admin.loading_description": "Fan go fóill fad atá an leathanach á lódáil.",
|
||||
"admin.toggle_sidebar": "Scoránaigh an taobhbharra",
|
||||
"admin.shout": "Cumarsáid",
|
||||
"admin_shout.online_one": "Tá {{count}} úsáideoir ar líne faoi láthair",
|
||||
|
|
@ -25,7 +26,11 @@
|
|||
"admin_pads.col.revisions": "Athbhreithnithe",
|
||||
"admin_pads.col.users": "Úsáideoirí",
|
||||
"admin_pads.confirm_button": "Ceart go leor",
|
||||
"admin_pads.create_pad_dialog_description": "Roghnaigh ainm don phainéal nua.",
|
||||
"admin_pads.delete_pad_dialog_description": "Deimhnigh nó cealaigh scriosadh an eochaircheap.",
|
||||
"admin_pads.delete_pad_dialog_title": "Scrios an eochaircheap",
|
||||
"admin_pads.empty_never_edited": "folamh · níor cuireadh in eagar riamh",
|
||||
"admin_pads.error_dialog_description": "Tharla earráid.",
|
||||
"admin_pads.error_prefix": "Earráid",
|
||||
"admin_pads.filter.active": "Gníomhach",
|
||||
"admin_pads.filter.all": "Gach",
|
||||
|
|
@ -137,7 +142,12 @@
|
|||
"admin_settings.prettify_confirm": "Bainfear na tráchtanna go léir má dhéantar áilleachtú. Leanfaidh tú ar aghaidh?",
|
||||
"admin_settings.mode.form": "Foirm",
|
||||
"admin_settings.mode.raw": "Amh",
|
||||
"admin_settings.mode.effective": "Éifeachtach",
|
||||
"admin_settings.mode.effective_tooltip": "Radharc inléite amháin ar na luachanna atá á n-úsáid ag Etherpad faoi láthair, tar éis athsholáthar athróg timpeallachta. Tá rúin curtha in eagar.",
|
||||
"admin_settings.mode.aria_label": "Mód eagarthóireachta",
|
||||
"admin_settings.envvar_banner.title": "Is teimpléad an comhad seo, ní an chumraíocht bheo.",
|
||||
"admin_settings.envvar_banner.body": "Cuirtear áitchoimeádaithe cosúil le ${VAR:default} isteach sa chuimhne ag an am tosaithe; ní scríobhtar ar ais chuig an gcomhad seo iad choíche. Cuir na cineálacha timpeallachta in eagar i do thimpeallacht (Docker compose, systemd, .env) chun an luach réitithe a athrú, nó cuir litriúil in ionad an áitchoimeádaitheora anseo. Téigh go dtí an cluaisín Éifeachtach chun a fheiceáil cad atá á úsáid ag Etherpad faoi láthair.",
|
||||
"admin_settings.toast.auth_error": "Níl tú fíordheimhnithe mar riarthóir. Logáil isteach arís le do thoil.",
|
||||
"admin_settings.section.general": "Ginearálta",
|
||||
"admin_settings.parse_error.title": "Ní féidir settings.json a pharsáil",
|
||||
"admin_settings.parse_error.cta": "Athraigh go amh le heagarthóireacht",
|
||||
|
|
@ -220,6 +230,7 @@
|
|||
"index.copyLinkButton": "Cóipeáil nasc chuig an ghearrthaisce",
|
||||
"index.transferToSystem": "3. Cóipeáil an seisiún chuig an gcóras nua",
|
||||
"index.transferToSystemDescription": "Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
|
||||
"index.code": "Cód",
|
||||
"index.transferSessionDescription": "Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
|
||||
"index.createOpenPad": "Oscail ceap de réir ainm",
|
||||
"index.openPad": "oscail Pad atá ann cheana féin leis an ainm:",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"admin.page-title": "Panel de administración - Etherpad",
|
||||
"admin.loading": "Cargando…",
|
||||
"admin.loading_description": "Agarda mentres se carga a páxina.",
|
||||
"admin.toggle_sidebar": "Activar ou desactivar a barra lateral",
|
||||
"admin.shout": "Comunicación",
|
||||
"admin_shout.online_one": "Nestes intres hai {{count}} usuario en liña",
|
||||
|
|
@ -27,7 +28,11 @@
|
|||
"admin_pads.col.revisions": "Revisións",
|
||||
"admin_pads.col.users": "Usuarios",
|
||||
"admin_pads.confirm_button": "Aceptar",
|
||||
"admin_pads.create_pad_dialog_description": "Escolle un nome para o documento novo.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirmar ou cancelar a eliminación do documento.",
|
||||
"admin_pads.delete_pad_dialog_title": "Borrar o documento",
|
||||
"admin_pads.empty_never_edited": "baleiro · non se editou nunca",
|
||||
"admin_pads.error_dialog_description": "Houbo un erro.",
|
||||
"admin_pads.error_prefix": "Erro",
|
||||
"admin_pads.filter.active": "Activos",
|
||||
"admin_pads.filter.all": "Todos",
|
||||
|
|
@ -139,7 +144,12 @@
|
|||
"admin_settings.prettify_confirm": "Ao aquelar, eliminaranse todos os comentarios. Queres continuar?",
|
||||
"admin_settings.mode.form": "Formulario",
|
||||
"admin_settings.mode.raw": "En bruto",
|
||||
"admin_settings.mode.effective": "Efectivo",
|
||||
"admin_settings.mode.effective_tooltip": "Vista de só lectura dos valores que Etherpad está a usar agora mesmo, despois da substitución das variables de contorno. Os segredos están agochados.",
|
||||
"admin_settings.mode.aria_label": "Modo de edición",
|
||||
"admin_settings.envvar_banner.title": "Este ficheiro é un modelo, non a configuración real.",
|
||||
"admin_settings.envvar_banner.body": "Os marcadores de posición como «${VAR:default}» substitúense na memoria durante o inicio da aplicación; nunca se escribir neste ficheiro. Edita as variables no teu contorno (Docker compose, systemd, .env) para cambiar o valor resolto ou substitúe o marcador de posición aquí por unha cadea de texto literal. Cambia á lapela «Efectivo» para ver o que está a usar Etherpad agora mesmo.",
|
||||
"admin_settings.toast.auth_error": "Non te conectaches cunha conta administrativa. Inicia sesión de novo.",
|
||||
"admin_settings.section.general": "Xeral",
|
||||
"admin_settings.parse_error.title": "Non se pode analizar o ficheiro «settings.json»",
|
||||
"admin_settings.parse_error.cta": "Cambiar ao editor en bruto para editar",
|
||||
|
|
@ -222,6 +232,7 @@
|
|||
"index.copyLinkButton": "Copiar a ligazón no portapapeis",
|
||||
"index.transferToSystem": "3. Copia a sesión no novo sistema",
|
||||
"index.transferToSystemDescription": "Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
|
||||
"index.code": "Código",
|
||||
"index.transferSessionDescription": "Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
|
||||
"index.createOpenPad": "Abrir un documento por nome",
|
||||
"index.openPad": "abrir un documento existente co nome:",
|
||||
|
|
|
|||
|
|
@ -15,13 +15,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Pannello amministrativo - Etherpad",
|
||||
"admin.loading": "Caricamento…",
|
||||
"admin.loading_description": "Attendi il caricamento della pagina.",
|
||||
"admin.toggle_sidebar": "Attiva/disattiva barra laterale",
|
||||
"admin.shout": "Comunicazione",
|
||||
"admin_shout.online_one": "Attualmente ci sono {{count}} utenti online",
|
||||
"admin_shout.online_other": "Attualmente ci sono {{count}} utenti online",
|
||||
"admin_shout.sticky_toggle": "Cambiare il messaggio adesivo",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Nome utente",
|
||||
"admin_login.password": "Password",
|
||||
"admin_login.submit": "Accedi",
|
||||
"admin_login.failed": "Accesso non riuscito!",
|
||||
"admin_pads.all_pads": "Tutti i Pads",
|
||||
"admin_pads.bulk.cleanup_history": "Pulizia della cronologia",
|
||||
"admin_pads.bulk.clear_selection": "Cancella selezione",
|
||||
"admin_pads.bulk.delete": "Elimina",
|
||||
"admin_pads.cancel": "Annulla",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revisioni",
|
||||
"admin_pads.col.users": "Utenti",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Scegli un nome per il nuovo Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Conferma o annulla la cancellazione del Pad.",
|
||||
"admin_pads.delete_pad_dialog_title": "Cancella Pad",
|
||||
"admin_pads.empty_never_edited": "vuoto · mai modificato",
|
||||
"admin_pads.error_dialog_description": "Si è verificato un errore.",
|
||||
"admin_pads.error_prefix": "Errore",
|
||||
"admin_pads.filter.active": "Attivo",
|
||||
"admin_pads.filter.all": "Tutto",
|
||||
"admin_pads.filter.empty": "Vuoto",
|
||||
"admin_pads.filter.recent": "Questa settimana",
|
||||
"admin_pads.filter.stale": "Stale (>1y)",
|
||||
"admin_pads.open": "Aperto",
|
||||
"admin_pads.pagination.next": "Successivo",
|
||||
"admin_pads.pagination.previous": "Precedente",
|
||||
"admin_pads.refresh": "Aggiorna",
|
||||
"admin_pads.relative.days": "{{count}}d fa",
|
||||
"admin_pads.relative.hours": "{{count}} ore fa",
|
||||
"admin_pads.relative.just_now": "proprio ora",
|
||||
"admin_pads.relative.minutes": "{{count}}m fa",
|
||||
"admin_pads.relative.months": "{{count}}mese fa",
|
||||
"admin_pads.relative.weeks": "{{count}} settimane fa",
|
||||
"admin_pads.relative.years": "{{count}} anni fa",
|
||||
"admin_pads.revisions_count": "{{count}} revisioni",
|
||||
"admin_pads.selected_count": "{{count}} selezionati",
|
||||
"admin_pads.show": "Mostra",
|
||||
"admin_pads.sort.name": "Nome (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Revisioni",
|
||||
"admin_pads.sort.user_count": "Utenti",
|
||||
"admin_pads.stats.across_pads": "su tutti i pad",
|
||||
"admin_pads.stats.active_users": "Utenti attivi",
|
||||
"admin_pads.stats.empty_pads": "Pads vuoti",
|
||||
"admin_pads.stats.last_activity": "Ultima attività",
|
||||
"admin_pads.stats.no_active_users": "Nessun utente attivo",
|
||||
"admin_pads.stats.revisions_zero": "0 revisioni",
|
||||
"admin_pads.stats.total": "Pads totali",
|
||||
"admin_pads.stats.users_active": "{{count}} attualmente attivi",
|
||||
"admin_pads.subtitle": "Panoramica di tutti i pad su questa istanza di Etherpad. Cerca, pulisci, apri.",
|
||||
"admin_plugins": "Gestione plugin",
|
||||
"admin_plugins.available": "Plugin disponibili",
|
||||
"admin_plugins.available_not-found": "Nessun plugin trovato.",
|
||||
"admin_plugins.available_fetching": "Recupero in corso…",
|
||||
"admin_plugins.available_install.value": "Installa",
|
||||
"admin_plugins.available_search.placeholder": "Cerca i plugin da installare",
|
||||
"admin_plugins.check_updates": "Verifica la presenza di aggiornamenti",
|
||||
"admin_plugins.core_count": "{{count}} core",
|
||||
"admin_plugins.catalog_disabled": "Il catalogo dei plugin è disabilitato dal tuo operatore (privacy.pluginCatalog=false). Per installare un plugin, esegui `pnpm run plugins i ep_<name> dal server.",
|
||||
"admin_plugins.crumbs": "Plugin",
|
||||
"admin_plugins.description": "Descrizione",
|
||||
"admin_plugins.disables.label": "Disabilitati:",
|
||||
"admin_plugins.disables.warning_title": "Questo plugin rimuove intenzionalmente le funzionalità di Etherpad elencate.",
|
||||
"admin_plugins.error_retrieving": "Errore durante il recupero dei plugin",
|
||||
"admin_plugins.install_error": "Impossibile installare {{plugin}}: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Impossibile installare {{plugin}}: richiede una versione più recente di Etherpad. Aggiorna Etherpad e riprova.",
|
||||
"admin_plugins.installed": "Plugin installati",
|
||||
"admin_plugins.installed_fetching": "Recupero dei plugin installati…",
|
||||
"admin_plugins.installed_nothing": "Non hai ancora installato alcun plugin.",
|
||||
|
|
@ -29,27 +96,120 @@
|
|||
"admin_plugins.last-update": "Ultimo aggiornamento",
|
||||
"admin_plugins.name": "Nome",
|
||||
"admin_plugins.page-title": "Gestore dei plugin - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Ricarica il catalogo",
|
||||
"admin_plugins.search_npm": "Cerca su npm",
|
||||
"admin_plugins.sort_ascending": "Ordinamento crescente",
|
||||
"admin_plugins.sort_descending": "Ordinamento decrescente",
|
||||
"admin_plugins.sort.last_updated": "Ultimo aggiornamento",
|
||||
"admin_plugins.sort.name": "Nome (A–Z)",
|
||||
"admin_plugins.sort.version": "Versione",
|
||||
"admin_plugins.source": "Fonte del plugin",
|
||||
"admin_plugins.subtitle": "Installa, aggiorna e rimuovi i plugin di Etherpad. Le modifiche richiedono il riavvio del server.",
|
||||
"admin_plugins.tag_core": "Core",
|
||||
"admin_plugins.update_tooltip": "Aggiornamento",
|
||||
"admin_plugins.updates_available": "Aggiornamenti disponibili",
|
||||
"admin_plugins.update_now": "Aggiorna",
|
||||
"admin_plugins.version": "Versione",
|
||||
"admin_plugins_info": "Informazioni sulla risoluzione dei problemi",
|
||||
"admin_plugins_info.bindings_label": "{{count}} bindings",
|
||||
"admin_plugins_info.copy_diagnostics": "Copia diagnostica",
|
||||
"admin_plugins_info.copy_value": "Copia {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Hook bindings",
|
||||
"admin_plugins_info.hooks": "Hook installati",
|
||||
"admin_plugins_info.hooks_client": "Hook lato client",
|
||||
"admin_plugins_info.hooks_server": "Hook lato server",
|
||||
"admin_plugins_info.no_hooks": "Nessun hooks trovato",
|
||||
"admin_plugins_info.parts": "Parti installate",
|
||||
"admin_plugins_info.plugins": "Plugin installati",
|
||||
"admin_plugins_info.page-title": "Informazioni sul plugin - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Cerca hook o parte…",
|
||||
"admin_plugins_info.subtitle": "Diagnostica di sistema: versione installata, componenti registrati e hook.",
|
||||
"admin_plugins_info.tab_client": "Client",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aggiornato",
|
||||
"admin_plugins_info.update_available": "Aggiornamento disponibile: {{version}}",
|
||||
"admin_plugins_info.version": "Versione di Etherpad",
|
||||
"admin_plugins_info.version_latest": "Ultima versione disponibile",
|
||||
"admin_plugins_info.version_number": "Numero di versione",
|
||||
"admin_settings": "Impostazioni",
|
||||
"admin_settings.create_pad": "Crea pad",
|
||||
"admin_settings.current": "Configurazione attuale",
|
||||
"admin_settings.current_example-devel": "Esempio di modello di impostazioni di sviluppo",
|
||||
"admin_settings.current_example-prod": "Esempio di modello di impostazioni di produzione",
|
||||
"admin_settings.current_restart.value": "Riavvia Etherpad",
|
||||
"admin_settings.current_save.value": "Salva impostazioni",
|
||||
"admin_settings.invalid_json": "JSON non valido",
|
||||
"admin_settings.current_test.value": "Validare JSON",
|
||||
"admin_settings.toast.saved": "Impostazioni salvate correttamente.",
|
||||
"admin_settings.toast.save_failed": "Salvataggio non riuscito: impossibile scrivere il file settings.json.",
|
||||
"admin_settings.toast.json_invalid": "Errore di sintassi: verificare virgole, parentesi graffe e virgolette.",
|
||||
"admin_settings.toast.disconnected": "Impossibile salvare: connessione al server non riuscita.",
|
||||
"admin_settings.toast.validation_ok": "Il formato JSON è valido.",
|
||||
"admin_settings.toast.validation_failed": "Il formato JSON non è valido: correggere gli errori di sintassi.",
|
||||
"admin_settings.mode.form": "Modulo",
|
||||
"admin_settings.mode.aria_label": "Modalità editor",
|
||||
"admin_settings.section.general": "Generale",
|
||||
"admin_settings.parse_error.title": "Impossibile analizzare il file settings.json",
|
||||
"admin_settings.parse_error.cta": "Passa al formato raw per modificare",
|
||||
"admin_settings.env_pill.tooltip": "Legge dalla variabile d'ambiente {{variable}}. Il valore seguente viene utilizzato quando {{variable}} non è impostata.",
|
||||
"admin_settings.env_pill.default_label": "predefinito",
|
||||
"admin_settings.env_pill.input_aria": "Valore predefinito per {{variable}}",
|
||||
"admin_settings.env_pill.runtime_label": "valore attivo",
|
||||
"admin_settings.env_pill.runtime_tooltip": "Etherpad sta attualmente utilizzando questo valore, risolto da {{variable}} o dal suo valore predefinito.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "Etherpad sta utilizzando un valore per {{variable}}, ma è nascosto perché è un segreto.",
|
||||
"admin_settings.page-title": "Impostazioni - Etherpad",
|
||||
"admin_settings.save_error": "Impostazioni di salvataggio degli errori",
|
||||
"admin_settings.saved_success": "Impostazioni salvate con successo",
|
||||
"update.banner.title": "Aggiornamento disponibile",
|
||||
"update.banner.body": "Etherpad {{latest}} è disponibile (stai eseguendo {{current}}).",
|
||||
"update.banner.cta": "Visualizza aggiornamento",
|
||||
"update.page.title": "Aggiornamenti di Etherpad",
|
||||
"update.page.current": "Versione attuale",
|
||||
"update.page.latest": "Ultima versione",
|
||||
"update.page.last_check": "Ultimo controllo effettuato",
|
||||
"update.page.install_method": "Metodo di installazione",
|
||||
"update.page.changelog": "Changelog",
|
||||
"update.page.up_to_date": "Stiamo utilizzando l'ultima versione.",
|
||||
"update.page.disabled": "I controlli degli aggiornamenti sono disabilitati (updates.tier = \"off\").",
|
||||
"update.page.unauthorized": "Non sei autorizzato a visualizzare lo stato degli aggiornamenti.",
|
||||
"update.page.error": "Impossibile caricare lo stato dell'aggiornamento (stato {{status}}).",
|
||||
"update.badge.severe": "Etherpad su questo server è gravemente obsoleto. Segnalalo all'amministratore.",
|
||||
"update.badge.vulnerable": "Su questo server è in esecuzione una versione di Etherpad con vulnerabilità di sicurezza note. Informa l'amministratore.",
|
||||
"update.page.apply": "Applica aggiornamento",
|
||||
"update.page.cancel": "Annulla",
|
||||
"update.page.log": "Registro dei log (ultime 200 righe)",
|
||||
"update.page.execution": "Stato",
|
||||
"update.page.policy.install-method-not-writable": "Gli aggiornamenti dall'interfaccia di amministrazione richiedono un'installazione da Git. Aggiorna tramite il tuo gestore di pacchetti.",
|
||||
"update.page.policy.rollback-failed-terminal": "Un precedente aggiornamento non è riuscito e non è stato possibile annullarlo. Premi Conferma al termine dell'installazione per rimuovere il blocco.",
|
||||
"update.page.policy.up-to-date": "Stiamo utilizzando l'ultima versione.",
|
||||
"update.page.policy.tier-off": "Gli aggiornamenti sono disabilitati (updates.tier = \"off\").",
|
||||
"update.page.policy.maintenance-window-missing": "Il livello 4 (autonomo) richiede una finestra di manutenzione. Imposta updates.maintenanceWindow in settings.json per abilitare gli aggiornamenti autonomi.",
|
||||
"update.page.last_result.verified": "Ultimo aggiornamento a {{tag}} verificato.",
|
||||
"update.page.last_result.rolled-back": "Ultimo tentativo di aggiornamento di {{tag}} annullato: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Ultimo tentativo di aggiornamento non riuscito E rollback non riuscito: {{reason}}. Intervento manuale necessario.",
|
||||
"update.page.last_result.cancelled": "Ultimo tentativo di aggiornamento di {{tag}} annullato dall'amministratore.",
|
||||
"update.execution.idle": "Idle",
|
||||
"update.execution.scheduled": "Aggiornamento programmato",
|
||||
"update.execution.executing": "Aggiornamento...",
|
||||
"update.execution.pending-verification": "In attesa di verifica",
|
||||
"update.execution.verified": "Verificato",
|
||||
"update.execution.rolling-back": "Rollback",
|
||||
"update.execution.rolled-back": "Rolled back",
|
||||
"update.execution.rollback-failed": "Rollback fallito",
|
||||
"update.banner.terminal.rollback-failed": "Il tentativo di aggiornamento non è riuscito e non è stato possibile annullarlo. È necessario un intervento manuale.",
|
||||
"update.banner.scheduled": "Aggiornamento automatico programmato per {{tag}} — si applica tra {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Gli aggiornamenti automatici sono disabilitati fino a quando non viene configurata una finestra di manutenzione.",
|
||||
"update.page.scheduled.title": "Aggiornamento programmato",
|
||||
"update.page.scheduled.countdown": "Etherpad inizierà l'aggiornamento a {{tag}} in {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Fuori dalla finestra di manutenzione. L'aggiornamento inizierà all'apertura della finestra alle {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Applica ora",
|
||||
"update.window.title": "Finestra di manutenzione",
|
||||
"update.window.unset": "Non configurato.",
|
||||
"update.window.next_opens_at": "La prossima finestra si aprirà alle {{at}}.",
|
||||
"update.drain.t60": "Etherpad si riavvierà tra 60 secondi per applicare un aggiornamento.",
|
||||
"update.drain.t30": "Etherpad si riavvierà tra 30 secondi per applicare un aggiornamento.",
|
||||
"update.drain.t10": "Etherpad si riavvierà tra 10 secondi per applicare un aggiornamento.",
|
||||
"index.newPad": "Nuovo pad",
|
||||
"index.settings": "Impostazioni",
|
||||
"index.transferSessionTitle": "Sessione di trasferimento",
|
||||
|
|
@ -62,6 +222,7 @@
|
|||
"index.copyLinkButton": "Copia link negli appunti",
|
||||
"index.transferToSystem": "3. Copia la sessione sul nuovo sistema",
|
||||
"index.transferToSystemDescription": "Apri il collegamento copiato nel browser o nel dispositivo di destinazione per trasferire la sessione",
|
||||
"index.code": "Codice",
|
||||
"index.transferSessionDescription": "Trasferisci la tua sessione corrente al browser o al dispositivo cliccando sul pulsante qui sotto. Verrà copiato un link a una pagina che trasferirà la tua sessione quando verrà aperta nel browser o dispositivo di destinazione.",
|
||||
"index.createOpenPad": "Apri pad per nome",
|
||||
"index.openPad": "apri un Pad esistente col nome:",
|
||||
|
|
@ -111,8 +272,18 @@
|
|||
"pad.settings.fontType": "Tipo di carattere:",
|
||||
"pad.settings.fontType.normal": "Normale",
|
||||
"pad.settings.language": "Lingua:",
|
||||
"pad.settings.deletePad": "Elimina Pad",
|
||||
"pad.settings.deletePad": "Cancella Pad",
|
||||
"pad.delete.confirm": "Vuoi veramente cancellare questo pad?",
|
||||
"pad.deletionToken.modalTitle": "Salva il token di eliminazione del tuo pad",
|
||||
"pad.deletionToken.modalBody": "Questo token è l'unico modo per eliminare questo pad se perdi la sessione del browser o il dispositivo di commutazione. Conservalo in un luogo sicuro - qui viene mostrato esattamente solo una volta.",
|
||||
"pad.deletionToken.copy": "Copia",
|
||||
"pad.deletionToken.copied": "Copiato",
|
||||
"pad.deletionToken.acknowledge": "L'ho salvato",
|
||||
"pad.deletionToken.deleteWithToken": "Cancellare il pad con il token",
|
||||
"pad.deletionToken.tokenFieldLabel": "Token di eliminazione del pad",
|
||||
"pad.deletionToken.tokenValueLabel": "Il tuo token di cancellazione del pad (solo-lettura)",
|
||||
"pad.deletionToken.invalid": "Quel token non è valido per questo pad.",
|
||||
"pad.deletionToken.notCreator": "Non sei tu il creatore di questo pad, quindi non puoi eliminarlo.",
|
||||
"pad.settings.about": "Informazioni",
|
||||
"pad.settings.poweredBy": "Realizzato con",
|
||||
"pad.importExport.import_export": "Importazione/esportazione",
|
||||
|
|
@ -125,6 +296,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Esporta come Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Esporta come HTML",
|
||||
"pad.importExport.exportplaina.title": "Esporta come testo semplice",
|
||||
"pad.importExport.exportworda.title": "Esporta come Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Esporta in formato PDF",
|
||||
"pad.importExport.exportopena.title": "Esporta in formato ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "È possibile importare direttamente file di testo semplice, HTML, Microsoft Word (.docx) e file Etherpad. Per importare altri formati come PDF, ODT, DOC o RTF, l'amministratore del server deve installare LibreOffice: consulta la <a href=\"https://docs.etherpad.org/\">documentazione di Etherpad</a>.",
|
||||
"pad.modals.connected": "Connesso.",
|
||||
"pad.modals.reconnecting": "Riconnessione al pad in corso…",
|
||||
"pad.modals.forcereconnect": "Forza la riconnessione",
|
||||
|
|
@ -171,16 +349,25 @@
|
|||
"timeslider.toolbar.returnbutton": "Ritorna al Pad",
|
||||
"pad.historyMode.banner": "Visualizza cronologia",
|
||||
"pad.historyMode.revisionLabel": "Versione {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Controlli della cronologia del pad",
|
||||
"pad.historyMode.sliderLabel": "Versione Pad",
|
||||
"pad.historyMode.settings.follow": "Segui gli aggiornamenti dei contenuti del pad",
|
||||
"pad.historyMode.settings.followShort": "Segui",
|
||||
"pad.historyMode.settings.playbackSpeed": "Velocità di riproduzione:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat del {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autori in questa versione",
|
||||
"pad.editor.skipToContent": "Vai all'editor",
|
||||
"pad.editor.keyboardHint": "Premi Esc per uscire dall'editor. Premi Alt+F9 per accedere alla barra degli strumenti.",
|
||||
"pad.editor.toolbar.formatting": "Strumento di formattazione",
|
||||
"pad.editor.toolbar.actions": "Barra degli strumenti delle azioni del pad",
|
||||
"pad.editor.toolbar.showMore": "Mostra altri pulsanti della barra degli strumenti",
|
||||
"timeslider.toolbar.authors": "Autori:",
|
||||
"timeslider.toolbar.authorsList": "Nessun autore",
|
||||
"timeslider.toolbar.exportlink.title": "Esporta",
|
||||
"timeslider.exportCurrent": "Esporta la versione corrente come:",
|
||||
"timeslider.version": "Versione {{version}}",
|
||||
"timeslider.saved": "Salvato {{day}} {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Velocità di riproduzione:",
|
||||
"timeslider.settings.playbackSpeed.original": "Velocità originale",
|
||||
"timeslider.settings.playbackSpeed.realtime": "In tempo reale",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 ms",
|
||||
|
|
@ -207,6 +394,7 @@
|
|||
"pad.savedrevs.timeslider": "Puoi vedere le versioni salvate visitando la cronologia",
|
||||
"pad.userlist.entername": "Inserisci il tuo nome",
|
||||
"pad.userlist.unnamed": "senza nome",
|
||||
"pad.userlist.onlineCount": "{[ plurale(count) uno: {{count}} utente connesso, altro: {{count}} utenti connessi ]}",
|
||||
"pad.editbar.clearcolors": "Eliminare i colori degli autori sull'intero documento? Questa azione non può essere annullata",
|
||||
"pad.impexp.importbutton": "Importa ora",
|
||||
"pad.impexp.importing": "Importazione in corso...",
|
||||
|
|
@ -217,5 +405,6 @@
|
|||
"pad.impexp.importfailed": "Importazione fallita",
|
||||
"pad.impexp.copypaste": "Si prega di copiare e incollare",
|
||||
"pad.impexp.exportdisabled": "L'esportazione come {{type}} è disabilitata. Contattare l'amministratore per i dettagli.",
|
||||
"pad.impexp.maxFileSize": "File troppo grande. Contatta l'amministratore del sito per incrementare la dimensione consentita per l'importazione"
|
||||
"pad.impexp.maxFileSize": "File troppo grande. Contatta l'amministratore del sito per incrementare la dimensione consentita per l'importazione",
|
||||
"pad.social.description": "Un documento collaborativo che tutti possono modificare in tempo reale."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"Chqaz",
|
||||
"Omotecho",
|
||||
"Shirayuki",
|
||||
"Tensama0415",
|
||||
"Torinky"
|
||||
]
|
||||
},
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
"admin_plugins_info.hooks_client": "クライアント側のフック",
|
||||
"admin_plugins_info.hooks_server": "サーバー側のフック",
|
||||
"index.newPad": "新規作成",
|
||||
"index.createOpenPad": "または作成/編集するパッド名を入力:",
|
||||
"index.createOpenPad": "編集するパッド名を入力",
|
||||
"index.openPad": "次の名称の既存の Pad を開く:",
|
||||
"pad.toolbar.bold.title": "太字 (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "斜体 (Ctrl+I)",
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
"pad.toolbar.ol.title": "番号付きリスト (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ul.title": "番号なしリスト (Ctrl+Shift+L)",
|
||||
"pad.toolbar.indent.title": "インデント (Tab)",
|
||||
"pad.toolbar.unindent.title": "インデント解除 (Shift+Tab)",
|
||||
"pad.toolbar.unindent.title": "アウトデント (Shift+Tab)",
|
||||
"pad.toolbar.undo.title": "元に戻す (Ctrl+Z)",
|
||||
"pad.toolbar.redo.title": "やり直し (Ctrl+Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "作者の色分けを消去(Ctrl+Shift+C)",
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
"pad.loading": "読み込み中...",
|
||||
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. \n\nクッキーが見つかりません。ブラウザの設定でクッキーの使用を許可するまで、アクセスの記録や設定は引き継がれません。原因はブラウザによって Etherpad が iFrame に組み込まれたからと考えられます。親ドメインの iFrame と同じドメイン/サブドメインに置かれているかどうか、Etherpad の設定を確認してください。",
|
||||
"pad.permissionDenied": "あなたにはこのパッドへのアクセス許可がありません",
|
||||
"pad.settings.padSettings": "パッドの設定",
|
||||
"pad.settings.padSettings": "全パッドの設定",
|
||||
"pad.settings.myView": "個人設定",
|
||||
"pad.settings.stickychat": "画面にチャットを常に表示",
|
||||
"pad.settings.chatandusers": "チャットとユーザーを表示",
|
||||
|
|
@ -77,7 +78,7 @@
|
|||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.modals.connected": "接続されました。",
|
||||
"pad.modals.reconnecting": "パッドに再接続中...",
|
||||
"pad.modals.reconnecting": "パッドに再接続中…",
|
||||
"pad.modals.forcereconnect": "強制的に再接続",
|
||||
"pad.modals.reconnecttimer": "再接続を試行中",
|
||||
"pad.modals.cancel": "中止",
|
||||
|
|
|
|||
|
|
@ -19,13 +19,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "관리 대시보드 - 이더패드",
|
||||
"admin.loading": "불러오는 중…",
|
||||
"admin.loading_description": "페이지가 로딩되는 동안 잠시 기다려 주세요.",
|
||||
"admin.toggle_sidebar": "사이드바 토글",
|
||||
"admin.shout": "커뮤니케이션",
|
||||
"admin_shout.online_one": "현재 {{count}}명의 사용자가 온라인 상태입니다",
|
||||
"admin_shout.online_other": "현재 {{count}}명의 사용자가 온라인 상태입니다",
|
||||
"admin_shout.sticky_toggle": "고정 메시지 변경",
|
||||
"admin_login.title": "이더패드",
|
||||
"admin_login.username": "사용자 이름",
|
||||
"admin_login.password": "비밀번호",
|
||||
"admin_login.submit": "로그인",
|
||||
"admin_login.failed": "로그인 실패",
|
||||
"admin_pads.all_pads": "모든 패드",
|
||||
"admin_pads.bulk.cleanup_history": "역사 지우기",
|
||||
"admin_pads.bulk.clear_selection": "선택 항목 지우기",
|
||||
"admin_pads.bulk.delete": "삭제",
|
||||
"admin_pads.cancel": "취소",
|
||||
"admin_pads.col.pad": "패드",
|
||||
"admin_pads.col.revisions": "판",
|
||||
"admin_pads.col.users": "사용자",
|
||||
"admin_pads.confirm_button": "확인",
|
||||
"admin_pads.create_pad_dialog_description": "새 패드의 이름을 선택하세요.",
|
||||
"admin_pads.delete_pad_dialog_description": "패드 삭제를 확인하거나 취소하세요.",
|
||||
"admin_pads.delete_pad_dialog_title": "패드 삭제",
|
||||
"admin_pads.empty_never_edited": "비었음 · 편집된 적 없음",
|
||||
"admin_pads.error_dialog_description": "오류가 발생했습니다.",
|
||||
"admin_pads.error_prefix": "오류",
|
||||
"admin_pads.filter.active": "활성",
|
||||
"admin_pads.filter.all": "모두",
|
||||
"admin_pads.filter.empty": "비었음",
|
||||
"admin_pads.filter.recent": "이번 주",
|
||||
"admin_pads.filter.stale": "오래됨 (>1년)",
|
||||
"admin_pads.open": "열기",
|
||||
"admin_pads.pagination.next": "다음",
|
||||
"admin_pads.pagination.previous": "이전",
|
||||
"admin_pads.refresh": "새로 고침",
|
||||
"admin_pads.relative.days": "{{count}}일 전",
|
||||
"admin_pads.relative.hours": "{{count}}시간 전",
|
||||
"admin_pads.relative.just_now": "방금",
|
||||
"admin_pads.relative.minutes": "{{count}}분 전",
|
||||
"admin_pads.relative.months": "{{count}}달 전",
|
||||
"admin_pads.relative.weeks": "{{count}}주 전",
|
||||
"admin_pads.relative.years": "{{count}}년 전",
|
||||
"admin_pads.revisions_count": "수정 내역 {{count}}개",
|
||||
"admin_pads.selected_count": "{{count}}개 선택됨",
|
||||
"admin_pads.show": "보기",
|
||||
"admin_pads.sort.name": "이름 (A-Z)",
|
||||
"admin_pads.sort.revision_number": "수정내역",
|
||||
"admin_pads.sort.user_count": "사용자",
|
||||
"admin_pads.stats.across_pads": "모든 패드에 걸쳐서",
|
||||
"admin_pads.stats.active_users": "활동적인 사용자",
|
||||
"admin_pads.stats.empty_pads": "빈 패드",
|
||||
"admin_pads.stats.last_activity": "마지막 활동",
|
||||
"admin_pads.stats.no_active_users": "활동적인 사용자 없음",
|
||||
"admin_pads.stats.revisions_zero": "수정 내역 없음",
|
||||
"admin_pads.stats.total": "총 패드",
|
||||
"admin_pads.stats.users_active": "{{count}}명 현재 활동중",
|
||||
"admin_pads.subtitle": "이 이더패드 인스턴스의 모든 패드 개요입니다. 검색, 정리, 열기를 할 수 있습니다.",
|
||||
"admin_plugins": "플러그인 관리자",
|
||||
"admin_plugins.available": "사용 가능한 플러그인",
|
||||
"admin_plugins.available_not-found": "플러그인이 없습니다.",
|
||||
"admin_plugins.available_fetching": "검색 중...",
|
||||
"admin_plugins.available_install.value": "설치",
|
||||
"admin_plugins.available_search.placeholder": "설치할 플러그인을 검색",
|
||||
"admin_plugins.check_updates": "업데이트 확인",
|
||||
"admin_plugins.core_count": "코어 {{count}}개",
|
||||
"admin_plugins.catalog_disabled": "운영자가 플러그인 카탈로그를 비활성화했습니다(privacy.pluginCatalog=false). 플러그인을 설치하려면 서버에서 `pnpm run plugins i ep_<name>`을 실행하세요.",
|
||||
"admin_plugins.crumbs": "플러그인",
|
||||
"admin_plugins.description": "설명",
|
||||
"admin_plugins.disables.label": "비활성화:",
|
||||
"admin_plugins.disables.warning_title": "이 플러그인은 나열된 이더패드 기능을 의도적으로 제거합니다.",
|
||||
"admin_plugins.error_retrieving": "플러그인을 가져오는 중 오류 발생",
|
||||
"admin_plugins.install_error": "{{plugin}} 설치 실패: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "{{plugin}}을 설치할 수 없습니다. 더 새로운 버전의 이더패드가 필요합니다. 이더패드를 업그레이드한 뒤 다시 시도하세요.",
|
||||
"admin_plugins.installed": "설치된 플러그인",
|
||||
"admin_plugins.installed_fetching": "설치된 플러그인을 검색하는 중...",
|
||||
"admin_plugins.installed_nothing": "아직 플러인을 설치하지 않으셨습니다.",
|
||||
|
|
@ -33,50 +100,161 @@
|
|||
"admin_plugins.last-update": "마지막 업데이트",
|
||||
"admin_plugins.name": "이름",
|
||||
"admin_plugins.page-title": "플러그인 관리자 - 이더패드",
|
||||
"admin_plugins.reload_catalog": "카탈로그 새로고침",
|
||||
"admin_plugins.search_npm": "npm에서 검색",
|
||||
"admin_plugins.sort_ascending": "오름차순 정렬",
|
||||
"admin_plugins.sort_descending": "내림차순 정렬",
|
||||
"admin_plugins.sort.last_updated": "최근 업데이트",
|
||||
"admin_plugins.sort.name": "이름 (A-Z)",
|
||||
"admin_plugins.sort.version": "버전",
|
||||
"admin_plugins.source": "플러그인 소스",
|
||||
"admin_plugins.subtitle": "이더패드 플러그인을 설치, 업데이트 및 제거합니다. 변경 사항을 적용하려면 서버를 다시 시작해야 합니다.",
|
||||
"admin_plugins.tag_core": "코어",
|
||||
"admin_plugins.update_tooltip": "업데이트",
|
||||
"admin_plugins.updates_available": "업데이트가 있습니다",
|
||||
"admin_plugins.update_now": "업데이트",
|
||||
"admin_plugins.version": "버전",
|
||||
"admin_plugins_info": "문제 해결 정보",
|
||||
"admin_plugins_info.bindings_label": "바인딩 {{count}}개",
|
||||
"admin_plugins_info.copy_diagnostics": "진단 복사",
|
||||
"admin_plugins_info.copy_value": "{{label}} 복사",
|
||||
"admin_plugins_info.git_sha": "깃 SHA",
|
||||
"admin_plugins_info.hook_bindings": "후크 바인딩",
|
||||
"admin_plugins_info.hooks": "설치된 훅",
|
||||
"admin_plugins_info.hooks_client": "클라이언트 사이드 훅",
|
||||
"admin_plugins_info.hooks_server": "서버사이드 훅",
|
||||
"admin_plugins_info.no_hooks": "훅이 없습니다",
|
||||
"admin_plugins_info.parts": "설치된 항목",
|
||||
"admin_plugins_info.plugins": "설치된 플러그인",
|
||||
"admin_plugins_info.page-title": "플러그인 정보 - 이더패드",
|
||||
"admin_plugins_info.search_placeholder": "후크 또는 파츠 검색…",
|
||||
"admin_plugins_info.subtitle": "시스템 진단: 설치된 버전, 등록된 파츠 및 후크입니다.",
|
||||
"admin_plugins_info.tab_client": "클라이언트",
|
||||
"admin_plugins_info.tab_server": "서버",
|
||||
"admin_plugins_info.up_to_date": "현재 최신 버전",
|
||||
"admin_plugins_info.update_available": "업데이트 가능: {{version}}",
|
||||
"admin_plugins_info.version": "이더패드 버전",
|
||||
"admin_plugins_info.version_latest": "사용 가능한 최신 버전",
|
||||
"admin_plugins_info.version_number": "버전 번호",
|
||||
"admin_settings": "설정",
|
||||
"admin_settings.create_pad": "패드 만들기",
|
||||
"admin_settings.current": "현재 구성",
|
||||
"admin_settings.current_example-devel": "예시 개발용 설정 틀",
|
||||
"admin_settings.current_example-prod": "예시 운영용 설정 틀",
|
||||
"admin_settings.current_restart.value": "이더패드 다시 시작",
|
||||
"admin_settings.current_save.value": "설정 저장",
|
||||
"admin_settings.invalid_json": "잘못된 JSON",
|
||||
"admin_settings.current_test.value": "JSON 검증",
|
||||
"admin_settings.current_prettify.value": "JSON를 보기 좋게 정리",
|
||||
"admin_settings.toast.saved": "설정을 성공적으로 저장했습니다.",
|
||||
"admin_settings.toast.save_failed": "저장 실패: settings.json에 쓰기 할 수 없습니다.",
|
||||
"admin_settings.toast.json_invalid": "구문 오류: 쉼표, 중괄호, 따옴표를 확인하세요.",
|
||||
"admin_settings.toast.disconnected": "저장할 수 없음: 서버에 연결되어 있지 않습니다.",
|
||||
"admin_settings.toast.validation_ok": "JSON이 유효합니다.",
|
||||
"admin_settings.toast.validation_failed": "JSON이 유효하지 않습니다. 구문 오류를 수정하세요.",
|
||||
"admin_settings.toast.prettify_failed": "보기 좋게 정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
|
||||
"admin_settings.prettify_confirm": "정리하면 모든 의견이 제거됩니다. 계속하시겠습니까?",
|
||||
"admin_settings.mode.form": "형태",
|
||||
"admin_settings.mode.raw": "원본",
|
||||
"admin_settings.mode.effective": "효과 적용 중",
|
||||
"admin_settings.mode.effective_tooltip": "환경 변수 대체 후 이더패드가 현재 실제로 사용 중인 값을 읽기 전용으로 보여줍니다. 비밀 정보는 가려졌습니다.",
|
||||
"admin_settings.mode.aria_label": "편집기 모드",
|
||||
"admin_settings.envvar_banner.title": "이 파일은 틀이며, 라이브 설정이 아닙니다.",
|
||||
"admin_settings.envvar_banner.body": "${VAR:default}와 같은 자리 표시자는 시작 시 메모리에 대체되며, 이 파일에는 다시 기록되지 않습니다. 환경 변수(Docker compose, systemd, .env)를 편집하여 확인된 값을 변경하거나, 여기의 자리 표시자를 리터럴 값으로 바꾸세요. 현재 이더패드에서 사용 중인 값을 확인하려면 '효과 적용 중' 탭으로 이동하세요.",
|
||||
"admin_settings.toast.auth_error": "관리자 계정으로 인증되지 않았습니다. 다시 로그인해 주세요.",
|
||||
"admin_settings.section.general": "일반",
|
||||
"admin_settings.parse_error.title": "settings.json을 구문 분석할 수 없습니다",
|
||||
"admin_settings.parse_error.cta": "원본 편집으로 전환",
|
||||
"admin_settings.env_pill.tooltip": "{{variable}} 환경 변수에서 읽습니다. {{variable}}이 설정되지 않은 경우 아래 값이 사용됩니다.",
|
||||
"admin_settings.env_pill.default_label": "기본값",
|
||||
"admin_settings.env_pill.input_aria": "{{variable}}의 기본값",
|
||||
"admin_settings.env_pill.runtime_label": "활성 값",
|
||||
"admin_settings.env_pill.runtime_tooltip": "이더패드는 현재 {{variable}} 또는 해당 기본값에서 확인된 이 값을 사용하고 있습니다.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "이더패드가 {{variable}}의 값을 사용하고 있지만, 비밀 값이므로 숨겨져 있습니다.",
|
||||
"admin_settings.page-title": "설정 - 이더패드",
|
||||
"admin_settings.save_error": "설정 저장 오류",
|
||||
"admin_settings.saved_success": "설정이 성공적으로 저장되었습니다",
|
||||
"update.banner.title": "업데이트 사용 가능",
|
||||
"update.banner.body": "이더패드 {{latest}}을 사용할 수 있습니다(현재 {{current}} 실행 중).",
|
||||
"update.banner.cta": "업데이트 보기",
|
||||
"update.page.title": "이더패드 업데이트",
|
||||
"update.page.current": "현재 판",
|
||||
"update.page.latest": "최신 버전",
|
||||
"update.page.last_check": "마지막으로 확인함",
|
||||
"update.page.install_method": "설치 방법",
|
||||
"update.page.tier": "업데이트 티어",
|
||||
"update.page.changelog": "바뀐기록",
|
||||
"update.page.up_to_date": "최신 버전을 사용 중입니다.",
|
||||
"update.page.disabled": "업데이트 확인이 비활성화되어 있습니다(updates.tier = \"off\").",
|
||||
"update.page.unauthorized": "업데이트 상태를 볼 권한이 없습니다.",
|
||||
"update.page.error": "업데이트 상태를 불러올 수 없습니다(상태 {{status}}).",
|
||||
"update.badge.severe": "이 서버의 이더패드가 심각하게 오래되었습니다. 관리자에게 알리세요.",
|
||||
"update.badge.vulnerable": "이 서버의 이더패드는 알려진 보안 문제가 있는 버전을 실행하고 있습니다. 관리자에게 알리세요.",
|
||||
"update.page.apply": "업데이트 적용",
|
||||
"update.page.cancel": "취소",
|
||||
"update.page.acknowledge": "확인",
|
||||
"update.page.log": "업데이트 로그 (최근 200줄)",
|
||||
"update.page.execution": "상태",
|
||||
"update.page.policy.install-method-not-writable": "관리자 UI에서 업데이트하려면 Git 설치가 필요합니다. 패키지 관리자를 통해 업데이트하세요.",
|
||||
"update.page.policy.rollback-failed-terminal": "이전 업데이트가 실패했으며 롤백할 수 없었습니다. 설치가 정상화된 뒤 확인을 눌러 잠금을 해제하세요.",
|
||||
"update.page.policy.up-to-date": "최신 버전을 사용 중입니다.",
|
||||
"update.page.policy.tier-off": "업데이트가 비활성화되었습니다 (updates.tier = \"off\").",
|
||||
"update.page.policy.maintenance-window-missing": "계층 4(자율)는 유지보수 기간이 필요합니다. 자율 업데이트를 활성화하려면 settings.json에서 updates.maintenanceWindow를 설정하세요.",
|
||||
"update.page.policy.maintenance-window-invalid": "updates.maintenanceWindow 형식이 잘못되어 계층 4(자율)가 비활성화되었습니다. HH:MM 시간과 \"local\" 또는 \"utc\"인 tz를 포함한 {start, end, tz} 형식이 필요합니다.",
|
||||
"update.page.last_result.verified": "{{tag}}의 마지막 업데이트가 확인되었습니다.",
|
||||
"update.page.last_result.rolled-back": "{{tag}}에 대한 마지막 업데이트 시도가 롤백되었습니다: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "마지막 업데이트 시도가 실패했고 롤백도 실패했습니다: {{reason}}. 수동 개입이 필요합니다.",
|
||||
"update.page.last_result.preflight-failed": "{{tag}}에 대한 마지막 업데이트 시도가 사전 검사에서 실패했습니다: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "관리자에 의해 {{tag}}에 대한 마지막 업데이트 시도가 취소되었습니다.",
|
||||
"update.execution.idle": "유휴 상태",
|
||||
"update.execution.scheduled": "예정된 업데이트",
|
||||
"update.execution.preflight": "비행 전 점검",
|
||||
"update.execution.preflight-failed": "비행 전 점검 실패",
|
||||
"update.execution.draining": "세션 정리 중",
|
||||
"update.execution.executing": "업데이트 중...",
|
||||
"update.execution.pending-verification": "확인 대기 중",
|
||||
"update.execution.verified": "확인됨",
|
||||
"update.execution.rolling-back": "되돌리는 중",
|
||||
"update.execution.rolled-back": "되돌려짐",
|
||||
"update.execution.rollback-failed": "되돌리기 실패",
|
||||
"update.banner.terminal.rollback-failed": "업데이트 시도가 실패했으며 롤백할 수 없습니다. 수동 조치가 필요합니다.",
|
||||
"update.banner.scheduled": "{{tag}}에 대한 자동 업데이트가 예약되었습니다. {{remaining}}에 적용됩니다.",
|
||||
"update.banner.maintenance-window-missing": "유지 관리 기간이 설정될 때까지 자동 업데이트가 비활성화됩니다.",
|
||||
"update.banner.maintenance-window-invalid": "유지 관리 기간이 잘못 설정되어 있어 자동 업데이트가 비활성화되었습니다.",
|
||||
"update.page.scheduled.title": "예정된 업데이트",
|
||||
"update.page.scheduled.countdown": "이더패드는 {{remaining}} 후에 {{tag}}(으)로의 업데이트를 시작합니다.",
|
||||
"update.page.scheduled.deferred_until": "유지 보수 시간 외입니다. 업데이트는 유지 보수가 시작되는 {{at}}에 시작됩니다.",
|
||||
"update.page.scheduled.apply_now": "지금 적용",
|
||||
"update.window.title": "유지보수 창",
|
||||
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||
"update.window.unset": "구성되지 않았습니다.",
|
||||
"update.window.next_opens_at": "다음 창은 {{at}}에 열립니다.",
|
||||
"update.drain.t60": "업데이트 적용을 위해 이더패드가 60초 뒤에 재시작됩니다.",
|
||||
"update.drain.t30": "업데이트 적용을 위해 이더패드가 30초 뒤에 재시작됩니다.",
|
||||
"update.drain.t10": "업데이트 적용을 위해 이더패드가 10초 뒤에 재시작됩니다.",
|
||||
"index.newPad": "새 패드",
|
||||
"index.settings": "설정",
|
||||
"index.transferSessionTitle": "전송 세션",
|
||||
"index.receiveSessionTitle": "수신 세션",
|
||||
"index.receiveSessionDescription": "여기에서 다른 브라우저나 기기에서 이더패드 세션을 수신할 수 있습니다. 단, 이렇게 하면 현재 세션이 삭제되므로 주의하세요.",
|
||||
"index.transferSession": "1. 전송 세션",
|
||||
"index.transferSessionNow": "전송 세션을 지금 시작",
|
||||
"index.copyLink": "2. 링크 복사",
|
||||
"index.copyLinkDescription": "아래 버튼을 클릭하여 링크를 클립보드에 복사하세요.",
|
||||
"index.copyLinkButton": "클립보드에 링크 복사하기",
|
||||
"index.transferToSystem": "3. 세션을 새 시스템으로 복사",
|
||||
"index.transferToSystemDescription": "복사한 링크를 대상 브라우저 또는 기기에서 열어 세션을 전송하세요.",
|
||||
"index.code": "코드",
|
||||
"index.transferSessionDescription": "아래 버튼을 클릭하여 현재 세션을 브라우저 또는 기기로 전송하세요. 이 버튼을 클릭하면 대상 브라우저 또는 기기에서 열었을 때 세션이 전송될 문서 링크가 복사됩니다.",
|
||||
"index.createOpenPad": "이름으로 패드 열기",
|
||||
"index.openPad": "이름으로 기존 패드 열기:",
|
||||
"index.recentPads": "최근 패드",
|
||||
"index.recentPadsEmpty": "최근 패드가 없습니다.",
|
||||
"index.generateNewPad": "무작위 패드 이름 생성",
|
||||
"index.labelPad": "패드 이름 (선택 사항)",
|
||||
"index.placeholderPadEnter": "패드 이름을 입력...",
|
||||
"index.createAndShareDocuments": "실시간으로 문서를 생성하고 공유하세요",
|
||||
"index.createAndShareDocumentsDescription": "이더패드를 사용하면 마치 브라우저에서 실행되는 실시간 멀티플레이어 편집기처럼 문서를 실시간으로 공동 편집할 수 있습니다.",
|
||||
"pad.toolbar.bold.title": "굵게 (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "기울임꼴 (Ctrl+I)",
|
||||
"pad.toolbar.underline.title": "밑줄 (Ctrl+U)",
|
||||
|
|
@ -109,33 +287,45 @@
|
|||
"pad.settings.stickychat": "화면에 항상 대화 보기",
|
||||
"pad.settings.chatandusers": "대화와 사용자 보기",
|
||||
"pad.settings.colorcheck": "작성자 표시 색상",
|
||||
"pad.settings.fadeInactiveAuthorColors": "비활성 저자의 색상 흐리기",
|
||||
"pad.settings.linenocheck": "줄 번호",
|
||||
"pad.settings.rtlcheck": "우횡서(오른쪽에서 왼쪽으로)입니까?",
|
||||
"pad.settings.enforceSettings": "다른 사용자에게 설정 적용",
|
||||
"pad.settings.enforcedNotice": "이 설정은 패드 제작자가 잠가 놓았습니다. 설정을 변경해야 하는 경우 패드 제작자에게 문의하세요.",
|
||||
"pad.settings.fontType": "글꼴 종류:",
|
||||
"pad.settings.fontType.normal": "보통",
|
||||
"pad.settings.language": "언어:",
|
||||
"pad.settings.deletePad": "패드 삭제",
|
||||
"pad.delete.confirm": "정말로 이 패드를 삭제하겠습니까?",
|
||||
"pad.deletionToken.modalTitle": "패드 삭제 토큰을 저장",
|
||||
"pad.deletionToken.modalBody": "이 토큰은 브라우저 세션이 종료되거나 기기를 변경했을 때 이 패드를 삭제할 수 있는 유일한 방법입니다. 안전한 곳에 저장해 두세요. 이 토큰은 여기에 딱 한 번만 표시됩니다.",
|
||||
"pad.deletionToken.copy": "복사",
|
||||
"pad.deletionToken.copied": "복사됨",
|
||||
"pad.deletionToken.acknowledge": "저장했습니다",
|
||||
"pad.deletionToken.deleteWithToken": "토큰으로 패드 삭제",
|
||||
"pad.deletionToken.tokenFieldLabel": "패드 삭제 토큰",
|
||||
"pad.deletionToken.tokenValueLabel": "패드 삭제 토큰(읽기 전용)",
|
||||
"pad.deletionToken.invalid": "이 패드에 유효한 토큰이 아닙니다.",
|
||||
"pad.deletionToken.notCreator": "당신은 이 패드의 제작자가 아니므로 삭제할 수 없습니다.",
|
||||
"pad.settings.about": "정보",
|
||||
"pad.settings.poweredBy": "제공:",
|
||||
"pad.importExport.import_export": "가져오기/내보내기",
|
||||
"pad.importExport.import": "텍스트 파일이나 문서 올리기",
|
||||
"pad.importExport.importSuccessful": "성공!",
|
||||
"pad.importExport.export": "다음으로 현재 패드 내보내기:",
|
||||
"pad.importExport.exportetherpad": "Etherpad",
|
||||
"pad.importExport.exportetherpad": "이더패드",
|
||||
"pad.importExport.exporthtml": "HTML",
|
||||
"pad.importExport.exportplain": "일반 텍스트",
|
||||
"pad.importExport.exportword": "Microsoft 워드",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format, 개방형 문서 형식)",
|
||||
"pad.importExport.exportetherpada.title": "이더패드 형식으로 내보내기",
|
||||
"pad.importExport.exporthtmla.title": "HTML로 내보내기",
|
||||
"pad.importExport.exportplaina.title": "일반 텍스트로 내보내기",
|
||||
"pad.importExport.exportworda.title": "Microsoft Word 파일로 내보내기",
|
||||
"pad.importExport.exportpdfa.title": "PDF로 내보내기",
|
||||
"pad.importExport.exportopena.title": "ODF(Open Document Format) 형식으로 내보내기",
|
||||
"pad.importExport.noConverter.innerHTML": "일반 텍스트, HTML, 마이크로소프트 워드 (.docx) 또는 이더패드 파일을 직접 가져올 수 있습니다. PDF, ODT, DOC 또는 RTF와 같은 다른 형식을 가져오려면 서버 관리자가 리브레오피스를 설치해야 합니다. 자세한 내용은 <a href=\"https://docs.etherpad.org/\">이더패드 설명서</a>를 참고하세요.",
|
||||
"pad.modals.connected": "연결함.",
|
||||
"pad.modals.reconnecting": "내 패드에 다시 연결하는 중...",
|
||||
"pad.modals.forcereconnect": "강제로 다시 연결",
|
||||
|
|
@ -181,9 +371,23 @@
|
|||
"timeslider.pageTitle": "{{appTitle}} 시간슬라이더",
|
||||
"timeslider.toolbar.returnbutton": "패드로 돌아가기",
|
||||
"pad.historyMode.banner": "역사 보는 중",
|
||||
"pad.historyMode.return": "라이브로 돌아가기",
|
||||
"pad.historyMode.revisionLabel": "판 {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "패드 기록 제어",
|
||||
"pad.historyMode.sliderLabel": "패드 판",
|
||||
"pad.historyMode.settings.title": "역사 재생",
|
||||
"pad.historyMode.settings.follow": "패드 콘텐츠의 갱신 주시하기",
|
||||
"pad.historyMode.settings.followShort": "팔로우",
|
||||
"pad.historyMode.followOn": "패드 변경 사항 추적중 — 추적 중지하려면 클릭",
|
||||
"pad.historyMode.followOff": "패드 변경 사항 추적 안함 — 추적하려면 클릭",
|
||||
"pad.historyMode.settings.playbackSpeed": "재생 속도:",
|
||||
"pad.historyMode.chat.replayHeader": "{{time}} 시점의 채팅",
|
||||
"pad.historyMode.users.authorsHeader": "이 판의 작성자",
|
||||
"pad.editor.skipToContent": "편집기로 바로 가기",
|
||||
"pad.editor.keyboardHint": "Esc 키를 눌러 편집기를 종료하세요. Alt+F9 키를 눌러 도구 모음에 액세스하세요.",
|
||||
"pad.editor.toolbar.formatting": "도구 모음 설정",
|
||||
"pad.editor.toolbar.actions": "패드 작업 도구 모음",
|
||||
"pad.editor.toolbar.showMore": "도구 모음 버튼 더 보기",
|
||||
"timeslider.toolbar.authors": "작성자:",
|
||||
"timeslider.toolbar.authorsList": "저자 없음",
|
||||
"timeslider.toolbar.exportlink.title": "내보내기",
|
||||
|
|
@ -217,6 +421,7 @@
|
|||
"pad.savedrevs.timeslider": "당신은 타임슬라이더를 통해 저장된 버전을 볼 수 있습니다",
|
||||
"pad.userlist.entername": "이름을 입력하세요",
|
||||
"pad.userlist.unnamed": "이름없음",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}}명의 연결된 사용자, other: {{count}}명의 연결된 사용자 ]}",
|
||||
"pad.editbar.clearcolors": "전체 문서의 작성자 표시 색상을 지우시겠습니까? 이 작업은 취소할 수 없습니다",
|
||||
"pad.impexp.importbutton": "지금 가져오기",
|
||||
"pad.impexp.importing": "가져오는 중...",
|
||||
|
|
@ -227,5 +432,6 @@
|
|||
"pad.impexp.importfailed": "가져오기를 실패했습니다",
|
||||
"pad.impexp.copypaste": "복사하여 붙여넣으세요",
|
||||
"pad.impexp.exportdisabled": "{{type}} 형식으로 내보내기가 비활성화되어 있습니다. 자세한 내용은 시스템 관리자에게 문의하시기 바랍니다.",
|
||||
"pad.impexp.maxFileSize": "파일의 용량이 너무 큽니다. 가져올 파일의 허용 크기를 늘리려면 사이트 관리자에게 문의하십시오"
|
||||
"pad.impexp.maxFileSize": "파일의 용량이 너무 큽니다. 가져올 파일의 허용 크기를 늘리려면 사이트 관리자에게 문의하십시오",
|
||||
"pad.social.description": "모두가 실시간으로 편집할 수 있는 공동 작업 문서입니다."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"admin_pads.col.users": "Benotzer",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.empty_never_edited": "eidel · nach ni geännert",
|
||||
"admin_pads.error_dialog_description": "Et ass e Feeler geschitt.",
|
||||
"admin_pads.error_prefix": "Feeler",
|
||||
"admin_pads.filter.active": "Aktiv",
|
||||
"admin_pads.filter.empty": "Eidel",
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
"admin_settings": "Astellungen",
|
||||
"admin_settings.current_save.value": "Astellunge späicheren",
|
||||
"admin_settings.invalid_json": "Ongültegen JSON",
|
||||
"admin_settings.toast.auth_error": "Dir sidd net als Admin authentifizéiert. Loggt Iech wgl. nei an.",
|
||||
"admin_settings.env_pill.runtime_label": "aktive Wäert",
|
||||
"admin_settings.page-title": "Astellungen - Etherpad",
|
||||
"admin_settings.save_error": "Feeler beim Späichere vun den Astellungen",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
},
|
||||
"admin.page-title": "Администраторска управувачница — Etherpad",
|
||||
"admin.loading": "Вчитувам…",
|
||||
"admin.loading_description": "Почекајте додека се вчита страницата.",
|
||||
"admin.toggle_sidebar": "Префрли страничник",
|
||||
"admin.shout": "Општење",
|
||||
"admin_shout.online_one": "Моментално има {{count}} корисник на линија",
|
||||
|
|
@ -27,7 +28,11 @@
|
|||
"admin_pads.col.revisions": "Преработки",
|
||||
"admin_pads.col.users": "Корисници",
|
||||
"admin_pads.confirm_button": "ОК",
|
||||
"admin_pads.create_pad_dialog_description": "Изберете име на новата тетратка.",
|
||||
"admin_pads.delete_pad_dialog_description": "Потврдете или откажете го бришењето на тетратката.",
|
||||
"admin_pads.delete_pad_dialog_title": "Избриши тетратка",
|
||||
"admin_pads.empty_never_edited": "празно · никогаш неуредено",
|
||||
"admin_pads.error_dialog_description": "Се појави грешка.",
|
||||
"admin_pads.error_prefix": "Грешка",
|
||||
"admin_pads.filter.active": "Активно",
|
||||
"admin_pads.filter.all": "Сите",
|
||||
|
|
@ -111,16 +116,52 @@
|
|||
"admin_plugins_info.plugins": "Воспоставени приклучоци",
|
||||
"admin_plugins_info.page-title": "Информации за приклучоци — Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Пребарај пресретник или дел…",
|
||||
"admin_plugins_info.subtitle": "Системска дијагностика: воспоставена верзија, регистрирани делови и пресретници.",
|
||||
"admin_plugins_info.tab_client": "Клиент",
|
||||
"admin_plugins_info.tab_server": "Опслужувач",
|
||||
"admin_plugins_info.up_to_date": "Подновено",
|
||||
"admin_plugins_info.update_available": "Достапна поднова: {{version}}",
|
||||
"admin_plugins_info.version": "Верзија на Etherpad",
|
||||
"admin_plugins_info.version_latest": "Најнова достапна верзија",
|
||||
"admin_plugins_info.version_number": "Број на верзијата",
|
||||
"admin_settings": "Нагодувања",
|
||||
"admin_settings.create_pad": "Создај тетратка",
|
||||
"admin_settings.current": "Тековна поставеност",
|
||||
"admin_settings.current_example-devel": "Предлошка за примерни разработни нагодувања",
|
||||
"admin_settings.current_example-prod": "Предлошка за примерни производни нагодувања",
|
||||
"admin_settings.current_restart.value": "Пушти го Etherpad одново",
|
||||
"admin_settings.current_save.value": "Зачувај нагодувања",
|
||||
"admin_settings.invalid_json": "Неважечки JSON",
|
||||
"admin_settings.current_test.value": "Провери го JSON",
|
||||
"admin_settings.current_prettify.value": "Разубави го JSON",
|
||||
"admin_settings.toast.saved": "Нагодувањата се успешно зачувани.",
|
||||
"admin_settings.toast.save_failed": "Зачувувањето на успеа: не можев да запишам во settings.json.",
|
||||
"admin_settings.toast.json_invalid": "Синтаксна грешка: проверете запирки, загради и наводници.",
|
||||
"admin_settings.toast.disconnected": "Не можам да зачуван: не сте поврзани на опслужувачот.",
|
||||
"admin_settings.toast.validation_ok": "JSON е важечки.",
|
||||
"admin_settings.toast.validation_failed": "JSON е неважечки: исправете ги синтаксните грешки.",
|
||||
"admin_settings.toast.prettify_failed": "Не можам да разубавам: прво поправете ги синтаксните грешки.",
|
||||
"admin_settings.prettify_confirm": "Разубавувањето ќе ги отстрани сите коментари. Да продолжам?",
|
||||
"admin_settings.mode.form": "Образец",
|
||||
"admin_settings.mode.raw": "Сирово",
|
||||
"admin_settings.mode.effective": "На дело",
|
||||
"admin_settings.mode.effective_tooltip": "Преглед само за читање на вредностите кои Etherpad всушност во моментов ги користи по околински променливо заменување. Тајните се скриени.",
|
||||
"admin_settings.mode.aria_label": "Уредувачки режим",
|
||||
"admin_settings.envvar_banner.title": "Оваа податотека е предлошка, не поставките во живо.",
|
||||
"admin_settings.envvar_banner.body": "Пополнувачите како ${VAR:default} се заменуваат во складот при покревање; никогаш не се запишуваат назад во оваа податотека. Уредете околински променливи во вашата околина (Docker compose, systemd, .env) за да ја измените произлезената вредност, или пак да го замените пополнувачот тука со буквална низа. Прејдете на јазичето „На дело“ за да видите што во тековно користи Etherpad.",
|
||||
"admin_settings.toast.auth_error": "Не сте заверени како администратор. Најавете се повторно.",
|
||||
"admin_settings.section.general": "Општо",
|
||||
"admin_settings.parse_error.title": "Не можам да го расчленам settings.json",
|
||||
"admin_settings.parse_error.cta": "Префрлете се на сирово за да уредувате",
|
||||
"admin_settings.env_pill.tooltip": "Чита од околинската променлива {{variable}}. Вредноста подолу се користи кога нема зададено ништо за {{variable}}.",
|
||||
"admin_settings.env_pill.default_label": "по основно",
|
||||
"admin_settings.env_pill.input_aria": "Стандардна вредност за {{variable}}",
|
||||
"admin_settings.env_pill.runtime_label": "активна вредност",
|
||||
"admin_settings.env_pill.runtime_tooltip": "Etherpad тековно ја користи оваа вредност, изведена од {{variable}} или основно зададената.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "Etherpad користи вредност за {{variable}}, но таа е скриена бидејќи е тајна.",
|
||||
"admin_settings.page-title": "Нагодувања — Etherpad",
|
||||
"admin_settings.save_error": "Грешка при зачувувањето на нагодувањата",
|
||||
"admin_settings.saved_success": "Успешно зачувани нагодувањата",
|
||||
"update.banner.title": "Достапна е надградба",
|
||||
"update.banner.body": "Достапен е Etherpad {{latest}} (ја користите {{current}}).",
|
||||
"update.banner.cta": "Погл. надградба",
|
||||
|
|
@ -132,8 +173,53 @@
|
|||
"update.page.tier": "Степен на надградба",
|
||||
"update.page.changelog": "Дневник на измени",
|
||||
"update.page.up_to_date": "Ја користите најновата верзија.",
|
||||
"update.page.disabled": "Проверките за поднова се оневозможени (updates.tier = „off“).",
|
||||
"update.page.unauthorized": "Не сте овластени да го гледате статусот на подновеност.",
|
||||
"update.page.error": "Не можев да го вчитам статусот на подновеност (статус {{status}}).",
|
||||
"update.badge.severe": "Etherpad на овој опслужувач е многу застарен. Известете го администраторот.",
|
||||
"update.badge.vulnerable": "Etherpad на овој опслужувач работи на верзија со познати безбедносни проблеми. Известете го администраторот.",
|
||||
"update.page.apply": "Примени поднова",
|
||||
"update.page.cancel": "Откажи",
|
||||
"update.page.acknowledge": "Прифати",
|
||||
"update.page.log": "Дневник на поднови (барем 200 редови)",
|
||||
"update.page.execution": "Статус",
|
||||
"update.page.policy.install-method-not-writable": "Подновите од администраторскиот посредник бараат Git-воспоставка. Подновете преку вашиот управувач со пакети.",
|
||||
"update.page.policy.rollback-failed-terminal": "Претходната поднова не успеа и не можеше да се отповика. Стиснете на „Прифати“ откако воспоставката ќе стане задрава за да отклучите.",
|
||||
"update.page.policy.up-to-date": "Ја користите најновата верзија.",
|
||||
"update.page.policy.tier-off": "Подновие се оневозможени (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Ниво 4 (автономно) бара период на одржување. Задајте го updates.maintenanceWindow во settings.json за да овозможите автономни поднови.",
|
||||
"update.page.policy.maintenance-window-invalid": "Ниво 4 (автономно) е оневозможено бидејќи updates.maintenanceWindow е погрешно срочен. Се очекуваше {start, end, tz} со времиња ЧЧ:ММ и tz да биде „local“ или „utc“.",
|
||||
"update.page.last_result.verified": "Потврдена последната поднова на {{tag}}.",
|
||||
"update.page.last_result.rolled-back": "Отповикан последниот обид за поднова на {{tag}}: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Последниот обид за поднова не успеа И отповикувањето не успеа: {{reason}}. Потребна е рачна интервенција.",
|
||||
"update.page.last_result.preflight-failed": "Не успеа последниот обид за поднова на {{tag}} во подготовката: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Последниот обид за поднова на {{tag}} е откажан од администратор.",
|
||||
"update.execution.idle": "Неактивно",
|
||||
"update.execution.scheduled": "Подновата е закажана",
|
||||
"update.execution.preflight": "Подготвителни проверки",
|
||||
"update.execution.preflight-failed": "Проверките не успеаја",
|
||||
"update.execution.draining": "Исцрпувачки седници",
|
||||
"update.execution.executing": "Подновувам...",
|
||||
"update.execution.pending-verification": "Чека на потврда",
|
||||
"update.execution.verified": "Потврдено",
|
||||
"update.execution.rolling-back": "Отповикување",
|
||||
"update.execution.rolled-back": "Отповикано",
|
||||
"update.execution.rollback-failed": "Отповикувањето не успеа",
|
||||
"update.banner.terminal.rollback-failed": "Не успеа обидот за поднова и не можеше да биде отповикан. Потребна е рачна интервенција.",
|
||||
"update.banner.scheduled": "Автоподновата на {{tag}} е закажана — на сила за {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Автономните поднови се оневозможени додека не се постави период на одржување.",
|
||||
"update.banner.maintenance-window-invalid": "Автономните поднови се оневозможени бидејќи периодот на одржување е погрешно срочен.",
|
||||
"update.page.scheduled.title": "Подновата е закажана",
|
||||
"update.page.scheduled.countdown": "Etherpad ќе почне со подновување на {{tag}} за {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Вон периодот на одржување. Подновата ќе почне кога периодот ќе започне во {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Примени сега",
|
||||
"update.window.title": "Период на одржување",
|
||||
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||
"update.window.unset": "Не е наместено.",
|
||||
"update.window.next_opens_at": "Следниот период почнува на {{at}}.",
|
||||
"update.drain.t60": "Etherpad ќе се превклучи за 60 секунди за да направи примена и поднова.",
|
||||
"update.drain.t30": "Etherpad ќе се превклучи за 30 секунди за да направи примена и поднова.",
|
||||
"update.drain.t10": "Etherpad ќе се превклучи за 10 секунди за да направи примена и поднова.",
|
||||
"index.newPad": "Нова тетратка",
|
||||
"index.settings": "Нагодувања",
|
||||
"index.transferSessionTitle": "Префрли седница",
|
||||
|
|
@ -146,6 +232,7 @@
|
|||
"index.copyLinkButton": "Копирај врска во меѓускладот",
|
||||
"index.transferToSystem": "3. Копирај седница во нов систем",
|
||||
"index.transferToSystemDescription": "Отворете ја ископираната врска во целниот прелистувач или уред за да ја префрлите вашата седница.",
|
||||
"index.code": "Код",
|
||||
"index.transferSessionDescription": "Префрлете ја вашата тековна седница на прелистувач или уред стискајќи на копчето подолу. Ова ќе ја прекопира врската во страница која ќе ви ја префрли седницата кога ќе се отвори во целниот прелистувач или уред.",
|
||||
"index.createOpenPad": "Отвори тетратка по име",
|
||||
"index.openPad": "отвори постоечка тетратка наречена:",
|
||||
|
|
@ -220,6 +307,12 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Извези како Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Извези како HTML",
|
||||
"pad.importExport.exportplaina.title": "Извези како прост текст",
|
||||
"pad.importExport.exportworda.title": "Извези како Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Извези како PDF",
|
||||
"pad.importExport.exportopena.title": "Извези како ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Можете да увезувате само од прост текст или HTML-форматти. Понапредни можности за увоз ќе добиете ако <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">го воспоставите LibreOffice</a>.",
|
||||
"pad.modals.connected": "Поврзано.",
|
||||
"pad.modals.reconnecting": "Ве преповрзувам со тетратката...",
|
||||
|
|
@ -265,13 +358,31 @@
|
|||
"timeslider.followContents": "Следи ги подновите во содржината на тетратката",
|
||||
"timeslider.pageTitle": "{{appTitle}} Историски преглед",
|
||||
"timeslider.toolbar.returnbutton": "Назад на тетратката",
|
||||
"pad.historyMode.banner": "Историја на посети",
|
||||
"pad.historyMode.return": "Назад во живо",
|
||||
"pad.historyMode.revisionLabel": "Преработка {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Контроли за историја на тетратката",
|
||||
"pad.historyMode.sliderLabel": "Преработка на тетратката",
|
||||
"pad.historyMode.settings.title": "Преглед на историјата",
|
||||
"pad.historyMode.settings.follow": "Следи поднови на содржини тетратки",
|
||||
"pad.historyMode.settings.followShort": "Следи",
|
||||
"pad.historyMode.followOn": "Следите промени во тетратката — стиснете за да престанете со следење",
|
||||
"pad.historyMode.followOff": "Не следите промени во тетратката — стиснете за да следите",
|
||||
"pad.historyMode.settings.playbackSpeed": "Брзина на прегледувањето:",
|
||||
"pad.historyMode.chat.replayHeader": "Разговор од {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Авроти на оваа преработка",
|
||||
"pad.editor.skipToContent": "Прејди на уредувачот",
|
||||
"pad.editor.keyboardHint": "Стиснете Escape за да излезете од уредувачот. Стиснете Alt+F9 за алатникот.",
|
||||
"pad.editor.toolbar.formatting": "Алатник за форматирање",
|
||||
"pad.editor.toolbar.actions": "Алатник за дејства врз тетратка",
|
||||
"pad.editor.toolbar.showMore": "Покажи повеќе копчиња на алатникот",
|
||||
"timeslider.toolbar.authors": "Автори:",
|
||||
"timeslider.toolbar.authorsList": "Нема автори",
|
||||
"timeslider.toolbar.exportlink.title": "Извоз",
|
||||
"timeslider.exportCurrent": "Извези ја тековната верзија како:",
|
||||
"timeslider.version": "Верзија {{version}}",
|
||||
"timeslider.saved": "Зачувано на {{day}} {{month}} {{year}} г.",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на репродукција:",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на прегледување:",
|
||||
"timeslider.settings.playbackSpeed.original": "Изворна брзина",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Реално време",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 мс",
|
||||
|
|
@ -298,6 +409,7 @@
|
|||
"pad.savedrevs.timeslider": "Можете да ги погледате зачуваните преработки посетувајќи го времеследниот лизгач",
|
||||
"pad.userlist.entername": "Внесете го вашето име",
|
||||
"pad.userlist.unnamed": "без име",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} поврзан корисник, other: {{count}} поврзани корисници ]}",
|
||||
"pad.editbar.clearcolors": "Да ги отстранам авторските бои од целиот документ? Ова е неповратно",
|
||||
"pad.impexp.importbutton": "Увези сега",
|
||||
"pad.impexp.importing": "Увезувам...",
|
||||
|
|
|
|||
|
|
@ -28,17 +28,17 @@
|
|||
"pad.modals.cancel": "Xikxolewa",
|
||||
"pad.modals.deleted": "Omopohpoloh.",
|
||||
"pad.modals.deleted.explanation": "Ōmopoloh inīn Pad.",
|
||||
"timeslider.version": "Inīc {{version}} Cuepaliztli",
|
||||
"timeslider.month.january": "Eneroh",
|
||||
"timeslider.month.february": "Febreroh",
|
||||
"timeslider.month.march": "Marsoh",
|
||||
"timeslider.month.april": "April",
|
||||
"timeslider.month.may": "Mayoh",
|
||||
"timeslider.month.june": "Honioh",
|
||||
"timeslider.month.july": "Holioh",
|
||||
"timeslider.month.august": "Ahostoh",
|
||||
"timeslider.month.september": "Septiempreh",
|
||||
"timeslider.month.october": "Oktopreh",
|
||||
"timeslider.month.november": "Noviempreh",
|
||||
"timeslider.month.december": "Tisiempreh"
|
||||
"timeslider.version": "Inic {{version}} Tlacepaliztli",
|
||||
"timeslider.month.january": "Enero",
|
||||
"timeslider.month.february": "Febrero",
|
||||
"timeslider.month.march": "Marzo",
|
||||
"timeslider.month.april": "Abril",
|
||||
"timeslider.month.may": "Mayo",
|
||||
"timeslider.month.june": "Junio",
|
||||
"timeslider.month.july": "Julio",
|
||||
"timeslider.month.august": "Agosto",
|
||||
"timeslider.month.september": "Septiembre",
|
||||
"timeslider.month.october": "Octubre",
|
||||
"timeslider.month.november": "Noviembre",
|
||||
"timeslider.month.december": "Diciembre"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
},
|
||||
"admin.page-title": "Beheerdashboard – Etherpad",
|
||||
"admin.loading": "Bezig met laden...",
|
||||
"admin.loading_description": "Even geduld, de pagina wordt geladen.",
|
||||
"admin.toggle_sidebar": "Zijbalk omschakelen",
|
||||
"admin.shout": "Communicatie",
|
||||
"admin_shout.online_one": "Er zijn momenteel {{count}} gebruikers online",
|
||||
|
|
@ -39,7 +40,11 @@
|
|||
"admin_pads.col.revisions": "Versies",
|
||||
"admin_pads.col.users": "Gebruikers",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Kies een naam voor de nieuwe notitie.",
|
||||
"admin_pads.delete_pad_dialog_description": "Verwijderen van de notitie bevestigen of annuleren.",
|
||||
"admin_pads.delete_pad_dialog_title": "Notitie verwijderen",
|
||||
"admin_pads.empty_never_edited": "leeg · nooit bewerkt",
|
||||
"admin_pads.error_dialog_description": "Er is een fout opgetreden.",
|
||||
"admin_pads.error_prefix": "Fout",
|
||||
"admin_pads.filter.active": "Actief",
|
||||
"admin_pads.filter.all": "Alle",
|
||||
|
|
@ -151,7 +156,12 @@
|
|||
"admin_settings.prettify_confirm": "Met opmaken worden alle opmerkingen verwijderd. Doorgaan?",
|
||||
"admin_settings.mode.form": "Formulier",
|
||||
"admin_settings.mode.raw": "Ruw",
|
||||
"admin_settings.mode.effective": "Actief",
|
||||
"admin_settings.mode.effective_tooltip": "Alleen-lezen weergave van de waarden die Etherpad momenteel daadwerkelijk gebruikt, na vervanging van omgevingsvariabelen. Geheimen zijn verwijderd.",
|
||||
"admin_settings.mode.aria_label": "Bewerkingsmodus",
|
||||
"admin_settings.envvar_banner.title": "Dit bestand is een sjabloon, niet de daadwerkelijke configuratie.",
|
||||
"admin_settings.envvar_banner.body": "Plaatsaanduidingen zoals ${VAR:default} worden bij het opstarten in het geheugen geladen; ze worden nooit naar dit bestand teruggeschreven. Bewerk omgevingsvariabelen in uw omgeving (Docker Compose, systemd, .env) om de opgeloste waarde te wijzigen, of vervang de plaatsaanduiding hier door een letterlijke waarde. Ga naar het tabblad Actief om te zien wat Etherpad momenteel gebruikt.",
|
||||
"admin_settings.toast.auth_error": "U bent niet aangemeld als beheerder. Meld alstublieft opnieuw aan.",
|
||||
"admin_settings.section.general": "Algemeen",
|
||||
"admin_settings.parse_error.title": "Kan settings.json niet verwerken",
|
||||
"admin_settings.parse_error.cta": "Overschakelen naar ruw bewerken",
|
||||
|
|
@ -186,7 +196,7 @@
|
|||
"update.page.log": "Updatelogboek (laatste 200 regels)",
|
||||
"update.page.execution": "Status",
|
||||
"update.page.policy.install-method-not-writable": "Updates via de beheerdersinterface vereisen een Git-installatie. Update via uw pakketbeheerder.",
|
||||
"update.page.policy.rollback-failed-terminal": "Een eerdere update is mislukt en kon niet ongedaan worden gemaakt. Druk op 'Bevestigen' nadat de installatie succesvol is voltooid om de vergrendeling op te heffen.",
|
||||
"update.page.policy.rollback-failed-terminal": "Een eerdere update is mislukt en kon niet ongedaan worden gemaakt. Druk op 'Bevestigen' nadat de installatie is voltooid om de vergrendeling op te heffen.",
|
||||
"update.page.policy.up-to-date": "U gebruikt de nieuwste versie.",
|
||||
"update.page.policy.tier-off": "Updates zijn uitgeschakeld (updates.tier = \"off\").",
|
||||
"update.page.policy.maintenance-window-missing": "Niveau 4 (autonoom) vereist een onderhoudsvenster. Stel updates.maintenanceWindow in settings.json in om autonoom bijwerken in te schakelen.",
|
||||
|
|
@ -234,6 +244,7 @@
|
|||
"index.copyLinkButton": "Koppeling naar klembord kopiëren",
|
||||
"index.transferToSystem": "3. Kopieer de sessie naar het nieuwe systeem",
|
||||
"index.transferToSystemDescription": "Open de gekopieerde koppeling in de doelbrowser of het doelapparaat om uw sessie over te zetten.",
|
||||
"index.code": "Code",
|
||||
"index.transferSessionDescription": "Zet uw huidige sessie over naar uw browser of apparaat door op de onderstaande knop te klikken. Hiermee wordt een koppeling naar een pagina gekopieerd die uw sessie overzet wanneer deze in de gewenste browser of op het gewenste apparaat wordt geopend.",
|
||||
"index.createOpenPad": "Notitie openen met de naam",
|
||||
"index.openPad": "open een bestaande notitie met de naam:",
|
||||
|
|
@ -295,7 +306,7 @@
|
|||
"pad.deletionToken.tokenFieldLabel": "Padverwijderingstoken",
|
||||
"pad.deletionToken.tokenValueLabel": "Uw padverwijderingstoken (alleen-lezen)",
|
||||
"pad.deletionToken.invalid": "Dat token is niet geldig voor deze notitie.",
|
||||
"pad.deletionToken.notCreator": "U bent niet de maker van dit notitieblok, dus je kunt het niet verwijderen.",
|
||||
"pad.deletionToken.notCreator": "U bent niet de maker van dit notitieblok, dus u kunt het niet verwijderen.",
|
||||
"pad.settings.about": "Over",
|
||||
"pad.settings.poweredBy": "Mogelijk gemaakt door",
|
||||
"pad.importExport.import_export": "Importeren/exporteren",
|
||||
|
|
|
|||
|
|
@ -7,13 +7,71 @@
|
|||
},
|
||||
"admin.page-title": "Cruscòt d'aministrator - Etherpad",
|
||||
"admin.loading": "Antramentre ch'as caria…",
|
||||
"admin.loading_description": "Për piasì, ch'a speta antramentre che la pàgina as caria.",
|
||||
"admin.toggle_sidebar": "Ativé o disativé la bara lateral",
|
||||
"admin.shout": "Comunicassion",
|
||||
"admin_shout.online_one": "Al moment a-i é {{count}} utent an linia",
|
||||
"admin_shout.online_other": "Al moment a-i son {{count}} utent an linia",
|
||||
"admin_shout.sticky_toggle": "Cangé ël mëssagi an evidensa",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Stranòm",
|
||||
"admin_login.password": "Ciav",
|
||||
"admin_login.submit": "Intré ant ël sistema",
|
||||
"admin_login.failed": "Falì a intré ant ël sistema",
|
||||
"admin_pads.all_pads": "Tuti ij blochèt",
|
||||
"admin_pads.bulk.cleanup_history": "Dëscancelé la stòria",
|
||||
"admin_pads.bulk.clear_selection": "Dëscancelé la selession",
|
||||
"admin_pads.bulk.delete": "Dëscancelé",
|
||||
"admin_pads.cancel": "Anulé",
|
||||
"admin_pads.col.pad": "Blochèt",
|
||||
"admin_pads.col.revisions": "Revision",
|
||||
"admin_pads.col.users": "Utent",
|
||||
"admin_pads.confirm_button": "Va bin",
|
||||
"admin_pads.create_pad_dialog_description": "Serne un nòm për ël neuv blochèt.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirmé o anulé la dëscancelassion dël blochèt.",
|
||||
"admin_pads.delete_pad_dialog_title": "Dëscancelé ël blochèt",
|
||||
"admin_pads.empty_never_edited": "veuid · mai modificà",
|
||||
"admin_pads.error_dialog_description": "A l'é rivaje n'eror.",
|
||||
"admin_pads.error_prefix": "Eror",
|
||||
"admin_pads.filter.active": "Ativ",
|
||||
"admin_pads.filter.all": "Tuti",
|
||||
"admin_pads.filter.empty": "Veuid",
|
||||
"admin_pads.filter.recent": "Costa sman-a",
|
||||
"admin_pads.filter.stale": "Blocà (>1a)",
|
||||
"admin_pads.open": "Duverté",
|
||||
"admin_pads.pagination.next": "Sucessiv",
|
||||
"admin_pads.pagination.previous": "Precedent",
|
||||
"admin_pads.refresh": "Rinfrësché",
|
||||
"admin_pads.relative.days": "{{count}}d fa",
|
||||
"admin_pads.relative.hours": "{{count}}o fa",
|
||||
"admin_pads.relative.just_now": "pròpi adess",
|
||||
"admin_pads.relative.minutes": "{{count}}m fa",
|
||||
"admin_pads.relative.months": "{{count}}mè fa",
|
||||
"admin_pads.relative.weeks": "{{count}}s fa",
|
||||
"admin_pads.relative.years": "{{count}}a fa",
|
||||
"admin_pads.revisions_count": "{{count}} revision",
|
||||
"admin_pads.selected_count": "{{count}} selessionà",
|
||||
"admin_pads.show": "Smon-e",
|
||||
"admin_pads.sort.name": "Nòm (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Revision",
|
||||
"admin_pads.sort.user_count": "Utent",
|
||||
"admin_pads.stats.across_pads": "a travers tuti ij blochèt",
|
||||
"admin_pads.stats.active_users": "Utent ativ",
|
||||
"admin_pads.stats.empty_pads": "Blochèt veuid",
|
||||
"admin_pads.stats.last_activity": "Ùltima atività",
|
||||
"admin_pads.stats.no_active_users": "Gnun utent ativ",
|
||||
"admin_pads.stats.revisions_zero": "0 revision",
|
||||
"admin_pads.stats.total": "Blochèt totaj",
|
||||
"admin_pads.stats.users_active": "{{count}} ativ al moment",
|
||||
"admin_pads.subtitle": "Vista global ëd tuti ij blochèt ansima a costa istansa d'Etherpad. Arserché, polidé, duverté.",
|
||||
"admin_plugins": "Mansé dj'anstalassion",
|
||||
"admin_plugins.available": "Anstalassion disponìbij",
|
||||
"admin_plugins.available_not-found": "Gnun-e anstalassion trovà.",
|
||||
"admin_plugins.available_fetching": "Arcuperassion…",
|
||||
"admin_plugins.available_install.value": "Anstalé",
|
||||
"admin_plugins.available_search.placeholder": "Arserca d'aplicassion da anstalé",
|
||||
"admin_plugins.check_updates": "Controlé për dj'agiornament",
|
||||
"admin_plugins.core_count": "{{count}} nos",
|
||||
"admin_plugins.description": "Descrission",
|
||||
"admin_plugins.installed": "Aplicassion anstalà",
|
||||
"admin_plugins.installed_fetching": "Arcuperassion dj'aplicassion anstalà…",
|
||||
|
|
|
|||
|
|
@ -5,19 +5,113 @@
|
|||
"شاه زمان پټان"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "پازوال ليددړه - اېترپيډ",
|
||||
"admin.loading": "بارېږي...",
|
||||
"admin.loading_description": "مهرباني وکړئ د مخ بارېدلو پرمهال په تمه شئ.",
|
||||
"admin.toggle_sidebar": "څنگپټه بدلول",
|
||||
"admin.shout": "اړيکتيا",
|
||||
"admin_shout.online_one": "اوسمهال {{count}} کارنان پرليکه دي",
|
||||
"admin_shout.online_other": "اوسمهال {{count}} کارنان پرليکه دي",
|
||||
"admin_login.title": "اېترپيډ",
|
||||
"admin_login.username": "کارننوم",
|
||||
"admin_login.password": "پټنوم",
|
||||
"admin_login.submit": "ننوتل",
|
||||
"admin_login.failed": "ننوتل نابرياله شول",
|
||||
"admin_pads.all_pads": "ټولې ليکچې",
|
||||
"admin_pads.bulk.cleanup_history": "پېښليک سپينول",
|
||||
"admin_pads.bulk.clear_selection": "ټاکنه سپاکول",
|
||||
"admin_pads.bulk.delete": "ړنگول",
|
||||
"admin_pads.cancel": "ناگارل",
|
||||
"admin_pads.col.pad": "ليکچه",
|
||||
"admin_pads.col.revisions": "بياليدنې",
|
||||
"admin_pads.col.users": "کارنان",
|
||||
"admin_pads.confirm_button": "ښه",
|
||||
"admin_pads.create_pad_dialog_description": "نوې ليکچې ته نوم خوښ کړئ.",
|
||||
"admin_pads.delete_pad_dialog_description": "د ليکچې د ړنگول تاييد يا ناگارل",
|
||||
"admin_pads.delete_pad_dialog_title": "ليکچه ړنگول",
|
||||
"admin_pads.empty_never_edited": "تش . هېڅکله سم شوی نه دی",
|
||||
"admin_pads.error_dialog_description": "یوه تېروتنه رامنځته شوې.",
|
||||
"admin_pads.error_prefix": "تېروتنه",
|
||||
"admin_pads.filter.active": "کارنده",
|
||||
"admin_pads.filter.all": "ټول",
|
||||
"admin_pads.filter.empty": "تش",
|
||||
"admin_pads.filter.recent": "دا اوونۍ",
|
||||
"admin_pads.open": "پرانېستل",
|
||||
"admin_pads.pagination.next": "راتلونکی",
|
||||
"admin_pads.pagination.previous": "مخکنی",
|
||||
"admin_pads.refresh": "تاندول",
|
||||
"admin_pads.relative.days": "{{count}} ورځې دمخه",
|
||||
"admin_pads.relative.hours": "{{count}} گړۍ دمخه",
|
||||
"admin_pads.relative.just_now": "همدا اوس",
|
||||
"admin_pads.relative.minutes": "{{count}} دقيقې دمخه",
|
||||
"admin_pads.relative.months": "{{count}} مياشتې دمخه",
|
||||
"admin_pads.relative.weeks": "{{count}} اوونۍ دمخه",
|
||||
"admin_pads.relative.years": "{{count}} کاله دمخه",
|
||||
"admin_pads.revisions_count": "{{count}} بياليدنې",
|
||||
"admin_pads.selected_count": "{{count}} غورهشوي",
|
||||
"admin_pads.show": "ښودل",
|
||||
"admin_pads.sort.name": "نوم (ا ـ ي)",
|
||||
"admin_pads.sort.revision_number": "بياليدنې",
|
||||
"admin_pads.sort.user_count": "کارنان",
|
||||
"admin_pads.stats.across_pads": "په ټولو ليکچو کې",
|
||||
"admin_pads.stats.active_users": "کارنده کارنان",
|
||||
"admin_pads.stats.empty_pads": "تشې ليکچې",
|
||||
"admin_pads.stats.last_activity": "وروستۍ کړنه",
|
||||
"admin_pads.stats.no_active_users": "هېڅ کارنده کارنان نشته",
|
||||
"admin_pads.stats.revisions_zero": "۰ بياليدنې",
|
||||
"admin_pads.stats.total": "ټولټال ليکچې",
|
||||
"admin_pads.stats.users_active": "وسمهال {{count}} کارنده",
|
||||
"admin_plugins.description": "څرگنداوی",
|
||||
"admin_plugins.disables.label": "ناچارنشوي:",
|
||||
"admin_plugins.last-update": "وروستۍ هممهالېدنه",
|
||||
"admin_plugins.name": "نوم",
|
||||
"admin_plugins.sort.last_updated": "وروستۍ هممهالېدنه",
|
||||
"admin_plugins.sort.name": "نوم (ا ـ ي)",
|
||||
"admin_plugins.sort.version": "بلبڼه",
|
||||
"admin_plugins.update_tooltip": "هممهالول",
|
||||
"admin_plugins.updates_available": "هممهالېدنې د لاسرسي وړ دي",
|
||||
"admin_plugins.update_now": "هممهالول",
|
||||
"admin_plugins.version": "بلبڼه",
|
||||
"admin_plugins_info": "ستونزو اواري مالومات",
|
||||
"admin_plugins_info.copy_value": "{{label}} لمېسل",
|
||||
"admin_plugins_info.hooks": "ځایپرځایشوي چنگکونه",
|
||||
"admin_plugins_info.tab_client": "غوښتندوی",
|
||||
"admin_plugins_info.tab_server": "پالنگر",
|
||||
"admin_plugins_info.up_to_date": "هم مهاله شوی",
|
||||
"admin_plugins_info.update_available": "هممهالېدنه شتون لري: {{version}}",
|
||||
"admin_plugins_info.version_latest": "وروستۍ د لاسرسي وړ بلبڼه",
|
||||
"admin_plugins_info.version_number": "بلبڼې شمېره",
|
||||
"admin_settings": "اوڼنې",
|
||||
"admin_settings.create_pad": "ليکنه جوړول",
|
||||
"admin_settings.current": "اوسنی ترتيب",
|
||||
"admin_settings.current_example-devel": "د پراختيااوڼنې کينډۍ بېلگه",
|
||||
"admin_settings.current_example-prod": "د توليد اوڼنې کينډۍ بېلگه",
|
||||
"admin_settings.current_save.value": "اوڼنې خونديکول",
|
||||
"admin_settings.mode.form": "فورمه",
|
||||
"admin_settings.mode.raw": "اومه",
|
||||
"admin_settings.mode.effective": "اغېزمن",
|
||||
"admin_settings.mode.aria_label": "سمونگر ونگډول",
|
||||
"admin_settings.section.general": "ټولواله",
|
||||
"admin_settings.env_pill.default_label": "تلواله",
|
||||
"admin_settings.env_pill.runtime_label": "کارنده ارزښت",
|
||||
"update.banner.title": "هممهالېدنه شتون لري",
|
||||
"update.banner.cta": "هممهالېدنه کتل",
|
||||
"update.page.current": "اوسنۍ بلبڼه",
|
||||
"update.page.latest": "اوسنۍ بلبڼه",
|
||||
"update.page.apply": "هممهالېدنه پلېکول",
|
||||
"update.page.cancel": "ناگارل",
|
||||
"update.page.execution": "دريځ",
|
||||
"update.execution.executing": "هممهالول...",
|
||||
"update.execution.pending-verification": "د تاييد په تمه",
|
||||
"update.execution.verified": "تایید شوی",
|
||||
"index.newPad": "نوې ليکچه",
|
||||
"index.settings": "اوڼنې",
|
||||
"index.transferSessionTitle": "لېږد غونډه",
|
||||
"index.receiveSessionTitle": "غونډې ته لاسرسی",
|
||||
"index.transferSession": "۱. لېږد غونډه",
|
||||
"index.copyLink": "۲. تړونی لمېسل",
|
||||
"index.copyLinkButton": "تړونی ټينگدړې ته لمېسل",
|
||||
"index.code": "کوډ",
|
||||
"index.createOpenPad": "ليکچه د نوم له مخې پرانېستل",
|
||||
"index.recentPads": "وروستۍ ليکچې",
|
||||
"index.recentPadsEmpty": "هېڅ وروستۍ ليکچې ونه موندل شوې.",
|
||||
|
|
@ -34,14 +128,18 @@
|
|||
"pad.toolbar.undo.title": "ناکړل (Ctrl-Z)",
|
||||
"pad.toolbar.redo.title": "بياکړل (Ctrl-Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "د ليکوالۍ رنگونه سپينول (Ctrl+Shift+C)",
|
||||
"pad.toolbar.savedRevision.title": "مخکتنه خوندي کول",
|
||||
"pad.toolbar.savedRevision.title": "بياليدنه خونديکول",
|
||||
"pad.toolbar.settings.title": "اوڼنې",
|
||||
"pad.toolbar.home.title": "بېرته کور ته",
|
||||
"pad.colorpicker.save": "خوندي کول",
|
||||
"pad.colorpicker.cancel": "ناگارل",
|
||||
"pad.loading": "رابرسېرېږي...",
|
||||
"pad.settings.padSettings": "د ليکچې اوڼنې",
|
||||
"pad.settings.title": "اوڼنې",
|
||||
"pad.settings.padSettings": "د ليکچې-پراخ اوڼنې",
|
||||
"pad.settings.userSettings": "کارن اوڼنې",
|
||||
"pad.settings.myView": "زما کتنه",
|
||||
"pad.settings.disablechat": "بنډار ناچارنول",
|
||||
"pad.settings.darkMode": "تياره ونگډول",
|
||||
"pad.settings.stickychat": "تل په پردې بانډار کول",
|
||||
"pad.settings.chatandusers": "کارنان او بانډار ښکاره کول",
|
||||
"pad.settings.colorcheck": "د ليکوالۍ رنگونه",
|
||||
|
|
@ -49,52 +147,71 @@
|
|||
"pad.settings.fontType": "ليکبڼې ډول:",
|
||||
"pad.settings.fontType.normal": "نورمال",
|
||||
"pad.settings.language": "ژبه:",
|
||||
"pad.settings.deletePad": "ليکچه ړنگول",
|
||||
"pad.delete.confirm": "ايا تاسو په رښتيا دا ليکچه ړنگول غواړئ؟",
|
||||
"pad.deletionToken.copy": "لمېسل",
|
||||
"pad.deletionToken.copied": "ولمېسل شو",
|
||||
"pad.deletionToken.acknowledge": "ما دا خوندي کړی دی",
|
||||
"pad.settings.about": "پهاړه",
|
||||
"pad.settings.poweredBy": "چلوونکی",
|
||||
"pad.importExport.import_export": "رالېږدول/بهرلېږل",
|
||||
"pad.importExport.importSuccessful": "بريالی شو!",
|
||||
"pad.importExport.exportetherpad": "اېترپډ",
|
||||
"pad.importExport.export": "اوسنۍ ليکچه په لاندې ډول بهرلېږل:",
|
||||
"pad.importExport.exportetherpad": "اېترپيډ",
|
||||
"pad.importExport.exporthtml": "اچ ټي ام اېل",
|
||||
"pad.importExport.exportplain": "ساده متن",
|
||||
"pad.importExport.exportplain": "ساده ليک",
|
||||
"pad.importExport.exportword": "مايکروسافټ ورډ",
|
||||
"pad.importExport.exportpdf": "پي ډي اېف",
|
||||
"pad.importExport.exportopen": "ODF (اوپن ډاکومنټ فارمټ)",
|
||||
"pad.importExport.exporthtmla.title": "اېچټياېماېل په توگه بهرلېږل",
|
||||
"pad.importExport.exportplaina.title": "ساده ليک په توگه بهرلېږل",
|
||||
"pad.importExport.exportworda.title": "مايکروسافټ ورډ په توگه بهرلېږل",
|
||||
"pad.importExport.exportpdfa.title": "پيډياېف په توگه بهرلېږل",
|
||||
"pad.modals.connected": "اړيکمن شو.",
|
||||
"pad.modals.reconnecting": "بېرته ستاسو ليکې سره پېلېږي...",
|
||||
"pad.modals.forcereconnect": "په زوره بيانښلونه",
|
||||
"pad.modals.reconnecttimer": "د بيانښلولو هڅهکول",
|
||||
"pad.modals.cancel": "ناگارل",
|
||||
"pad.modals.userdup": "په بله کړکۍ کې پرانېستل شو",
|
||||
"pad.modals.slowcommit.explanation": "پالنگر ځواب نه وايي.",
|
||||
"pad.modals.slowcommit.cause": "دا کېدای شي د جال د اړيکتيايي ستونزو په سبب وي.",
|
||||
"pad.modals.deleted": "ړنگ شو.",
|
||||
"pad.gritter.unacceptedCommit.title": "ناخوندي شوی سمون",
|
||||
"pad.share": "دا ليکچه شريکول",
|
||||
"pad.share.readonly": "يوازې لوستنه",
|
||||
"pad.share.link": "تړونی",
|
||||
"pad.share.emebdcode": "يو آر اېل ټومبل",
|
||||
"pad.share.emebdcode": "وېبتړ ټومبل",
|
||||
"pad.chat": "بانډار",
|
||||
"pad.chat.loadmessages": "نور پيغامونه برسېرول",
|
||||
"pad.historyMode.banner": "پېښليک کتل",
|
||||
"pad.historyMode.revisionLabel": "بياليدنه {{rev}}",
|
||||
"pad.historyMode.sliderLabel": "د ليکچې بياليدنه",
|
||||
"pad.historyMode.settings.followShort": "څارل",
|
||||
"timeslider.toolbar.authors": "ليکوال:",
|
||||
"timeslider.toolbar.authorsList": "بې ليکواله",
|
||||
"timeslider.toolbar.exportlink.title": "صادرول",
|
||||
"timeslider.version": "بڼه {{version}}",
|
||||
"timeslider.toolbar.exportlink.title": "بهرلېږل",
|
||||
"timeslider.version": "بلبڼه {{version}}",
|
||||
"timeslider.saved": "خوندي شو {{month}} {{day}}, {{year}}",
|
||||
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
|
||||
"timeslider.month.january": "جنوري",
|
||||
"timeslider.month.february": "فبروري",
|
||||
"timeslider.month.february": "فېبروري",
|
||||
"timeslider.month.march": "مارچ",
|
||||
"timeslider.month.april": "اپرېل",
|
||||
"timeslider.month.may": "مۍ",
|
||||
"timeslider.month.april": "اپریل",
|
||||
"timeslider.month.may": "می",
|
||||
"timeslider.month.june": "جون",
|
||||
"timeslider.month.july": "جولای",
|
||||
"timeslider.month.august": "اگسټ",
|
||||
"timeslider.month.september": "سېپتمبر",
|
||||
"timeslider.month.october": "اکتوبر",
|
||||
"timeslider.month.november": "نومبر",
|
||||
"timeslider.month.december": "ډيسمبر",
|
||||
"timeslider.month.december": "ډېسمبر",
|
||||
"timeslider.unnamedauthors": "{{num}} بې نومه {[ډېرگړي(num) يو: ليکوال، نور: ليکوالان ]}",
|
||||
"pad.savedrevs.marked": "اوس دا مخکتنه د يوې خوندي شوې مخکتنې په توگه په نښه شوه",
|
||||
"pad.userlist.entername": "نوم مو ورکړۍ",
|
||||
"pad.savedrevs.marked": "اوس دا بياليدنه د يوې خوندي شوې بياليدنې په توگه په نښه شوه",
|
||||
"pad.userlist.entername": "نوم مو ورکړئ",
|
||||
"pad.userlist.unnamed": "بې نومه",
|
||||
"pad.impexp.importbutton": "اوس واردول",
|
||||
"pad.impexp.importing": "په واردولو کې دی...",
|
||||
"pad.impexp.importbutton": "اوس رالېږدول",
|
||||
"pad.impexp.importing": "رالېدول...",
|
||||
"pad.impexp.uploadFailed": "راپورتهکول نابرياله شول، مهرباني وکړئ بيا هڅه وکړئ.",
|
||||
"pad.impexp.importfailed": "رالېږدول نابرياله شو",
|
||||
"pad.impexp.copypaste": "لطفاً لمېسل لېښل ترسره کړئ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
"AmaryllisGardener",
|
||||
"CiphriusKane",
|
||||
"John Reid",
|
||||
"Nintendofan885"
|
||||
"Nintendofan885",
|
||||
"Tensama0415"
|
||||
]
|
||||
},
|
||||
"index.newPad": "New Pad",
|
||||
|
|
@ -72,7 +73,7 @@
|
|||
"pad.modals.corruptPad.cause": "This micht be cause o ae wrang server confeeguration or some ither onexpected behavior. Please contact the service admeenistrater.",
|
||||
"pad.modals.deleted": "Deletit.",
|
||||
"pad.modals.deleted.explanation": "This pad has been hif't.",
|
||||
"pad.modals.disconnected": "Ye'v been disconnected.",
|
||||
"pad.modals.disconnected": "切断されました。",
|
||||
"pad.modals.disconnected.explanation": "The connection til the server wis loast",
|
||||
"pad.modals.disconnected.cause": "The server micht be onavailable. Please notify the service admeenistrater gif this continues tae happen.",
|
||||
"pad.share": "Share this pad",
|
||||
|
|
|
|||
|
|
@ -7,17 +7,85 @@
|
|||
"Mark",
|
||||
"Rudko",
|
||||
"Teslaton",
|
||||
"Wizzard",
|
||||
"Yardom78"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel správu - Etherpad",
|
||||
"admin.loading": "Načítava sa…",
|
||||
"admin.loading_description": "Počkajte, prosím, kým sa stránka načíta.",
|
||||
"admin.toggle_sidebar": "Prepnúť bočný panel",
|
||||
"admin.shout": "Komunikácia",
|
||||
"admin_shout.online_one": "Momentálne je online {{count}} používateľ",
|
||||
"admin_shout.online_other": "Momentálne je online {{count}} používateľov",
|
||||
"admin_shout.sticky_toggle": "Zmeniť pripnutú správu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Používateľské meno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Prihlásiť sa",
|
||||
"admin_login.failed": "Prihlásenie zlyhalo",
|
||||
"admin_pads.all_pads": "Všetky pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistiť históriu",
|
||||
"admin_pads.bulk.clear_selection": "Zrušiť výber",
|
||||
"admin_pads.bulk.delete": "Zmazať",
|
||||
"admin_pads.cancel": "Zrušiť",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revízie",
|
||||
"admin_pads.col.users": "Používatelia",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Zvoľte názov nového padu.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrďte alebo zrušte zmazanie padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Zmazať pad",
|
||||
"admin_pads.empty_never_edited": "prázdny · nikdy neupravovaný",
|
||||
"admin_pads.error_dialog_description": "Vyskytla sa chyba.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktívne",
|
||||
"admin_pads.filter.all": "Všetky",
|
||||
"admin_pads.filter.empty": "Prázdne",
|
||||
"admin_pads.filter.recent": "Tento týždeň",
|
||||
"admin_pads.filter.stale": "Neaktívne (>1 r.)",
|
||||
"admin_pads.open": "Otvoriť",
|
||||
"admin_pads.pagination.next": "Ďalej",
|
||||
"admin_pads.pagination.previous": "Späť",
|
||||
"admin_pads.refresh": "Obnoviť",
|
||||
"admin_pads.relative.days": "pred {{count}} d",
|
||||
"admin_pads.relative.hours": "pred {{count}} h",
|
||||
"admin_pads.relative.just_now": "práve teraz",
|
||||
"admin_pads.relative.minutes": "pred {{count}} min",
|
||||
"admin_pads.relative.months": "pred {{count}} mes.",
|
||||
"admin_pads.relative.weeks": "pred {{count}} týž.",
|
||||
"admin_pads.relative.years": "pred {{count}} r.",
|
||||
"admin_pads.revisions_count": "{{count}} revízií",
|
||||
"admin_pads.selected_count": "Vybraté: {{count}}",
|
||||
"admin_pads.show": "Zobraziť",
|
||||
"admin_pads.sort.name": "Názov (A – Z)",
|
||||
"admin_pads.sort.revision_number": "Revízie",
|
||||
"admin_pads.sort.user_count": "Používatelia",
|
||||
"admin_pads.stats.across_pads": "vo všetkých padoch",
|
||||
"admin_pads.stats.active_users": "Aktívni používatelia",
|
||||
"admin_pads.stats.empty_pads": "Prázdne pady",
|
||||
"admin_pads.stats.last_activity": "Posledná aktivita",
|
||||
"admin_pads.stats.no_active_users": "Žiadni aktívni používatelia",
|
||||
"admin_pads.stats.revisions_zero": "0 revízií",
|
||||
"admin_pads.stats.total": "Pady spolu",
|
||||
"admin_pads.stats.users_active": "Momentálne aktívnych: {{count}}",
|
||||
"admin_pads.subtitle": "Prehľad všetkých padov na tejto inštancii Etherpadu. Vyhľadávajte, čistite, otvárajte.",
|
||||
"admin_plugins": "Správca doplnkov",
|
||||
"admin_plugins.available": "Dostupné doplnky",
|
||||
"admin_plugins.available_not-found": "Doplnky neboli nájdené.",
|
||||
"admin_plugins.available_fetching": "Načítavanie...",
|
||||
"admin_plugins.available_install.value": "Inštalovať",
|
||||
"admin_plugins.available_search.placeholder": "Vyhľadať doplnky na inštaláciu",
|
||||
"admin_plugins.check_updates": "Skontrolovať aktualizácie",
|
||||
"admin_plugins.core_count": "{{count}} základných",
|
||||
"admin_plugins.catalog_disabled": "Katalóg zásuvných modulov je vypnutý vaším prevádzkovateľom (privacy.pluginCatalog=false). Zásuvný modul nainštalujete spustením `pnpm run plugins i ep_<name>` na serveri.",
|
||||
"admin_plugins.crumbs": "Zásuvné moduly",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypína:",
|
||||
"admin_plugins.disables.warning_title": "Tento zásuvný modul zámerne odstraňuje uvedené funkcie Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba pri získavaní zásuvných modulov",
|
||||
"admin_plugins.install_error": "Nepodarilo sa nainštalovať {{plugin}}: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nemožno nainštalovať {{plugin}}: vyžaduje novšiu verziu Etherpadu. Aktualizujte Etherpad a skúste to znova.",
|
||||
"admin_plugins.installed": "Nainštalované doplnky",
|
||||
"admin_plugins.installed_fetching": "Načítavanie nainštalovaných doplnkov...",
|
||||
"admin_plugins.installed_nothing": "Ešte ste nenainštalovali žiadne doplnky.",
|
||||
|
|
@ -25,27 +93,161 @@
|
|||
"admin_plugins.last-update": "Posledná aktualizácia",
|
||||
"admin_plugins.name": "Názov",
|
||||
"admin_plugins.page-title": "Správca doplnkov - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Znovu načítať katalóg",
|
||||
"admin_plugins.search_npm": "Hľadať na npm",
|
||||
"admin_plugins.sort_ascending": "Zoradiť vzostupne",
|
||||
"admin_plugins.sort_descending": "Zoradiť zostupne",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizované",
|
||||
"admin_plugins.sort.name": "Názov (A – Z)",
|
||||
"admin_plugins.sort.version": "Verzia",
|
||||
"admin_plugins.source": "Zdroj zásuvného modulu",
|
||||
"admin_plugins.subtitle": "Inštalujte, aktualizujte a odstraňujte zásuvné moduly Etherpadu. Zmeny si vyžadujú reštart servera.",
|
||||
"admin_plugins.tag_core": "Základné",
|
||||
"admin_plugins.update_tooltip": "Aktualizovať",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizácie",
|
||||
"admin_plugins.update_now": "Aktualizovať",
|
||||
"admin_plugins.version": "Verzia",
|
||||
"admin_plugins_info": "Informácie k riešeniu problémov",
|
||||
"admin_plugins_info.bindings_label": "{{count}} väzieb",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopírovať diagnostiku",
|
||||
"admin_plugins_info.copy_value": "Kopírovať {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Väzby hookov",
|
||||
"admin_plugins_info.hooks": "Nainštalované súčasti",
|
||||
"admin_plugins_info.hooks_client": "Súčasti na strane klienta",
|
||||
"admin_plugins_info.hooks_server": "Súčasti na strane servera",
|
||||
"admin_plugins_info.no_hooks": "Nenašli sa žiadne hooky",
|
||||
"admin_plugins_info.parts": "Nainštalované súčasti",
|
||||
"admin_plugins_info.plugins": "Nainštalované doplnky",
|
||||
"admin_plugins_info.page-title": "Informácie o doplnkoch - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Hľadať hook alebo časť…",
|
||||
"admin_plugins_info.subtitle": "Diagnostika systému: nainštalovaná verzia, registrované časti a hooky.",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuálne",
|
||||
"admin_plugins_info.update_available": "Dostupná aktualizácia: {{version}}",
|
||||
"admin_plugins_info.version": "Verzia Etherpadu",
|
||||
"admin_plugins_info.version_latest": "Posledná dostupná verzia",
|
||||
"admin_plugins_info.version_number": "Číslo verzie",
|
||||
"admin_settings": "Nastavenia",
|
||||
"admin_settings.create_pad": "Vytvoriť pad",
|
||||
"admin_settings.current": "Aktuálne nastavenia",
|
||||
"admin_settings.current_example-devel": "Príklad šablóny vývojárskeho nastavenia",
|
||||
"admin_settings.current_example-prod": "Príklad šablóny výrobného nastavenia",
|
||||
"admin_settings.current_restart.value": "Reštartovať Etherpad",
|
||||
"admin_settings.current_save.value": "Uložiť nastavenia",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Overiť JSON",
|
||||
"admin_settings.current_prettify.value": "Sformátovať JSON",
|
||||
"admin_settings.toast.saved": "Nastavenia boli úspešne uložené.",
|
||||
"admin_settings.toast.save_failed": "Uloženie zlyhalo: súbor settings.json sa nepodarilo zapísať.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: skontrolujte čiarky, zátvorky a úvodzovky.",
|
||||
"admin_settings.toast.disconnected": "Nemožno uložiť: bez pripojenia k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nemožno sformátovať: najprv opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Formátovaním sa odstránia všetky komentáre. Pokračovať?",
|
||||
"admin_settings.mode.form": "Formulár",
|
||||
"admin_settings.mode.raw": "Surový",
|
||||
"admin_settings.mode.effective": "Efektívny",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazenie hodnôt, ktoré Etherpad práve teraz skutočne používa (po dosadení premenných prostredia), iba na čítanie. Tajné hodnoty sú skryté.",
|
||||
"admin_settings.mode.aria_label": "Režim editora",
|
||||
"admin_settings.envvar_banner.title": "Tento súbor je šablóna, nie živá konfigurácia.",
|
||||
"admin_settings.envvar_banner.body": "Zástupné výrazy ako ${VAR:default} sa pri štarte dosadia do pamäte; nikdy sa nezapisujú späť do tohto súboru. Ak chcete zmeniť výslednú hodnotu, upravte premenné prostredia vo svojom prostredí (Docker compose, systemd, .env), alebo tu nahraďte zástupný výraz konkrétnou hodnotou. Prepnutím na kartu Efektívny zistíte, čo Etherpad práve používa.",
|
||||
"admin_settings.toast.auth_error": "Nie ste overený ako správca. Prihláste sa, prosím, znova.",
|
||||
"admin_settings.section.general": "Všeobecné",
|
||||
"admin_settings.parse_error.title": "Nemožno spracovať settings.json",
|
||||
"admin_settings.parse_error.cta": "Prepnite na surový režim na úpravu",
|
||||
"admin_settings.env_pill.tooltip": "Číta sa z premennej prostredia {{variable}}. Hodnota nižšie sa použije, keď {{variable}} nie je nastavená.",
|
||||
"admin_settings.env_pill.default_label": "predvolené",
|
||||
"admin_settings.env_pill.input_aria": "Predvolená hodnota pre {{variable}}",
|
||||
"admin_settings.env_pill.runtime_label": "aktívna hodnota",
|
||||
"admin_settings.env_pill.runtime_tooltip": "Etherpad práve používa túto hodnotu, určenú z {{variable}} alebo jej predvolenej hodnoty.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "Etherpad používa hodnotu pre {{variable}}, je však skrytá, pretože ide o tajnú hodnotu.",
|
||||
"admin_settings.page-title": "Nastavenia - Etherpad",
|
||||
"admin_settings.save_error": "Chyba pri ukladaní nastavení",
|
||||
"admin_settings.saved_success": "Nastavenia boli úspešne uložené",
|
||||
"update.banner.title": "Dostupná aktualizácia",
|
||||
"update.banner.body": "Je dostupný Etherpad {{latest}} (používate {{current}}).",
|
||||
"update.banner.cta": "Zobraziť aktualizáciu",
|
||||
"update.page.title": "Aktualizácie Etherpadu",
|
||||
"update.page.current": "Aktuálna verzia",
|
||||
"update.page.latest": "Najnovšia verzia",
|
||||
"update.page.last_check": "Naposledy skontrolované",
|
||||
"update.page.install_method": "Spôsob inštalácie",
|
||||
"update.page.tier": "Úroveň aktualizácií",
|
||||
"update.page.changelog": "Zoznam zmien",
|
||||
"update.page.up_to_date": "Používate najnovšiu verziu.",
|
||||
"update.page.disabled": "Kontroly aktualizácií sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.unauthorized": "Nemáte oprávnenie zobraziť stav aktualizácií.",
|
||||
"update.page.error": "Nepodarilo sa načítať stav aktualizácií (stav {{status}}).",
|
||||
"update.badge.severe": "Etherpad na tomto serveri je vážne zastaraný. Upozornite správcu.",
|
||||
"update.badge.vulnerable": "Etherpad na tomto serveri používa verziu so známymi bezpečnostnými problémami. Upozornite správcu.",
|
||||
"update.page.apply": "Použiť aktualizáciu",
|
||||
"update.page.cancel": "Zrušiť",
|
||||
"update.page.acknowledge": "Potvrdiť",
|
||||
"update.page.log": "Záznam aktualizácie (posledných 200 riadkov)",
|
||||
"update.page.execution": "Stav",
|
||||
"update.page.policy.install-method-not-writable": "Aktualizácie zo správcovského rozhrania vyžadujú inštaláciu cez git. Aktualizujte cez svojho správcu balíkov.",
|
||||
"update.page.policy.rollback-failed-terminal": "Predchádzajúca aktualizácia zlyhala a nepodarilo sa ju vrátiť späť. Po uvedení inštalácie do poriadku stlačte Potvrdiť, čím sa zámok zruší.",
|
||||
"update.page.policy.up-to-date": "Používate najnovšiu verziu.",
|
||||
"update.page.policy.tier-off": "Aktualizácie sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Úroveň 4 (autonómna) vyžaduje okno údržby. Autonómne aktualizácie zapnete nastavením updates.maintenanceWindow v settings.json.",
|
||||
"update.page.policy.maintenance-window-invalid": "Úroveň 4 (autonómna) je vypnutá, pretože updates.maintenanceWindow má nesprávny formát. Očakáva sa {start, end, tz} s časmi vo formáte HH:MM a tz „local“ alebo „utc“.",
|
||||
"update.page.last_result.verified": "Posledná aktualizácia na {{tag}} bola overená.",
|
||||
"update.page.last_result.rolled-back": "Posledná pokusná aktualizácia na {{tag}} bola vrátená späť: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Posledný pokus o aktualizáciu zlyhal A zlyhalo aj vrátenie späť: {{reason}}. Vyžaduje sa ručný zásah.",
|
||||
"update.page.last_result.preflight-failed": "Posledná pokusná aktualizácia na {{tag}} zlyhala pri kontrole pred spustením: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Poslednú pokusnú aktualizáciu na {{tag}} zrušil správca.",
|
||||
"update.execution.idle": "Nečinné",
|
||||
"update.execution.scheduled": "Aktualizácia naplánovaná",
|
||||
"update.execution.preflight": "Kontroly pred spustením",
|
||||
"update.execution.preflight-failed": "Kontrola pred spustením zlyhala",
|
||||
"update.execution.draining": "Ukončovanie relácií",
|
||||
"update.execution.executing": "Aktualizuje sa…",
|
||||
"update.execution.pending-verification": "Čaká sa na overenie",
|
||||
"update.execution.verified": "Overené",
|
||||
"update.execution.rolling-back": "Vracia sa späť",
|
||||
"update.execution.rolled-back": "Vrátené späť",
|
||||
"update.execution.rollback-failed": "Vrátenie späť zlyhalo",
|
||||
"update.banner.terminal.rollback-failed": "Pokus o aktualizáciu zlyhal a nepodarilo sa ho vrátiť späť. Vyžaduje sa ručný zásah.",
|
||||
"update.banner.scheduled": "Automatická aktualizácia na {{tag}} je naplánovaná — použije sa o {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Autonómne aktualizácie sú vypnuté, kým sa nenastaví okno údržby.",
|
||||
"update.banner.maintenance-window-invalid": "Autonómne aktualizácie sú vypnuté, pretože okno údržby má nesprávny formát.",
|
||||
"update.page.scheduled.title": "Aktualizácia naplánovaná",
|
||||
"update.page.scheduled.countdown": "Etherpad sa začne aktualizovať na {{tag}} o {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Mimo okna údržby. Aktualizácia sa spustí, keď sa okno otvorí o {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Použiť teraz",
|
||||
"update.window.title": "Okno údržby",
|
||||
"update.window.summary": "{{start}} – {{end}} ({{tz}})",
|
||||
"update.window.unset": "Nie je nastavené.",
|
||||
"update.window.next_opens_at": "Ďalšie okno sa otvorí o {{at}}.",
|
||||
"update.drain.t60": "Etherpad sa o 60 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t30": "Etherpad sa o 30 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t10": "Etherpad sa o 10 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"index.newPad": "Nový poznámkový blok",
|
||||
"index.createOpenPad": "alebo vytvoriť/otvoriť poznámkový blok s názvom:",
|
||||
"index.settings": "Nastavenia",
|
||||
"index.transferSessionTitle": "Preniesť reláciu",
|
||||
"index.receiveSessionTitle": "Prijať reláciu",
|
||||
"index.receiveSessionDescription": "Tu môžete prijať reláciu Etherpadu z iného prehliadača alebo zariadenia. Upozorňujeme však, že tým sa odstráni vaša súčasná relácia, ak nejakú máte.",
|
||||
"index.transferSession": "1. Preniesť reláciu",
|
||||
"index.transferSessionNow": "Preniesť reláciu teraz",
|
||||
"index.copyLink": "2. Kopírovať odkaz",
|
||||
"index.copyLinkDescription": "Kliknutím na tlačidlo nižšie skopírujete odkaz do schránky.",
|
||||
"index.copyLinkButton": "Kopírovať odkaz do schránky",
|
||||
"index.transferToSystem": "3. Skopírovať reláciu do nového systému",
|
||||
"index.transferToSystemDescription": "Otvorením skopírovaného odkazu v cieľovom prehliadači alebo zariadení prenesiete svoju reláciu.",
|
||||
"index.code": "Kód",
|
||||
"index.transferSessionDescription": "Kliknutím na tlačidlo nižšie prenesiete svoju súčasnú reláciu do prehliadača alebo zariadenia. Skopíruje sa odkaz na stránku, ktorá po otvorení v cieľovom prehliadači alebo zariadení prenesie vašu reláciu.",
|
||||
"index.createOpenPad": "Otvoriť pad podľa názvu",
|
||||
"index.openPad": "otvoriť poznámkový blok s názvom:",
|
||||
"index.recentPads": "Nedávne pady",
|
||||
"index.recentPadsEmpty": "Nenašli sa žiadne nedávne pady.",
|
||||
"index.generateNewPad": "Vygenerovať náhodný názov padu",
|
||||
"index.labelPad": "Názov padu (nepovinné)",
|
||||
"index.placeholderPadEnter": "Zadajte názov padu…",
|
||||
"index.createAndShareDocuments": "Vytvárajte a zdieľajte dokumenty v reálnom čase",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad vám umožňuje spoločne upravovať dokumenty v reálnom čase, podobne ako živý viacpoužívateľský editor, ktorý beží vo vašom prehliadači.",
|
||||
"pad.toolbar.bold.title": "Tučné (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl+I)",
|
||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl+U)",
|
||||
|
|
@ -62,22 +264,42 @@
|
|||
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
||||
"pad.toolbar.settings.title": "Nastavenia",
|
||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
|
||||
"pad.toolbar.home.title": "Späť na domovskú stránku",
|
||||
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto poznámkového bloku",
|
||||
"pad.colorpicker.save": "Uložiť",
|
||||
"pad.colorpicker.cancel": "Zrušiť",
|
||||
"pad.loading": "Načítava sa...",
|
||||
"pad.noCookie": "Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači. Vaše sedenie a nastavenia sa medzi návštevami stránky neuložia. To môže byť spôsobené tým že Etherpad je zahrnutý do iFrame v niektorých prehliadačoch. Prosím uistite sa, že Etherpad sa nachádza na tej istej doméne ako hlavný iFrame",
|
||||
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku",
|
||||
"pad.settings.padSettings": "Nastavenia poznámkového bloku",
|
||||
"pad.settings.title": "Nastavenia",
|
||||
"pad.settings.padSettings": "Nastavenia pre celý pad",
|
||||
"pad.settings.userSettings": "Používateľské nastavenia",
|
||||
"pad.settings.myView": "Vlastný pohľad",
|
||||
"pad.settings.disablechat": "Vypnúť chat",
|
||||
"pad.settings.darkMode": "Tmavý režim",
|
||||
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
|
||||
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
|
||||
"pad.settings.colorcheck": "Farby autorov",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Stlmiť farby neaktívnych autorov",
|
||||
"pad.settings.linenocheck": "Čísla riadkov",
|
||||
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
||||
"pad.settings.enforceSettings": "Vynútiť nastavenia pre ostatných používateľov",
|
||||
"pad.settings.enforcedNotice": "Tieto nastavenia vám uzamkol tvorca tohto padu. Ak ich potrebujete zmeniť, požiadajte tvorcu padu.",
|
||||
"pad.settings.fontType": "Typ písma:",
|
||||
"pad.settings.fontType.normal": "Normálne",
|
||||
"pad.settings.language": "Jazyk:",
|
||||
"pad.settings.deletePad": "Zmazať pad",
|
||||
"pad.delete.confirm": "Naozaj chcete zmazať tento pad?",
|
||||
"pad.deletionToken.modalTitle": "Uložte si token na zmazanie padu",
|
||||
"pad.deletionToken.modalBody": "Tento token je jediný spôsob, ako zmazať tento pad, ak stratíte reláciu prehliadača alebo zmeníte zariadenie. Uložte si ho na bezpečné miesto — zobrazí sa tu iba raz.",
|
||||
"pad.deletionToken.copy": "Kopírovať",
|
||||
"pad.deletionToken.copied": "Skopírované",
|
||||
"pad.deletionToken.acknowledge": "Uložil som si ho",
|
||||
"pad.deletionToken.deleteWithToken": "Zmazať pad pomocou tokenu",
|
||||
"pad.deletionToken.tokenFieldLabel": "Token na zmazanie padu",
|
||||
"pad.deletionToken.tokenValueLabel": "Váš token na zmazanie padu (iba na čítanie)",
|
||||
"pad.deletionToken.invalid": "Tento token nie je platný pre tento pad.",
|
||||
"pad.deletionToken.notCreator": "Nie ste tvorcom tohto padu, takže ho nemôžete zmazať.",
|
||||
"pad.settings.about": "O Etherpade",
|
||||
"pad.settings.poweredBy": "Poháňané cez",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -90,6 +312,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Exportovať ako Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exportovať ako HTML",
|
||||
"pad.importExport.exportplaina.title": "Exportovať ako čistý text",
|
||||
"pad.importExport.exportworda.title": "Exportovať ako Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Exportovať ako PDF",
|
||||
"pad.importExport.exportopena.title": "Exportovať ako ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Importovať môžete iba z formátov čistého textu alebo HTML. Pre pokročilejšie funkcie importu si <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">nainštalujte LibreOffice</a>.",
|
||||
"pad.modals.connected": "Pripojené.",
|
||||
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu poznámkovému bloku...",
|
||||
"pad.modals.forcereconnect": "Vynútiť znovupripojenie",
|
||||
|
|
@ -120,6 +349,8 @@
|
|||
"pad.modals.disconnected": "Boli ste odpojení.",
|
||||
"pad.modals.disconnected.explanation": "Spojenie so serverom sa prerušilo",
|
||||
"pad.modals.disconnected.cause": "Server môže byť nedostupný. Ak by problém pretrvával, informujte správcu služby.",
|
||||
"pad.gritter.unacceptedCommit.title": "Neuložená úprava",
|
||||
"pad.gritter.unacceptedCommit.text": "Vaša nedávna úprava stále nie je uložená. Znova sa pripojte a skúste to ešte raz.",
|
||||
"pad.share": "Zdieľať tento poznámkový blok",
|
||||
"pad.share.readonly": "Len na čítanie",
|
||||
"pad.share.link": "Odkaz",
|
||||
|
|
@ -132,12 +363,36 @@
|
|||
"timeslider.followContents": "Sledovať aktualizácie obsahu poznámkového bloku",
|
||||
"timeslider.pageTitle": "Časová os {{appTitle}}",
|
||||
"timeslider.toolbar.returnbutton": "Späť do poznámkového bloku",
|
||||
"pad.historyMode.banner": "Prezeranie histórie",
|
||||
"pad.historyMode.return": "Návrat k živej verzii",
|
||||
"pad.historyMode.revisionLabel": "Revízia {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Ovládanie histórie padu",
|
||||
"pad.historyMode.sliderLabel": "Revízia padu",
|
||||
"pad.historyMode.settings.title": "Prehrávanie histórie",
|
||||
"pad.historyMode.settings.follow": "Sledovať zmeny obsahu padu",
|
||||
"pad.historyMode.settings.followShort": "Sledovať",
|
||||
"pad.historyMode.followOn": "Sledujú sa zmeny padu — kliknutím sledovanie ukončíte",
|
||||
"pad.historyMode.followOff": "Zmeny padu sa nesledujú — kliknutím začnete sledovať",
|
||||
"pad.historyMode.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat k {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autori v tejto revízii",
|
||||
"pad.editor.skipToContent": "Preskočiť na editor",
|
||||
"pad.editor.keyboardHint": "Stlačením Escape opustíte editor. Stlačením Alt+F9 sa dostanete na panel nástrojov.",
|
||||
"pad.editor.toolbar.formatting": "Panel nástrojov formátovania",
|
||||
"pad.editor.toolbar.actions": "Panel akcií padu",
|
||||
"pad.editor.toolbar.showMore": "Zobraziť viac tlačidiel panela",
|
||||
"timeslider.toolbar.authors": "Autori:",
|
||||
"timeslider.toolbar.authorsList": "Bez autorov",
|
||||
"timeslider.toolbar.exportlink.title": "Export",
|
||||
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
|
||||
"timeslider.version": "Verzia {{version}}",
|
||||
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"timeslider.settings.playbackSpeed.original": "Pôvodná rýchlosť",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Reálny čas",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 ms",
|
||||
"timeslider.settings.playbackSpeed.500ms": "500 ms",
|
||||
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
|
||||
"timeslider.playPause": "Pustiť / Pozastaviť obsah poznámkového bloku",
|
||||
"timeslider.backRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu späť",
|
||||
"timeslider.forwardRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu vpred",
|
||||
|
|
@ -159,6 +414,7 @@
|
|||
"pad.savedrevs.timeslider": "Návštevou časovej osi môžete zobraziť uložené revízie",
|
||||
"pad.userlist.entername": "Zadajte svoje meno",
|
||||
"pad.userlist.unnamed": "nemenovaný",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} pripojený používateľ, few: {{count}} pripojení používatelia, many: {{count}} pripojených používateľov, other: {{count}} pripojených používateľov ]}",
|
||||
"pad.editbar.clearcolors": "Odstrániť farby autorov z celého dokumentu? Táto akcia sa nedá vrátiť",
|
||||
"pad.impexp.importbutton": "Importovať teraz",
|
||||
"pad.impexp.importing": "Prebieha import...",
|
||||
|
|
@ -169,5 +425,6 @@
|
|||
"pad.impexp.importfailed": "Import zlyhal",
|
||||
"pad.impexp.copypaste": "Vložte prosím kópiu cez schránku",
|
||||
"pad.impexp.exportdisabled": "Export do formátu {{type}} nie je povolený. Kontaktujte prosím administrátora pre zistenie detailov.",
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import"
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import",
|
||||
"pad.social.description": "Kolaboratívny dokument, ktorý môže každý upravovať v reálnom čase."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"Stang",
|
||||
"TFX202X",
|
||||
"VulpesVulpes825",
|
||||
"XtexChooser",
|
||||
"Yfdyh000",
|
||||
"乌拉跨氪",
|
||||
"列维劳德",
|
||||
|
|
@ -26,6 +27,30 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理员面板 - Etherpad",
|
||||
"admin.loading": "正在加载…",
|
||||
"admin.loading_description": "页面加载中,请稍安勿躁。",
|
||||
"admin.toggle_sidebar": "开关侧边栏",
|
||||
"admin.shout": "通讯",
|
||||
"admin_shout.online_one": "目前有{{count}}位用户在线",
|
||||
"admin_shout.online_other": "目前有{{count}}位用户在线",
|
||||
"admin_shout.sticky_toggle": "更改置顶消息",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "用户名",
|
||||
"admin_login.password": "密码",
|
||||
"admin_login.submit": "登录",
|
||||
"admin_login.failed": "登录失败",
|
||||
"admin_pads.all_pads": "所有记事本",
|
||||
"admin_pads.bulk.cleanup_history": "清理历史",
|
||||
"admin_pads.bulk.clear_selection": "清除选择",
|
||||
"admin_pads.bulk.delete": "删除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "用户",
|
||||
"admin_pads.confirm_button": "确定",
|
||||
"admin_pads.error_prefix": "错误",
|
||||
"admin_pads.pagination.next": "下一页",
|
||||
"admin_pads.pagination.previous": "上一页",
|
||||
"admin_pads.refresh": "刷新",
|
||||
"admin_pads.relative.just_now": "刚刚",
|
||||
"admin_plugins": "插件管理器",
|
||||
"admin_plugins.available": "可用插件",
|
||||
"admin_plugins.available_not-found": "找不到插件。",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理員面板 - Etherpad",
|
||||
"admin.loading": "載入中…",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "使用者名稱",
|
||||
"admin_login.password": "密碼",
|
||||
"admin_login.submit": "登入",
|
||||
"admin_login.failed": "登入失敗",
|
||||
"admin_pads.bulk.delete": "刪除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "使用者",
|
||||
"admin_pads.error_prefix": "錯誤",
|
||||
"admin_plugins": "外掛程式管理器",
|
||||
"admin_plugins.available": "可用套件",
|
||||
"admin_plugins.available_not-found": "沒有找到套件。",
|
||||
|
|
@ -29,6 +39,9 @@
|
|||
"admin_plugins.last-update": "最後更新",
|
||||
"admin_plugins.name": "名稱",
|
||||
"admin_plugins.page-title": "套件管理 - Etherpad",
|
||||
"admin_plugins.update_tooltip": "更新",
|
||||
"admin_plugins.updates_available": "有可用更新",
|
||||
"admin_plugins.update_now": "更新",
|
||||
"admin_plugins.version": "版本",
|
||||
"admin_plugins_info": "問題排除資訊",
|
||||
"admin_plugins_info.hooks": "已安裝的掛勾",
|
||||
|
|
@ -37,6 +50,8 @@
|
|||
"admin_plugins_info.parts": "已安裝部分",
|
||||
"admin_plugins_info.plugins": "已安裝的套件",
|
||||
"admin_plugins_info.page-title": "套件資訊 - Etherpad",
|
||||
"admin_plugins_info.tab_client": "客戶端",
|
||||
"admin_plugins_info.tab_server": "伺服器",
|
||||
"admin_plugins_info.version": "Etherpad 版本",
|
||||
"admin_plugins_info.version_latest": "最新可用版本",
|
||||
"admin_plugins_info.version_number": "版本號",
|
||||
|
|
@ -46,6 +61,9 @@
|
|||
"admin_settings.current_example-prod": "生產設定模板範例",
|
||||
"admin_settings.current_restart.value": "重新啟動 Etherpad",
|
||||
"admin_settings.current_save.value": "儲存設定",
|
||||
"admin_settings.invalid_json": "無效 JSON",
|
||||
"admin_settings.current_test.value": "驗證 JSON",
|
||||
"admin_settings.current_prettify.value": "美化 JSON 格式",
|
||||
"admin_settings.page-title": "設定 - Etherpad",
|
||||
"index.newPad": "新記事本",
|
||||
"index.settings": "設定",
|
||||
|
|
@ -116,6 +134,11 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF(開放文件格式)",
|
||||
"pad.importExport.exporthtmla.title": "匯出成 HTML",
|
||||
"pad.importExport.exportplaina.title": "匯出成純文字",
|
||||
"pad.importExport.exportworda.title": "匯出成 Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "匯出成 PDF",
|
||||
"pad.importExport.exportopena.title": "匯出成 ODF(開放文件格式)",
|
||||
"pad.modals.connected": "已連線。",
|
||||
"pad.modals.reconnecting": "重新連線到您的記事本…",
|
||||
"pad.modals.forcereconnect": "強制重新連線",
|
||||
|
|
|
|||
|
|
@ -414,7 +414,13 @@ exports.appendChatMessage = async (padID: string, text: string|object, authorID:
|
|||
time = Date.now();
|
||||
}
|
||||
|
||||
// @TODO - missing getPadSafe() call ?
|
||||
// Reject messages addressed to a pad that doesn't exist. Without this check
|
||||
// the downstream padManager.getPad() would create the pad on demand with
|
||||
// default content, so the documented {code:1,"padID does not exist"} result
|
||||
// would never be returned.
|
||||
if (!await padManager.doesPadExists(padID)) {
|
||||
throw new CustomError('padID does not exist', 'apierror');
|
||||
}
|
||||
|
||||
// save chat message to database and send message to all connected clients
|
||||
await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);
|
||||
|
|
@ -545,10 +551,11 @@ exports.createPad = async (padID: string, text: string, authorId = '') => {
|
|||
|
||||
// create pad
|
||||
await getPadSafe(padID, false, text, authorId);
|
||||
// When requireAuthentication is on, every creator has a stable identity, so
|
||||
// the cookie/identity path covers recovery and the one-time token is just
|
||||
// an extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication
|
||||
// No recovery token when it cannot help: requireAuthentication gives every
|
||||
// creator a stable identity, and allowPadDeletionByAllUsers lets anyone delete
|
||||
// the pad with no token at all (issue #7926). Either way the token is just an
|
||||
// extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication || settings.allowPadDeletionByAllUsers
|
||||
? null
|
||||
: await padDeletionManager.createDeletionTokenIfAbsent(padID);
|
||||
return {deletionToken};
|
||||
|
|
|
|||
|
|
@ -590,25 +590,42 @@ class Pad {
|
|||
Object.assign(this, value);
|
||||
if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);
|
||||
} else {
|
||||
if (text == null) {
|
||||
// Auto-generated default content (settings.defaultPadText or whatever a
|
||||
// padDefaultContent hook substitutes) is not written by the user who
|
||||
// happens to open the pad first, so the text must not carry their author
|
||||
// attribute — otherwise the welcome text shows up in the creator's
|
||||
// authorship colour (issue #7885). Track whether the text came from the
|
||||
// default-content path so its insert op can be attributed to the system
|
||||
// author.
|
||||
const usedDefaultContent = (text == null);
|
||||
if (usedDefaultContent) {
|
||||
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
|
||||
await hooks.aCallAll('padDefaultContent', context);
|
||||
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
|
||||
text = exports.cleanText(context.content);
|
||||
}
|
||||
// When the initial pad text is non-empty but no authorId was
|
||||
// supplied (internal getPad calls during HTTP API setup,
|
||||
// padDefaultContent flows, plugin-driven pad creation), fall back
|
||||
// to the stable system author so the initial changeset's insert
|
||||
// op carries an `author` attribute. Mirrors the same substitution
|
||||
// The author *attribute* applied to the initial text — i.e. what colours
|
||||
// it in the editor — is the stable system author when the content is
|
||||
// auto-generated default text (#7885), or when non-empty text was
|
||||
// supplied without an authorId (internal getPad calls during HTTP API
|
||||
// setup, plugin-driven pad creation). The latter keeps the insert op
|
||||
// carrying an `author` attribute, mirroring the substitution
|
||||
// setText/appendText already do via spliceText.
|
||||
const effectiveAuthorId =
|
||||
(text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId;
|
||||
const firstAttribs = effectiveAuthorId
|
||||
? [['author', effectiveAuthorId] as [string, string]]
|
||||
const attribAuthorId =
|
||||
((usedDefaultContent || !authorId) && text.length > 0)
|
||||
? Pad.SYSTEM_AUTHOR_ID : authorId;
|
||||
const firstAttribs = attribAuthorId
|
||||
? [['author', attribAuthorId] as [string, string]]
|
||||
: undefined;
|
||||
// The *revision* author (revs:0 meta.author) stays the real creator so
|
||||
// pad ownership is preserved: isPadCreator() / the pad-wide settings gate
|
||||
// and the deletion token all key off getRevisionAuthor(0). Only when no
|
||||
// author was supplied at all do we fall back to the system author, so the
|
||||
// initial revision still records a stable, non-empty author.
|
||||
const revisionAuthorId =
|
||||
authorId || (text.length > 0 ? Pad.SYSTEM_AUTHOR_ID : '');
|
||||
const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool);
|
||||
await this.appendRevision(firstChangeset, effectiveAuthorId);
|
||||
await this.appendRevision(firstChangeset, revisionAuthorId);
|
||||
}
|
||||
this.padSettings = Pad.normalizePadSettings(this.padSettings);
|
||||
await hooks.aCallAll('padLoad', {pad: this});
|
||||
|
|
@ -851,6 +868,8 @@ class Pad {
|
|||
await this.saveToDatabase();
|
||||
}
|
||||
|
||||
// Returns the newly created saved revision, or undefined if this revision
|
||||
// was already saved (so callers can broadcast only genuine additions).
|
||||
async addSavedRevision(revNum: string, savedById: string, label: string) {
|
||||
// if this revision is already saved, return silently
|
||||
for (const i in this.savedRevisions) {
|
||||
|
|
@ -870,6 +889,7 @@ class Pad {
|
|||
// save this new saved revision
|
||||
this.savedRevisions.push(savedRevision);
|
||||
await this.saveToDatabase();
|
||||
return savedRevision;
|
||||
}
|
||||
|
||||
getSavedRevisions() {
|
||||
|
|
|
|||
|
|
@ -192,7 +192,15 @@ exports.sanitizePadId = async (padId: string) => {
|
|||
return padId;
|
||||
};
|
||||
|
||||
exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
|
||||
// Pad IDs consisting only of URL "dot-segments" ('.' or '..') are unreachable:
|
||||
// per the WHATWG URL standard a browser normalises "/p/." to "/p/" and "/p/.."
|
||||
// to "/", so the pad can never be opened or exported — the request arrives as
|
||||
// "/p/" and Etherpad answers "Cannot GET /p/". Reject them so such (broken) pads
|
||||
// can never be created.
|
||||
const dotSegmentPadId = /^\.{1,2}$/;
|
||||
|
||||
exports.isValidPadId = (padId: string) =>
|
||||
/^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
|
||||
|
||||
/**
|
||||
* Removes the pad from database and unloads it.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue