mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-05-04 06:09:14 -04:00
Merge branch 'develop'
This commit is contained in:
commit
4ce7a60b9c
138 changed files with 4593 additions and 3750 deletions
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
|
@ -12,6 +12,7 @@ exemptLabels:
|
|||
- Black hole bug
|
||||
- Special case Bug
|
||||
- Upstream bug
|
||||
- Feature Request
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
|
|
4
.github/workflows/backend-tests.yml
vendored
4
.github/workflows/backend-tests.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [10, 12, 14, 15]
|
||||
node: [12, 14, 16]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
@ -50,7 +50,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [10, 12, 14, 15]
|
||||
node: [12, 14, 16]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
2
.github/workflows/frontend-admin-tests.yml
vendored
2
.github/workflows/frontend-admin-tests.yml
vendored
|
@ -11,7 +11,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [10, 12, 14, 15]
|
||||
node: [12, 14, 16]
|
||||
|
||||
steps:
|
||||
- name: Generate Sauce Labs strings
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
name: "In-place git pull from master"
|
||||
name: "Upgrade from latest release"
|
||||
|
||||
# any branch is useful for testing before a PR is submitted
|
||||
on: [push, pull_request]
|
||||
|
@ -16,10 +16,10 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [10, 12, 14, 15]
|
||||
node: [12, 14, 16]
|
||||
|
||||
steps:
|
||||
- name: Checkout master repository
|
||||
- name: Check out latest release
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: master
|
||||
|
@ -60,10 +60,18 @@ jobs:
|
|||
- name: Run the backend tests
|
||||
run: cd src && npm test
|
||||
|
||||
- name: Git fetch
|
||||
run: git fetch
|
||||
# Because actions/checkout@v2 is called with "ref: master" and without
|
||||
# "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA} commit.
|
||||
# Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a plain
|
||||
# "git fetch" only fetches "normal" references (refs/heads/* and
|
||||
# refs/tags/*), and for pull requests none of the normal references include
|
||||
# ${GITHUB_SHA}, so we have to explicitly tell Git to fetch ${GITHUB_REF}.
|
||||
- name: Fetch the new Git commits
|
||||
run: git fetch --depth=1 origin "${GITHUB_REF}"
|
||||
|
||||
- name: Checkout this branch over master
|
||||
- name: Upgrade to the new Git revision
|
||||
# For pull requests, ${GITHUB_SHA} is the automatically generated merge
|
||||
# commit that merges the PR's source branch to its destination branch.
|
||||
run: git checkout "${GITHUB_SHA}"
|
||||
|
||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
61
CHANGELOG.md
61
CHANGELOG.md
|
@ -1,9 +1,68 @@
|
|||
# 1.8.14
|
||||
|
||||
### Security fixes
|
||||
|
||||
* Fixed a persistent XSS vulnerability in the Chat component. In case you can't
|
||||
update to 1.8.14 directly, we strongly recommend to cherry-pick
|
||||
a7968115581e20ef47a533e030f59f830486bdfa. Thanks to sonarsource for the
|
||||
professional disclosure.
|
||||
|
||||
### Compatibility changes
|
||||
|
||||
* Node.js v12.13.0 or later is now required.
|
||||
* The `favicon` setting is now interpreted as a pathname to a favicon file, not
|
||||
a URL. Please see the documentation comment in `settings.json.template`.
|
||||
* The undocumented `faviconPad` and `faviconTimeslider` settings have been
|
||||
removed.
|
||||
* MySQL/MariaDB now uses connection pooling, which means you will see up to 10
|
||||
connections to the MySQL/MariaDB server (by default) instead of 1. This might
|
||||
cause Etherpad to crash with a "ER_CON_COUNT_ERROR: Too many connections"
|
||||
error if your server is configured with a low connection limit.
|
||||
* Changes to environment variable substitution in `settings.json` (see the
|
||||
documentation comments in `settings.json.template` for details):
|
||||
* An environment variable set to the string "null" now becomes `null` instead
|
||||
of the string "null". Similarly, if the environment variable is unset and
|
||||
the default value is "null" (e.g., `"${UNSET_VAR:null}"`), the value now
|
||||
becomes `null` instead of the string "null". It is no longer possible to
|
||||
produce the string "null" via environment variable substitution.
|
||||
* An environment variable set to the string "undefined" now causes the setting
|
||||
to be removed instead of set to the string "undefined". Similarly, if the
|
||||
environment variable is unset and the default value is "undefined" (e.g.,
|
||||
`"${UNSET_VAR:undefined}"`), the setting is now removed instead of set to
|
||||
the string "undefined". It is no longer possible to produce the string
|
||||
"undefined" via environment variable substitution.
|
||||
* Support for unset variables without a default value is now deprecated.
|
||||
Please change all instances of `"${FOO}"` in your `settings.json` to
|
||||
`${FOO:null}` to keep the current behavior.
|
||||
* The `DB_*` variable substitutions in `settings.json.docker` that previously
|
||||
defaulted to `null` now default to "undefined".
|
||||
* Calling `next` without argument when using `Changeset.opIterator` does always
|
||||
return a new Op. See b9753dcc7156d8471a5aa5b6c9b85af47f630aa8 for details.
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
||||
* MySQL/MariaDB now uses connection pooling, which should improve stability and
|
||||
reduce latency.
|
||||
* Bulk database writes are now retried individually on write failure.
|
||||
* Minify: Avoid crash due to unhandled Promise rejection if stat fails.
|
||||
* padIds are now included in /socket.io query string, e.g.
|
||||
`https://video.etherpad.com/socket.io/?padId=AWESOME&EIO=3&transport=websocket&t=...&sid=...`.
|
||||
This is useful for directing pads to separate socket.io nodes.
|
||||
* <script> elements added via aceInitInnerdocbodyHead hook are now executed.
|
||||
* Fix read only pad access with authentication.
|
||||
* Await more db writes.
|
||||
* Disabled wtfnode dump by default.
|
||||
* Send `USER_NEWINFO` messages on reconnect.
|
||||
* Fixed loading in a hidden iframe.
|
||||
* Fixed a race condition with composition. (Thanks @ingoncalves for an exceptionally
|
||||
detailed analysis and @rhansen for the fix.)
|
||||
|
||||
# 1.8.13
|
||||
|
||||
### Notable fixes
|
||||
|
||||
* Fixed a bug in the safeRun.sh script (#4935)
|
||||
* Don't create sessions on some static resources (#4921)
|
||||
* Add more endpoints that do not need authentication/authorization (#4921)
|
||||
* Fixed issue with non-opening device keyboard on smartphones (#4929)
|
||||
* Add version string to iframe_editor.css to prevent stale cache entry (#4964)
|
||||
|
||||
|
|
36
Dockerfile
36
Dockerfile
|
@ -55,12 +55,19 @@ RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \
|
|||
ARG EP_DIR=/opt/etherpad-lite
|
||||
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
||||
|
||||
# install abiword for DOC/PDF/ODT export
|
||||
RUN [ -z "${INSTALL_ABIWORD}" ] || (apt update && apt -y install abiword && apt clean && rm -rf /var/lib/apt/lists/*)
|
||||
|
||||
# install libreoffice for DOC/PDF/ODT export
|
||||
# the mkdir is needed for configuration of openjdk-11-jre-headless, see https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
||||
RUN [ -z "${INSTALL_SOFFICE}" ] || (apt update && mkdir -p /usr/share/man/man1 && apt -y install libreoffice && apt clean && rm -rf /var/lib/apt/lists/*)
|
||||
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
|
||||
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
||||
RUN export DEBIAN_FRONTEND=noninteractive; \
|
||||
mkdir -p /usr/share/man/man1 && \
|
||||
apt-get -qq update && \
|
||||
apt-get -qq --no-install-recommends install \
|
||||
ca-certificates \
|
||||
git \
|
||||
${INSTALL_ABIWORD:+abiword} \
|
||||
${INSTALL_SOFFICE:+libreoffice} \
|
||||
&& \
|
||||
apt-get -qq clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER etherpad
|
||||
|
||||
|
@ -68,11 +75,18 @@ WORKDIR "${EP_DIR}"
|
|||
|
||||
COPY --chown=etherpad:etherpad ./ ./
|
||||
|
||||
# install node dependencies for Etherpad
|
||||
RUN src/bin/installDeps.sh && \
|
||||
rm -rf ~/.npm/_cacache
|
||||
|
||||
RUN [ -z "${ETHERPAD_PLUGINS}" ] || npm install ${ETHERPAD_PLUGINS}
|
||||
# Plugins must be installed before installing Etherpad's dependencies, otherwise
|
||||
# npm will try to hoist common dependencies by removing them from
|
||||
# src/node_modules and installing them in the top-level node_modules. As of
|
||||
# v6.14.10, npm's hoist logic appears to be buggy, because it sometimes removes
|
||||
# dependencies from src/node_modules but fails to add them to the top-level
|
||||
# node_modules. Even if npm correctly hoists the dependencies, the hoisting
|
||||
# seems to confuse tools such as `npm outdated`, `npm update`, and some ESLint
|
||||
# rules.
|
||||
RUN { [ -z "${ETHERPAD_PLUGINS}" ] || \
|
||||
npm install --no-save ${ETHERPAD_PLUGINS}; } && \
|
||||
src/bin/installDeps.sh && \
|
||||
rm -rf ~/.npm
|
||||
|
||||
# Copy the configuration file.
|
||||
COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json
|
||||
|
|
|
@ -32,7 +32,7 @@ Etherpad is extremely flexible providing you the means to modify it to solve wha
|
|||
# Installation
|
||||
|
||||
## Requirements
|
||||
- `nodejs` >= **10.17.0**.
|
||||
- [Node.js](https://nodejs.org/) >= **12.13.0**.
|
||||
|
||||
## GNU/Linux and other UNIX-like systems
|
||||
|
||||
|
@ -46,7 +46,8 @@ src/bin/run.sh
|
|||
```
|
||||
|
||||
### Manual install
|
||||
You'll need git and [node.js](https://nodejs.org) installed (minimum required Node version: **10.17.0**).
|
||||
|
||||
You'll need Git and [Node.js](https://nodejs.org/) installed.
|
||||
|
||||
**As any user (we recommend creating a separate user called etherpad):**
|
||||
|
||||
|
|
|
@ -148,4 +148,9 @@ This is an atext. An atext has two parts: text and attribs. The text is just the
|
|||
|
||||
The attribs are again a bunch of operators like .ops in the changeset was. But these operators are only + operators. They describe which part of the text has which attributes
|
||||
|
||||
For more information see /doc/easysync/easysync-notes.txt in the source.
|
||||
## Resources / further reading
|
||||
|
||||
Detailed information about the changesets & Easysync protocol:
|
||||
|
||||
* Easysync Protocol - [/doc/easysync/easysync-notes.pdf](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf)
|
||||
* Etherpad and EasySync Technical Manual - [/doc/easysync/easysync-full-description.pdf](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf)
|
||||
|
|
|
@ -294,6 +294,11 @@ Things in context:
|
|||
|
||||
This hook is called on the client side whenever a chat message is received from
|
||||
the server. It can be used to create different notifications for chat messages.
|
||||
Hoook functions can modify the `author`, `authorName`, `duration`, `sticky`,
|
||||
`text`, and `timeStr` context properties to change how the message is processed.
|
||||
The `text` and `timeStr` properties may contain HTML, but plugins should be
|
||||
careful to sanitize any added user input to avoid introducing an XSS
|
||||
vulnerability.
|
||||
|
||||
## collectContentPre
|
||||
|
||||
|
|
|
@ -156,11 +156,13 @@ Called from: src/node/db/SecurityManager.js
|
|||
|
||||
Things in context:
|
||||
|
||||
1. padID - the pad the user wants to access
|
||||
1. padID - the real ID (never the read-only ID) of the pad the user wants to
|
||||
access
|
||||
2. token - the token of the author
|
||||
3. sessionCookie - the session the use has
|
||||
|
||||
This hook gets called when the access to the concrete pad is being checked. Return `false` to deny access.
|
||||
This hook gets called when the access to the concrete pad is being checked.
|
||||
Return `false` to deny access.
|
||||
|
||||
## padCreate
|
||||
Called from: src/node/db/Pad.js
|
||||
|
@ -615,14 +617,14 @@ is sent to the client. Plugins can use this hook to manipulate the
|
|||
configuration. (Example: Add a tracking ID for an external analytics tool that
|
||||
is used client-side.)
|
||||
|
||||
The clientVars function must return a Promise that resolves to an object (or
|
||||
null/undefined) whose properties will be merged into `context.clientVars`.
|
||||
Returning `callback(value)` will return a Promise that is resolved to `value`.
|
||||
|
||||
You can modify `context.clientVars` to change the values sent to the client, but
|
||||
beware: async functions from other clientVars plugins might also be reading or
|
||||
manipulating the same `context.clientVars` object. For this reason it is
|
||||
recommended you return an object rather than modify `context.clientVars`.
|
||||
You can manipulate `clientVars` in two different ways:
|
||||
* Return an object. The object will be merged into `clientVars` via
|
||||
`Object.assign()`, so any keys that already exist in `clientVars` will be
|
||||
overwritten by the values in the returned object.
|
||||
* Modify `context.clientVars`. Beware: Other plugins might also be reading or
|
||||
manipulating the same `context.clientVars` object. To avoid race conditions,
|
||||
you are encouraged to return an object rather than modify
|
||||
`context.clientVars`.
|
||||
|
||||
If needed, you can access the user's account information (if authenticated) via
|
||||
`context.socket.client.request.session.user`.
|
||||
|
@ -643,8 +645,6 @@ exports.clientVars = (hookName, context, callback) => {
|
|||
};
|
||||
```
|
||||
|
||||
This can be accessed on the client-side using `clientVars.currentYear`.
|
||||
|
||||
## getLineHTMLForExport
|
||||
Called from: src/node/utils/ExportHtml.js
|
||||
|
||||
|
|
|
@ -33,3 +33,18 @@ code{
|
|||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
table, th, td {
|
||||
text-align: left;
|
||||
border: 1px solid gray;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.5em;
|
||||
background: #EEE;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ The `settings.json.docker` available by default allows to control almost every s
|
|||
| `DB_USER` | a database user with sufficient permissions to create tables | |
|
||||
| `DB_PASS` | the password for the database username | |
|
||||
| `DB_CHARSET` | the character set for the tables (only required for MySQL) | |
|
||||
| `DB_FILENAME` | in case `DB_TYPE` is `DirtyDB`, the database filename. | `var/dirty.db` |
|
||||
| `DB_FILENAME` | in case `DB_TYPE` is `DirtyDB` or `sqlite`, the database file. | `var/dirty.db`, `var/etherpad.sq3` |
|
||||
|
||||
If your database needs additional settings, you will have to use a personalized `settings.json.docker` and rebuild the container (or otherwise put the updated `settings.json` inside your image).
|
||||
|
||||
|
|
|
@ -225,7 +225,7 @@ publish your plugin.
|
|||
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
||||
"contributors": [],
|
||||
"dependencies": {"MODULE": "0.3.20"},
|
||||
"engines": { "node": "^10.17.0 || >=11.14.0"}
|
||||
"engines": {"node": ">=12.13.0"}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -24,6 +24,31 @@
|
|||
*
|
||||
* This is useful, for example, when running in a Docker container.
|
||||
*
|
||||
* DETAILED RULES:
|
||||
* - If the environment variable is set to the string "true" or "false", the
|
||||
* value becomes Boolean true or false.
|
||||
* - If the environment variable is set to the string "null", the value
|
||||
* becomes null.
|
||||
* - If the environment variable is set to the string "undefined", the setting
|
||||
* is removed entirely, except when used as the member of an array in which
|
||||
* case it becomes null.
|
||||
* - If the environment variable is set to a string representation of a finite
|
||||
* number, the string is converted to that number.
|
||||
* - If the environment variable is set to any other string, including the
|
||||
* empty string, the value is that string.
|
||||
* - If the environment variable is unset and a default value is provided, the
|
||||
* value is as if the environment variable was set to the provided default:
|
||||
* - "${UNSET_VAR:}" becomes the empty string.
|
||||
* - "${UNSET_VAR:foo}" becomes the string "foo".
|
||||
* - "${UNSET_VAR:true}" and "${UNSET_VAR:false}" become true and false.
|
||||
* - "${UNSET_VAR:null}" becomes null.
|
||||
* - "${UNSET_VAR:undefined}" causes the setting to be removed (or be set
|
||||
* to null, if used as a member of an array).
|
||||
* - If the environment variable is unset and no default value is provided,
|
||||
* the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF
|
||||
* ETHERPAD; if you want the default value to be null, you should explicitly
|
||||
* specify "null" as the default value.
|
||||
*
|
||||
* EXAMPLE:
|
||||
* "port": "${PORT:9001}"
|
||||
* "minify": "${MINIFY}"
|
||||
|
@ -80,10 +105,12 @@
|
|||
"title": "${TITLE:Etherpad}",
|
||||
|
||||
/*
|
||||
* favicon default name
|
||||
* alternatively, set up a fully specified Url to your own favicon
|
||||
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
||||
* used if one is provided by the skin, otherwise the default Etherpad favicon
|
||||
* is used. If this is a relative path it is interpreted as relative to the
|
||||
* Etherpad root directory.
|
||||
*/
|
||||
"favicon": "${FAVICON:favicon.ico}",
|
||||
"favicon": "${FAVICON:null}",
|
||||
|
||||
/*
|
||||
* Skin name.
|
||||
|
@ -180,12 +207,12 @@
|
|||
|
||||
"dbType": "${DB_TYPE:dirty}",
|
||||
"dbSettings": {
|
||||
"host": "${DB_HOST}",
|
||||
"port": "${DB_PORT}",
|
||||
"database": "${DB_NAME}",
|
||||
"user": "${DB_USER}",
|
||||
"password": "${DB_PASS}",
|
||||
"charset": "${DB_CHARSET}",
|
||||
"host": "${DB_HOST:undefined}",
|
||||
"port": "${DB_PORT:undefined}",
|
||||
"database": "${DB_NAME:undefined}",
|
||||
"user": "${DB_USER:undefined}",
|
||||
"password": "${DB_PASS:undefined}",
|
||||
"charset": "${DB_CHARSET:undefined}",
|
||||
"filename": "${DB_FILENAME:var/dirty.db}"
|
||||
},
|
||||
|
||||
|
@ -283,7 +310,7 @@
|
|||
* it to null disables Abiword and will only allow plain text and HTML
|
||||
* import/exports.
|
||||
*/
|
||||
"abiword": "${ABIWORD}",
|
||||
"abiword": "${ABIWORD:null}",
|
||||
|
||||
/*
|
||||
* This is the absolute path to the soffice executable.
|
||||
|
@ -291,7 +318,7 @@
|
|||
* LibreOffice can be used in lieu of Abiword to export pads.
|
||||
* Setting it to null disables LibreOffice exporting.
|
||||
*/
|
||||
"soffice": "${SOFFICE}",
|
||||
"soffice": "${SOFFICE:null}",
|
||||
|
||||
/*
|
||||
* Path to the Tidy executable.
|
||||
|
@ -299,7 +326,7 @@
|
|||
* Tidy is used to improve the quality of exported pads.
|
||||
* Setting it to null disables Tidy.
|
||||
*/
|
||||
"tidyHtml": "${TIDY_HTML}",
|
||||
"tidyHtml": "${TIDY_HTML:null}",
|
||||
|
||||
/*
|
||||
* Allow import of file types other than the supported ones:
|
||||
|
@ -429,13 +456,13 @@
|
|||
"admin": {
|
||||
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
|
||||
// 2) please note that if password is null, the user will not be created
|
||||
"password": "${ADMIN_PASSWORD}",
|
||||
"password": "${ADMIN_PASSWORD:null}",
|
||||
"is_admin": true
|
||||
},
|
||||
"user": {
|
||||
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
|
||||
// 2) please note that if password is null, the user will not be created
|
||||
"password": "${USER_PASSWORD}",
|
||||
"password": "${USER_PASSWORD:null}",
|
||||
"is_admin": false
|
||||
}
|
||||
},
|
||||
|
@ -463,6 +490,11 @@
|
|||
*/
|
||||
"loadTest": "${LOAD_TEST:false}",
|
||||
|
||||
/**
|
||||
* Disable dump of objects preventing a clean exit
|
||||
*/
|
||||
"dumpOnUncleanExit": false,
|
||||
|
||||
/*
|
||||
* Disable indentation on new line when previous line ends with some special
|
||||
* chars (':', '[', '(', '{')
|
||||
|
|
|
@ -15,6 +15,31 @@
|
|||
*
|
||||
* This is useful, for example, when running in a Docker container.
|
||||
*
|
||||
* DETAILED RULES:
|
||||
* - If the environment variable is set to the string "true" or "false", the
|
||||
* value becomes Boolean true or false.
|
||||
* - If the environment variable is set to the string "null", the value
|
||||
* becomes null.
|
||||
* - If the environment variable is set to the string "undefined", the setting
|
||||
* is removed entirely, except when used as the member of an array in which
|
||||
* case it becomes null.
|
||||
* - If the environment variable is set to a string representation of a finite
|
||||
* number, the string is converted to that number.
|
||||
* - If the environment variable is set to any other string, including the
|
||||
* empty string, the value is that string.
|
||||
* - If the environment variable is unset and a default value is provided, the
|
||||
* value is as if the environment variable was set to the provided default:
|
||||
* - "${UNSET_VAR:}" becomes the empty string.
|
||||
* - "${UNSET_VAR:foo}" becomes the string "foo".
|
||||
* - "${UNSET_VAR:true}" and "${UNSET_VAR:false}" become true and false.
|
||||
* - "${UNSET_VAR:null}" becomes null.
|
||||
* - "${UNSET_VAR:undefined}" causes the setting to be removed (or be set
|
||||
* to null, if used as a member of an array).
|
||||
* - If the environment variable is unset and no default value is provided,
|
||||
* the value becomes null. THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF
|
||||
* ETHERPAD; if you want the default value to be null, you should explicitly
|
||||
* specify "null" as the default value.
|
||||
*
|
||||
* EXAMPLE:
|
||||
* "port": "${PORT:9001}"
|
||||
* "minify": "${MINIFY}"
|
||||
|
@ -71,10 +96,12 @@
|
|||
"title": "Etherpad",
|
||||
|
||||
/*
|
||||
* favicon default name
|
||||
* alternatively, set up a fully specified Url to your own favicon
|
||||
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
||||
* used if one is provided by the skin, otherwise the default Etherpad favicon
|
||||
* is used. If this is a relative path it is interpreted as relative to the
|
||||
* Etherpad root directory.
|
||||
*/
|
||||
"favicon": "favicon.ico",
|
||||
"favicon": null,
|
||||
|
||||
/*
|
||||
* Skin name.
|
||||
|
@ -468,6 +495,11 @@
|
|||
*/
|
||||
"loadTest": false,
|
||||
|
||||
/**
|
||||
* Disable dump of objects preventing a clean exit
|
||||
*/
|
||||
"dumpOnUncleanExit": false,
|
||||
|
||||
/*
|
||||
* Disable indentation on new line when previous line ends with some special
|
||||
* chars (':', '[', '(', '{')
|
||||
|
|
|
@ -36,4 +36,4 @@ src/bin/installDeps.sh "$@" || exit 1
|
|||
#Move to the node folder and start
|
||||
echo "Starting Etherpad..."
|
||||
|
||||
exec node $(compute_node_args) src/node/server.js "$@"
|
||||
exec node src/node/server.js "$@"
|
||||
|
|
|
@ -16,4 +16,4 @@ echo "Open 'chrome://inspect' on Chrome to start debugging."
|
|||
|
||||
# Use 0.0.0.0 to allow external connections to the debugger
|
||||
# (ex: running Etherpad on a docker container). Use default port # (9229)
|
||||
exec node $(compute_node_args) --inspect=0.0.0.0:9229 src/node/server.js "$@"
|
||||
exec node --inspect=0.0.0.0:9229 src/node/server.js "$@"
|
||||
|
|
|
@ -146,15 +146,16 @@ const buildToc = (lexed, filename, cb) => {
|
|||
lexed.forEach((tok) => {
|
||||
if (tok.type !== 'heading') return;
|
||||
if (tok.depth - depth > 1) {
|
||||
return cb(new Error(`Inappropriate heading level\n${
|
||||
JSON.stringify(tok)}`));
|
||||
return cb(new Error(`Inappropriate heading level\n${JSON.stringify(tok)}`));
|
||||
}
|
||||
|
||||
depth = tok.depth;
|
||||
const id = getId(`${filename}_${tok.text.trim()}`);
|
||||
toc.push(`${new Array((depth - 1) * 2 + 1).join(' ')
|
||||
}* <a href="#${id}">${
|
||||
tok.text}</a>`);
|
||||
|
||||
const slugger = new marked.Slugger();
|
||||
const id = slugger.slug(`${filename}_${tok.text.trim()}`);
|
||||
|
||||
toc.push(`${new Array((depth - 1) * 2 + 1).join(' ')}* <a href="#${id}">${tok.text}</a>`);
|
||||
|
||||
tok.text += `<span><a class="mark" href="#${id}" ` +
|
||||
`id="${id}">#</a></span>`;
|
||||
});
|
||||
|
@ -162,17 +163,3 @@ const buildToc = (lexed, filename, cb) => {
|
|||
toc = marked.parse(toc.join('\n'));
|
||||
cb(null, toc);
|
||||
};
|
||||
|
||||
const idCounters = {};
|
||||
const getId = (text) => {
|
||||
text = text.toLowerCase();
|
||||
text = text.replace(/[^a-z0-9]+/g, '_');
|
||||
text = text.replace(/^_+|_+$/, '');
|
||||
text = text.replace(/^([^a-z])/, '_$1');
|
||||
if (Object.prototype.hasOwnProperty.call(idCounters, text)) {
|
||||
text += `_${++idCounters[text]}`;
|
||||
} else {
|
||||
idCounters[text] = 0;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"description": "Internal tool for generating Node.js API docs",
|
||||
"version": "0.0.0",
|
||||
"engines": {
|
||||
"node": ">=10.17.0"
|
||||
"node": ">=12.13.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^2.0.0"
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||
process.on('unhandledRejection', (err) => { throw err; });
|
||||
|
||||
const util = require('util');
|
||||
|
||||
if (process.argv.length !== 3) throw new Error('Use: node extractPadData.js $PADID');
|
||||
|
||||
// get the padID
|
||||
|
|
|
@ -19,4 +19,4 @@ cd "${MY_DIR}/../.." || exit 1
|
|||
echo "Running directly, without checking/installing dependencies"
|
||||
|
||||
# run Etherpad main class
|
||||
exec node $(compute_node_args) src/node/server.js "$@"
|
||||
exec node src/node/server.js "$@"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# minimum required node version
|
||||
REQUIRED_NODE_MAJOR=10
|
||||
REQUIRED_NODE_MAJOR=12
|
||||
REQUIRED_NODE_MINOR=13
|
||||
|
||||
# minimum required npm version
|
||||
|
@ -50,16 +50,6 @@ get_program_version() {
|
|||
}
|
||||
|
||||
|
||||
compute_node_args() {
|
||||
ARGS=""
|
||||
|
||||
NODE_MAJOR=$(get_program_version "node" "major")
|
||||
[ "$NODE_MAJOR" -eq "10" ] && ARGS="$ARGS --experimental-worker"
|
||||
|
||||
echo $ARGS
|
||||
}
|
||||
|
||||
|
||||
require_minimal_version() {
|
||||
PROGRAM_LABEL="$1"
|
||||
VERSION="$2"
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||
process.on('unhandledRejection', (err) => { throw err; });
|
||||
|
||||
const util = require('util');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const log = (str) => {
|
||||
|
@ -46,6 +48,7 @@ const unescape = (val) => {
|
|||
(async () => {
|
||||
const fs = require('fs');
|
||||
const log4js = require('log4js');
|
||||
const readline = require('readline');
|
||||
const settings = require('../node/utils/Settings');
|
||||
const ueberDB = require('ueberdb2');
|
||||
|
||||
|
@ -69,14 +72,12 @@ const unescape = (val) => {
|
|||
await util.promisify(db.init.bind(db))();
|
||||
log('done');
|
||||
|
||||
log('open output file...');
|
||||
const lines = fs.readFileSync(sqlFile, 'utf8').split('\n');
|
||||
log(`Opening ${sqlFile}...`);
|
||||
const stream = fs.createReadStream(sqlFile, {encoding: 'utf8'});
|
||||
|
||||
const count = lines.length;
|
||||
log(`Reading ${sqlFile}...`);
|
||||
let keyNo = 0;
|
||||
|
||||
process.stdout.write(`Start importing ${count} keys...\n`);
|
||||
lines.forEach((l) => {
|
||||
for await (const l of readline.createInterface({input: stream, crlfDelay: Infinity})) {
|
||||
if (l.substr(0, 27) === 'REPLACE INTO store VALUES (') {
|
||||
const pos = l.indexOf("', '");
|
||||
const key = l.substr(28, pos - 28);
|
||||
|
@ -86,11 +87,9 @@ const unescape = (val) => {
|
|||
console.log(`unval: ${unescape(value)}`);
|
||||
db.set(key, unescape(value), null);
|
||||
keyNo++;
|
||||
if (keyNo % 1000 === 0) {
|
||||
process.stdout.write(` ${keyNo}/${count}\n`);
|
||||
if (keyNo % 1000 === 0) log(` ${keyNo}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
process.stdout.write('\n');
|
||||
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||
'depended on dbms this may take some time..\n');
|
||||
|
|
|
@ -220,14 +220,15 @@ fs.readdir(pluginPath, (err, rootFiles) => {
|
|||
}
|
||||
|
||||
updateDeps(parsedPackageJSON, 'devDependencies', {
|
||||
'eslint': '^7.20.0',
|
||||
'eslint-config-etherpad': '^1.0.25',
|
||||
'eslint': '^7.28.0',
|
||||
'eslint-config-etherpad': '^2.0.0',
|
||||
'eslint-plugin-cypress': '^2.11.3',
|
||||
'eslint-plugin-eslint-comments': '^3.2.0',
|
||||
'eslint-plugin-mocha': '^8.0.0',
|
||||
'eslint-plugin-mocha': '^9.0.0',
|
||||
'eslint-plugin-node': '^11.1.0',
|
||||
'eslint-plugin-prefer-arrow': '^1.2.3',
|
||||
'eslint-plugin-promise': '^4.3.1',
|
||||
'eslint-plugin-you-dont-need-lodash-underscore': '^6.11.0',
|
||||
'eslint-plugin-promise': '^5.1.0',
|
||||
'eslint-plugin-you-dont-need-lodash-underscore': '^6.12.0',
|
||||
});
|
||||
|
||||
updateDeps(parsedPackageJSON, 'peerDependencies', {
|
||||
|
@ -263,7 +264,7 @@ fs.readdir(pluginPath, (err, rootFiles) => {
|
|||
console.warn('No engines or node engine in package.json');
|
||||
if (autoFix) {
|
||||
const engines = {
|
||||
node: '^10.17.0 || >=11.14.0',
|
||||
node: '>=12.13.0',
|
||||
};
|
||||
parsedPackageJSON.engines = engines;
|
||||
writePackageJson(parsedPackageJSON);
|
||||
|
|
|
@ -14,7 +14,7 @@ const currentTime = new Date();
|
|||
const diffTime = Math.abs(currentTime - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > (365 * 2)) {
|
||||
console.log(`${name}, ${plugins[plugin].data.maintainers[0].email}`)
|
||||
console.log(`${name}, ${plugins[plugin].data.maintainers[0].email}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -32,4 +32,4 @@ src/bin/installDeps.sh "$@" || exit 1
|
|||
# Move to the node folder and start
|
||||
log "Starting Etherpad..."
|
||||
|
||||
exec node $(compute_node_args) src/node/server.js "$@"
|
||||
exec node src/node/server.js "$@"
|
||||
|
|
|
@ -1,20 +1,11 @@
|
|||
#!/bin/sh
|
||||
|
||||
#Move to the folder where ep-lite is installed
|
||||
cd $(dirname $0)
|
||||
|
||||
#Was this script started in the bin folder? if yes move out
|
||||
if [ -d "../bin" ]; then
|
||||
cd "../"
|
||||
fi
|
||||
|
||||
# npm outdated --depth=0 | grep -v "^Package" | awk '{print $1}' | xargs npm install $1 --save-dev
|
||||
OUTDATED=$(npm outdated --depth=0 | grep -v "^Package" | awk '{print $1}')
|
||||
# echo $OUTDATED
|
||||
if test -n "$OUTDATED"; then
|
||||
echo "Plugins require update, doing this now..."
|
||||
echo "Updating $OUTDATED"
|
||||
npm install $OUTDATED --save-dev
|
||||
else
|
||||
echo "Plugins are all up to date"
|
||||
fi
|
||||
set -e
|
||||
mydir=$(cd "${0%/*}" && pwd -P) || exit 1
|
||||
cd "${mydir}"/../..
|
||||
OUTDATED=$(npm outdated --depth=0 | awk '{print $1}' | grep '^ep_') || {
|
||||
echo "All plugins are up-to-date"
|
||||
exit 0
|
||||
}
|
||||
set -- ${OUTDATED}
|
||||
echo "Updating plugins: $*"
|
||||
exec npm install --no-save "$@"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"authors": [
|
||||
"Alami",
|
||||
"Ali1",
|
||||
"ArticleEditor404",
|
||||
"Haytham morsy",
|
||||
"Meno25",
|
||||
"Mido",
|
||||
|
@ -14,11 +15,21 @@
|
|||
"محمد أحمد عبد الفتاح"
|
||||
]
|
||||
},
|
||||
"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.description": "الوصف",
|
||||
"admin_plugins.installed_uninstall.value": "فك التنصيب",
|
||||
"admin_plugins.last-update": "آخر تحديث",
|
||||
"admin_plugins.name": "الاسم",
|
||||
"admin_plugins.version": "الإصدار",
|
||||
"admin_plugins_info.version_latest": "أحدث نسخة متاحة",
|
||||
"admin_plugins_info.version_number": "رقم الإصدار",
|
||||
"admin_settings": "إعدادات",
|
||||
"admin_settings.current": "التكوين الحالي",
|
||||
"index.newPad": "باد جديد",
|
||||
"index.createOpenPad": "أو صنع/فتح باد بوضع اسمه:",
|
||||
"index.openPad": "افتح باد موجودة بالاسم:",
|
||||
|
@ -90,6 +101,8 @@
|
|||
"pad.modals.corruptPad.cause": "قد يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة.",
|
||||
"pad.modals.deleted": "محذوف.",
|
||||
"pad.modals.deleted.explanation": "تمت إزالة هذا الباد.",
|
||||
"pad.modals.rateLimited.explanation": "لقد أرسلت عددًا كبيرًا جدًا من الرسائل إلى هذه اللوحة ، لذا فقد قطع اتصالك.",
|
||||
"pad.modals.rejected.explanation": "رفض الخادم رسالة أرسلها متصفحك.",
|
||||
"pad.modals.disconnected": "لم تعد متصلا.",
|
||||
"pad.modals.disconnected.explanation": "تم فقدان الاتصال بالخادم",
|
||||
"pad.modals.disconnected.cause": "قد يكون الخادم غير متوفر. يرجى إعلام مسؤول الخدمة إذا كان هذا لا يزال يحدث.",
|
||||
|
|
|
@ -12,11 +12,11 @@
|
|||
]
|
||||
},
|
||||
"admin_plugins.available_fetching": "আনা হচ্ছে...",
|
||||
"admin_plugins.available_install.value": "ইন্সটল করুন",
|
||||
"admin_plugins.available_install.value": "ইনস্টল করুন",
|
||||
"admin_plugins.description": "বিবরণ",
|
||||
"admin_plugins.installed": "ইন্সটল হওয়া প্লাগিনসমূহ",
|
||||
"admin_plugins.installed_fetching": "ইন্সটলকৃত প্লাগিন আনা হচ্ছে",
|
||||
"admin_plugins.installed_uninstall.value": "আনইন্সটল করুন",
|
||||
"admin_plugins.installed_uninstall.value": "আনইনস্টল করুন",
|
||||
"admin_plugins.last-update": "সর্বশেষ হালনাগাদ",
|
||||
"admin_plugins.name": "নাম",
|
||||
"admin_plugins.version": "সংস্করণ",
|
||||
|
@ -52,7 +52,7 @@
|
|||
"pad.permissionDenied": "দুঃখিত, এ প্যাড-টি দেখার অধিকার আপনার নেই",
|
||||
"pad.settings.padSettings": "প্যাডের স্থাপন",
|
||||
"pad.settings.myView": "আমার দৃশ্য",
|
||||
"pad.settings.stickychat": "চ্যাট সক্রীনে প্রদর্শন করা হবে",
|
||||
"pad.settings.stickychat": "সর্বদা পর্দায় চ্যাট দেখান",
|
||||
"pad.settings.chatandusers": "চ্যাট এবং ব্যবহারকারী দেখান",
|
||||
"pad.settings.colorcheck": "লেখকদের নিজস্ব নির্বাচিত রং",
|
||||
"pad.settings.linenocheck": "লাইন নম্বর",
|
||||
|
@ -61,19 +61,19 @@
|
|||
"pad.settings.fontType.normal": "সাধারণ",
|
||||
"pad.settings.language": "ভাষা:",
|
||||
"pad.settings.about": "পরিচিতি",
|
||||
"pad.settings.poweredBy": "$1 দ্বারা চালিত",
|
||||
"pad.settings.poweredBy": "এটি দ্বারা চালিত:",
|
||||
"pad.importExport.import_export": "আমদানি/রপ্তানি",
|
||||
"pad.importExport.import": "কোন টেক্সট ফাইল বা নথি আপলোড করুন",
|
||||
"pad.importExport.importSuccessful": "সফল!",
|
||||
"pad.importExport.export": "এই প্যাডটি রপ্তানি করুন:",
|
||||
"pad.importExport.export": "এইরূপে এই প্যাডটি রপ্তানি করুন:",
|
||||
"pad.importExport.exportetherpad": "ইথারপ্যাড",
|
||||
"pad.importExport.exporthtml": "এইচটিএমএল",
|
||||
"pad.importExport.exportplain": "সাধারণ লেখা",
|
||||
"pad.importExport.exportword": "মাইক্রোসফট ওয়ার্ড",
|
||||
"pad.importExport.exportpdf": "পিডিএফ",
|
||||
"pad.importExport.exportopen": "ওডিএফ (ওপেন ডকুমেন্ট ফরম্যাট)",
|
||||
"pad.modals.connected": "যোগাযোগ সফল",
|
||||
"pad.modals.reconnecting": "আপনার প্যাডের সাথে সংযোগস্থাপন করা হচ্ছে..",
|
||||
"pad.modals.connected": "সংযোগস্থাপন করা হয়েছে।",
|
||||
"pad.modals.reconnecting": "আপনার প্যাডের সাথে সংযোগস্থাপন করা হচ্ছে…",
|
||||
"pad.modals.forcereconnect": "পুনরায় সংযোগস্থাপনের চেষ্টা",
|
||||
"pad.modals.userdup": "অন্য উইন্ডো-তে খোলা হয়েছে",
|
||||
"pad.modals.unauth": "আপনার অধিকার নেই",
|
||||
|
|
|
@ -5,14 +5,49 @@
|
|||
"Dalba",
|
||||
"Ebraminio",
|
||||
"FarsiNevis",
|
||||
"Jeeputer",
|
||||
"Omid.koli",
|
||||
"Reza1615",
|
||||
"ZxxZxxZ",
|
||||
"الناز"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "داشبورد مدیر - اترپد",
|
||||
"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.description": "توضیحات",
|
||||
"admin_plugins.installed": "افزونههای نصبشده",
|
||||
"admin_plugins.installed_fetching": "در حال واکشی افزونههای نصبشده...",
|
||||
"admin_plugins.installed_nothing": "شما هنوز هیچ افزونهای را نصب نکردهاید.",
|
||||
"admin_plugins.installed_uninstall.value": "از کار انداختن",
|
||||
"admin_plugins.last-update": "آخرین بهروزرسانی",
|
||||
"admin_plugins.name": "نام",
|
||||
"admin_plugins.page-title": "مدیریت افزونهها - اترپد",
|
||||
"admin_plugins.version": "نسخه",
|
||||
"admin_plugins_info": "اطلاعات عیبیابی",
|
||||
"admin_plugins_info.hooks": "قلابهای نصبشده",
|
||||
"admin_plugins_info.hooks_client": "قلابهای سمت مشتری",
|
||||
"admin_plugins_info.hooks_server": "قلابهای سمت سرور",
|
||||
"admin_plugins_info.parts": "قسمتهای نصبشده",
|
||||
"admin_plugins_info.plugins": "افزونههای نصبشده",
|
||||
"admin_plugins_info.page-title": "اطلاعات افزونه - اترپد",
|
||||
"admin_plugins_info.version": "نسخهٔ اترپد",
|
||||
"admin_plugins_info.version_latest": "آخرین نسخهٔ موجود",
|
||||
"admin_plugins_info.version_number": "شمارهٔ نسخه",
|
||||
"admin_settings": "تنظیمات",
|
||||
"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.page-title": "تنظیمات - اترپد",
|
||||
"index.newPad": "دفترچه یادداشت تازه",
|
||||
"index.createOpenPad": "یا ایجاد/بازکردن یک دفترچه یادداشت با نام:",
|
||||
"index.openPad": "باز کردن یک پد موجود با نام:",
|
||||
"pad.toolbar.bold.title": "پررنگ (Ctrl-B)",
|
||||
"pad.toolbar.italic.title": "کج (Ctrl-I)",
|
||||
"pad.toolbar.underline.title": "زیرخط (Ctrl-U)",
|
||||
|
@ -20,7 +55,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)",
|
||||
|
@ -33,7 +68,7 @@
|
|||
"pad.colorpicker.save": "ذخیره",
|
||||
"pad.colorpicker.cancel": "لغو",
|
||||
"pad.loading": "در حال بارگذاری...",
|
||||
"pad.noCookie": "کوکی یافت نشد. لطفاً اجازهٔ اجرای کوکی در مروگرتان را بدهید!",
|
||||
"pad.noCookie": "کلوچک یافت نشد. لطفاً اجارهٔ استفاده از کلوچک را در مرورگر خود تأیید کنید! نشست و تنظیمات شما در میان بازدیدها ذخیره نخواهد شد. این میتواند به این دلیل باشد که اترپد در برخی مرورگرها در یک iFrame قرار میگیرد. لطفاً مطمئن شوید که اترپد در زیردامنه/دامنهٔ یکسان با iFrame والد قرار دارد",
|
||||
"pad.permissionDenied": "شما اجازهی دسترسی به این دفترچه یادداشت را ندارید",
|
||||
"pad.settings.padSettings": "تنظیمات دفترچه یادداشت",
|
||||
"pad.settings.myView": "نمای من",
|
||||
|
@ -45,6 +80,8 @@
|
|||
"pad.settings.fontType": "نوع قلم:",
|
||||
"pad.settings.fontType.normal": "ساده",
|
||||
"pad.settings.language": "زبان:",
|
||||
"pad.settings.about": "درباره",
|
||||
"pad.settings.poweredBy": "قدرستگرفته از",
|
||||
"pad.importExport.import_export": "درونریزی/برونریزی",
|
||||
"pad.importExport.import": "بارگذاری پروندهی متنی یا سند",
|
||||
"pad.importExport.importSuccessful": "موفقیت آمیز بود!",
|
||||
|
@ -55,9 +92,9 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (قالب سند باز)",
|
||||
"pad.importExport.abiword.innerHTML": "شما تنها میتوانید از قالب متن ساده یا اچتیامال درونریزی کنید. برای بیشتر شدن ویژگیهای درونریزی پیشرفته <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord</a> را نصب کنید.",
|
||||
"pad.importExport.abiword.innerHTML": "شما تنها میتوانید از قالب متن ساده یا اچتیامال درونریزی کنید. برای بیشتر شدن ویژگیهای درونریزی پیشرفته <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord یا LibreOffice را نصب کنید</a>.",
|
||||
"pad.modals.connected": "متصل شد.",
|
||||
"pad.modals.reconnecting": "در حال اتصال دوباره به دفترچه یادداشت شما..",
|
||||
"pad.modals.reconnecting": "در حال اتصال دوباره به پد شما...",
|
||||
"pad.modals.forcereconnect": "واداشتن به اتصال دوباره",
|
||||
"pad.modals.reconnecttimer": "تلاش برای اتصال مجدد",
|
||||
"pad.modals.cancel": "لغو",
|
||||
|
@ -79,6 +116,7 @@
|
|||
"pad.modals.corruptPad.cause": "این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.",
|
||||
"pad.modals.deleted": "پاک شد.",
|
||||
"pad.modals.deleted.explanation": "این دفترچه یادداشت پاک شدهاست.",
|
||||
"pad.modals.rateLimited": "نرخ محدود شدهاست.",
|
||||
"pad.modals.disconnected": "اتصال شما قطع شدهاست.",
|
||||
"pad.modals.disconnected.explanation": "اتصال به سرور قطع شدهاست.",
|
||||
"pad.modals.disconnected.cause": "ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.",
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
"pad.modals.disconnected.explanation": "Perdeuse a conexión co servidor",
|
||||
"pad.modals.disconnected.cause": "O servidor non está dispoñible. Póñase en contacto co administrador do servizo se o problema continúa.",
|
||||
"pad.share": "Compartir este documento",
|
||||
"pad.share.readonly": "Lectura só",
|
||||
"pad.share.readonly": "Só lectura",
|
||||
"pad.share.link": "Ligazón",
|
||||
"pad.share.emebdcode": "Incorporar o URL",
|
||||
"pad.chat": "Chat",
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"@metadata": {
|
||||
"authors": [
|
||||
"Bhatakati aatma",
|
||||
"Dsvyas",
|
||||
"Harsh4101991",
|
||||
"KartikMistry"
|
||||
]
|
||||
|
@ -10,7 +11,7 @@
|
|||
"pad.toolbar.bold.title": "બોલ્ડ",
|
||||
"pad.toolbar.settings.title": "ગોઠવણીઓ",
|
||||
"pad.colorpicker.save": "સાચવો",
|
||||
"pad.colorpicker.cancel": "રદ્દ કરો",
|
||||
"pad.colorpicker.cancel": "રદ કરો",
|
||||
"pad.loading": "લાવે છે...",
|
||||
"pad.noCookie": "કુકી મળી નહી. આપના બ્રાઉઝર સેટિંગમાં જઇ કુકી સક્રિય કરો!",
|
||||
"pad.permissionDenied": "આ પેડના ઉપયોગની આપને પરવાનગી નથી",
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"pad.toolbar.bold.title": "Grasse (Ctrl-B)",
|
||||
"pad.toolbar.italic.title": "Italic (Ctrl-I)",
|
||||
"pad.toolbar.underline.title": "Sublinear (Ctrl-U)",
|
||||
"pad.toolbar.strikethrough.title": "Cancellar (Ctrl+5)",
|
||||
"pad.toolbar.strikethrough.title": "Barrate (Ctrl+5)",
|
||||
"pad.toolbar.ol.title": "Lista ordinate (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ul.title": "Lista non ordinate (Ctrl+Shift+L)",
|
||||
"pad.toolbar.indent.title": "Indentar (TAB)",
|
||||
|
@ -26,7 +26,7 @@
|
|||
"pad.colorpicker.save": "Salveguardar",
|
||||
"pad.colorpicker.cancel": "Cancellar",
|
||||
"pad.loading": "Cargamento…",
|
||||
"pad.noCookie": "Le cookie non pote esser trovate. Per favor permitte le cookies in tu navigator!",
|
||||
"pad.noCookie": "Le cookie non pote esser trovate. Per favor permitte le cookies in tu navigator! Tu session e parametros non essera salveguardate inter visitas. Isto pote esser debite al facto que Etherpad ha essite includite in un iFrame in alcun navigatores. Assecura te que Etherpad es sure le mesme subdominio/dominio que su iFrame parente.",
|
||||
"pad.permissionDenied": "Tu non ha le permission de acceder a iste pad",
|
||||
"pad.settings.padSettings": "Configuration del pad",
|
||||
"pad.settings.myView": "Mi vista",
|
||||
|
@ -50,7 +50,7 @@
|
|||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.abiword.innerHTML": "Tu pote solmente importar files in formato de texto simple o HTML. Pro functionalitate de importation plus extense, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installa AbiWord o LibreOffice</a>.",
|
||||
"pad.modals.connected": "Connectite.",
|
||||
"pad.modals.reconnecting": "Reconnecte a tu pad…",
|
||||
"pad.modals.reconnecting": "Reconnexion a tu pad…",
|
||||
"pad.modals.forcereconnect": "Fortiar reconnexion",
|
||||
"pad.modals.reconnecttimer": "Tentativa de reconnexion in",
|
||||
"pad.modals.cancel": "Cancellar",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"@metadata": {
|
||||
"authors": [
|
||||
"Ajeje Brazorf",
|
||||
"Beta16",
|
||||
"Gianfranco",
|
||||
"Jack",
|
||||
|
@ -10,6 +11,12 @@
|
|||
"Vituzzu"
|
||||
]
|
||||
},
|
||||
"admin_plugins.available_install.value": "Installa",
|
||||
"admin_plugins.installed_uninstall.value": "Disinstalla",
|
||||
"admin_plugins.last-update": "Ultimo aggiornamento",
|
||||
"admin_plugins.name": "Nome",
|
||||
"admin_plugins.version": "Versione",
|
||||
"admin_settings": "Impostazioni",
|
||||
"index.newPad": "Nuovo pad",
|
||||
"index.createOpenPad": "o crea/apre un pad con il nome:",
|
||||
"index.openPad": "apri un Pad esistente col nome:",
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
"pad.colorpicker.save": "Späicheren",
|
||||
"pad.colorpicker.cancel": "Ofbriechen",
|
||||
"pad.loading": "Lueden...",
|
||||
"pad.noCookie": "Cookie gouf net fonnt. Erlaabt wgl. Cookien an Ärem Browser!",
|
||||
"pad.noCookie": "Cookie gouf net fonnt. Erlaabt wgl. Cookien an Ärem Browser! Är Sessioun an Är Astellungen ginn net tëscht de Visitte gespäichert. Dëst kann doduerch bedéngt sinn datt Etherpad a verschiddene Browser an iFrameën agebaut ass. Vergewëssert Iech wgl. datt Etherpad am selwechten Subdomain/Domain ass wéi den iwwergeuerdneten iFrame",
|
||||
"pad.permissionDenied": "Dir hutt net déi néideg Rechter fir dëse Pad opzemaachen",
|
||||
"pad.settings.myView": "Méng Usiicht",
|
||||
"pad.settings.linenocheck": "Zeilennummeren",
|
||||
|
|
|
@ -5,6 +5,33 @@
|
|||
"Quentí"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "Panèl d’administracion - Etherpad",
|
||||
"admin_plugins": "Gestion de las extensions",
|
||||
"admin_plugins.available": "Extensions disponiblas",
|
||||
"admin_plugins.available_not-found": "Cap d’extension pas trobada.",
|
||||
"admin_plugins.available_fetching": "Recuperacion…",
|
||||
"admin_plugins.available_install.value": "Installar",
|
||||
"admin_plugins.available_search.placeholder": "Cercar las extensions d’installar",
|
||||
"admin_plugins.description": "Descripcion",
|
||||
"admin_plugins.installed": "Extensions installadas",
|
||||
"admin_plugins.installed_fetching": "Recuperacion de las extensions installadas...",
|
||||
"admin_plugins.installed_nothing": "Avètz pas installat cap d’extensions pel moment.",
|
||||
"admin_plugins.installed_uninstall.value": "Desinstallar",
|
||||
"admin_plugins.last-update": "Darrièra mesa a jorn",
|
||||
"admin_plugins.name": "Nom",
|
||||
"admin_plugins.page-title": "Gestion de las extensions - Etherpad",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Informacion de resolucion de problèmas",
|
||||
"admin_plugins_info.plugins": "Extensions installadas",
|
||||
"admin_plugins_info.page-title": "Informacion d’extension - Etherpad",
|
||||
"admin_plugins_info.version": "Version d’Etherpad",
|
||||
"admin_plugins_info.version_latest": "Darrièra version disponibla",
|
||||
"admin_plugins_info.version_number": "Numèro de version",
|
||||
"admin_settings": "Paramètres",
|
||||
"admin_settings.current": "Configuracion actuala",
|
||||
"admin_settings.current_restart.value": "Reaviar Etherpad",
|
||||
"admin_settings.current_save.value": "Enregistrar los paramètres",
|
||||
"admin_settings.page-title": "Paramètres - Etherpad",
|
||||
"index.newPad": "Pad novèl",
|
||||
"index.createOpenPad": "o crear/dobrir un Pad intitulat :",
|
||||
"pad.toolbar.bold.title": "Gras (Ctrl-B)",
|
||||
|
@ -39,6 +66,8 @@
|
|||
"pad.settings.fontType": "Tipe de poliça :",
|
||||
"pad.settings.fontType.normal": "Normal",
|
||||
"pad.settings.language": "Lenga :",
|
||||
"pad.settings.about": "A prepaus",
|
||||
"pad.settings.poweredBy": "Propulsat per",
|
||||
"pad.importExport.import_export": "Importar/Exportar",
|
||||
"pad.importExport.import": "Cargar un tèxte o un document",
|
||||
"pad.importExport.importSuccessful": "Capitat !",
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"MuratTheTurkish",
|
||||
"Ti4goc",
|
||||
"Tuliouel",
|
||||
"Unamane",
|
||||
"Waldir",
|
||||
"Waldyrious"
|
||||
]
|
||||
|
@ -29,7 +30,7 @@
|
|||
"admin_plugins.installed_fetching": "A obter plugins instalados...",
|
||||
"admin_plugins.installed_nothing": "Não instalas-te nenhum plugin ainda.",
|
||||
"admin_plugins.installed_uninstall.value": "Desinstalar",
|
||||
"admin_plugins.last-update": "Ultima atualização",
|
||||
"admin_plugins.last-update": "Última atualização",
|
||||
"admin_plugins.name": "Nome",
|
||||
"admin_plugins.page-title": "Gestor de plugins - Etherpad",
|
||||
"admin_plugins.version": "Versão",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"@metadata": {
|
||||
"authors": [
|
||||
"BryanDavis",
|
||||
"Liuxinyu970226",
|
||||
"Mklehr",
|
||||
"Nemo bis",
|
||||
|
@ -10,6 +11,12 @@
|
|||
"Tim.krieger"
|
||||
]
|
||||
},
|
||||
"admin_plugins.available_install.value": "{{Identical|Install}}",
|
||||
"admin_plugins.description": "{{Identical|Description}}",
|
||||
"admin_plugins.installed_uninstall.value": "{{Identical|Uninstall}}",
|
||||
"admin_plugins.last-update": "{{Identical|Last update}}",
|
||||
"admin_plugins.name": "{{Identical|Name}}",
|
||||
"admin_plugins.version": "{{Identical|Version}}",
|
||||
"admin_settings": "{{identical|Settings}}",
|
||||
"index.newPad": "Used as button text.\nA pad, in the context of Etherpad, is a notepad, something to write on.",
|
||||
"index.createOpenPad": "label for an input field that allows the user to choose a custom name for his new pad. In case the pad already exists the user will be redirected to its url.",
|
||||
|
@ -43,6 +50,7 @@
|
|||
"pad.settings.fontType": "Used as label for the \"Font type\" select box which has the following options:\n* {{msg-etherpadlite|Pad.settings.fontType.normal}}\n* {{msg-etherpadlite|Pad.settings.fontType.monospaced}}",
|
||||
"pad.settings.fontType.normal": "Used as an option in the \"Font type\" select box which is labeled {{msg-etherpadlite|Pad.settings.fontType}}.\n{{Identical|Normal}}",
|
||||
"pad.settings.language": "This is a label for a select list of languages.\n{{Identical|Language}}",
|
||||
"pad.settings.about": "{{Identical|About}}",
|
||||
"pad.importExport.import_export": "Used as HTML <code><nowiki><h1></nowiki></code> heading of window.\n\nFollowed by the child heading {{msg-etherpadlite|Pad.importExport.import}}.",
|
||||
"pad.importExport.import": "Used as HTML <code><nowiki><h2></nowiki></code> heading.\n\nPreceded by the parent heading {{msg-etherpadlite|Pad.importExport.import_export}}.",
|
||||
"pad.importExport.importSuccessful": "Used as success message to indicate that the pad has been imported successfully.\n{{Identical|Successful}}",
|
||||
|
|
|
@ -5,47 +5,84 @@
|
|||
"Lexected",
|
||||
"Mark",
|
||||
"Rudko",
|
||||
"Teslaton"
|
||||
"Teslaton",
|
||||
"Yardom78"
|
||||
]
|
||||
},
|
||||
"index.newPad": "Nový Pad",
|
||||
"index.createOpenPad": "alebo vytvoriť/otvoriť Pad s názvom:",
|
||||
"pad.toolbar.bold.title": "Tučné (Ctrl-B)",
|
||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl-I)",
|
||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl-U)",
|
||||
"admin.page-title": "Ovládací panel správu - Etherpad",
|
||||
"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.description": "Popis",
|
||||
"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.",
|
||||
"admin_plugins.installed_uninstall.value": "Odinštalovať",
|
||||
"admin_plugins.last-update": "Posledná aktualizácia",
|
||||
"admin_plugins.name": "Názov",
|
||||
"admin_plugins.page-title": "Správca doplnkov - Etherpad",
|
||||
"admin_plugins.version": "Verzia",
|
||||
"admin_plugins_info": "Informácie k riešeniu problémov",
|
||||
"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.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.version": "Verzia Etherpadu",
|
||||
"admin_plugins_info.version_latest": "Posledná dostupná verzia",
|
||||
"admin_plugins_info.version_number": "Číslo verzie",
|
||||
"admin_settings": "Nastavenia",
|
||||
"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ť Ehterpad",
|
||||
"admin_settings.current_save.value": "Uložiť nastavenia",
|
||||
"admin_settings.page-title": "Nastavenia - Etherpad",
|
||||
"index.newPad": "Nový poznámkový blok",
|
||||
"index.createOpenPad": "alebo vytvoriť/otvoriť poznámkový blok s názvom:",
|
||||
"index.openPad": "otvoriť poznámkový blok s názvom:",
|
||||
"pad.toolbar.bold.title": "Tučné (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl+I)",
|
||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl+U)",
|
||||
"pad.toolbar.strikethrough.title": "Prečiarknuté (Ctrl+5)",
|
||||
"pad.toolbar.ol.title": "Usporiadaný zoznam (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ol.title": "Zoradený zoznam (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ul.title": "Nezoradený zoznam (Ctrl+Shift+L)",
|
||||
"pad.toolbar.indent.title": "Zväčšiť odsadenie (TAB)",
|
||||
"pad.toolbar.unindent.title": "Zmenšiť odsadenie (Shift+TAB)",
|
||||
"pad.toolbar.indent.title": "Zväčšiť okraj (TAB)",
|
||||
"pad.toolbar.unindent.title": "Zmenšiť okraj (Shift+TAB)",
|
||||
"pad.toolbar.undo.title": "Späť (Ctrl-Z)",
|
||||
"pad.toolbar.redo.title": "Znova (Ctrl-Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "Odstrániť farby autorstva (Ctrl+Shift+C)",
|
||||
"pad.toolbar.redo.title": "Opakovať (Ctrl-Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "Odstrániť farebné označovanie autorov (Ctrl+Shift+C)",
|
||||
"pad.toolbar.import_export.title": "Import/export z/do rôznych formátov súborov",
|
||||
"pad.toolbar.timeslider.title": "Časová os",
|
||||
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
||||
"pad.toolbar.settings.title": "Nastavenia",
|
||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento Pad",
|
||||
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto Padu",
|
||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
|
||||
"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.",
|
||||
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto Padu",
|
||||
"pad.settings.padSettings": "Nastavenia Padu",
|
||||
"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.myView": "Vlastný pohľad",
|
||||
"pad.settings.stickychat": "Chat stále na obrazovke",
|
||||
"pad.settings.chatandusers": "Zobraziť chat a užívateľov",
|
||||
"pad.settings.colorcheck": "Farby autorstva",
|
||||
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
|
||||
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
|
||||
"pad.settings.colorcheck": "Farby autorov",
|
||||
"pad.settings.linenocheck": "Čísla riadkov",
|
||||
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
||||
"pad.settings.fontType": "Typ písma:",
|
||||
"pad.settings.fontType.normal": "Normálne",
|
||||
"pad.settings.language": "Jazyk:",
|
||||
"pad.settings.about": "O Etherpade",
|
||||
"pad.settings.poweredBy": "Poháňané cez",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
"pad.importExport.import": "Nahrať ľubovoľný textový súbor alebo dokument",
|
||||
"pad.importExport.importSuccessful": "Import úspešný!",
|
||||
"pad.importExport.export": "Exportovať aktuálny Pad ako:",
|
||||
"pad.importExport.export": "Exportovať aktuálny poznámkový blok ako:",
|
||||
"pad.importExport.exportetherpad": "Etherpad",
|
||||
"pad.importExport.exporthtml": "HTML",
|
||||
"pad.importExport.exportplain": "Čistý text",
|
||||
|
@ -54,12 +91,12 @@
|
|||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.abiword.innerHTML": "Importovať môžete len čistý text alebo HTML. Pre pokročilejšie funkcie importu prosím nainštalujte „<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord</a>“.",
|
||||
"pad.modals.connected": "Pripojené.",
|
||||
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu Padu...",
|
||||
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu poznámkovému bloku...",
|
||||
"pad.modals.forcereconnect": "Vynútiť znovupripojenie",
|
||||
"pad.modals.reconnecttimer": "Skúšam sa pripojiť",
|
||||
"pad.modals.cancel": "Zrušiť",
|
||||
"pad.modals.userdup": "Otvorené v inom okne",
|
||||
"pad.modals.userdup.explanation": "Zdá sa, že tento Pad je na tomto počítači otvorený vo viacerých oknách prehliadača.",
|
||||
"pad.modals.userdup.explanation": "Zdá sa, že tento poznámkový blok je na tomto počítači otvorený vo viacerých oknách prehliadača.",
|
||||
"pad.modals.userdup.advice": "Pre použitie tohoto okna se musíte znovu pripojiť.",
|
||||
"pad.modals.unauth": "Nie ste autorizovaný",
|
||||
"pad.modals.unauth.explanation": "Vaše oprávnenia sa počas prehliadania tejto stránky zmenili. Skúste sa pripojiť znovu.",
|
||||
|
@ -70,33 +107,40 @@
|
|||
"pad.modals.initsocketfail.cause": "Príčinou je pravdepodobne problém s prehliadačom alebo internetovým pripojením.",
|
||||
"pad.modals.slowcommit.explanation": "Server neodpovedá.",
|
||||
"pad.modals.slowcommit.cause": "Príčinou môže byť problém so sieťovým pripojením.",
|
||||
"pad.modals.badChangeset.explanation": "Editácia, kterú ste vykonali byla synchronizáciou serveru vyhodnotená ako nepovolená.",
|
||||
"pad.modals.badChangeset.explanation": "Úprava, ktorú ste vykonali bola synchronizáciou serveru vyhodnotená ako nepovolená.",
|
||||
"pad.modals.badChangeset.cause": "To môže byť z dôvodu nesprávnej konfigurácie servera alebo iného neočakávaného správania. Ak máte pocit že došlo k chybe, kontaktuje prosím správcu služby. Pokúste sa pripojiť znova a pokračovať v úpravách.",
|
||||
"pad.modals.corruptPad.explanation": "Pad ku ktorému sa snažíte získať prístup je poškodený.",
|
||||
"pad.modals.corruptPad.explanation": "Poznámkový blok ku ktorému sa snažíte získať prístup je poškodený.",
|
||||
"pad.modals.corruptPad.cause": "To môže byť z dôvodu nesprávnej konfigurácie servera alebo iného neočakávaného správania. Prosím, obráťte sa na správcu služby.",
|
||||
"pad.modals.deleted": "Odstránené.",
|
||||
"pad.modals.deleted.explanation": "Tento Pad bol odstránený.",
|
||||
"pad.modals.deleted.explanation": "Tento poznámkový blok bol odstránený.",
|
||||
"pad.modals.rateLimited": "Rýchlosť obmedzená.",
|
||||
"pad.modals.rateLimited.explanation": "Do tohto poznámkového bloku ste poslali príliš veľa správ a preto ste boli odpojení.",
|
||||
"pad.modals.rejected.explanation": "Server odmietol správu poslanú Vašim prehliadačom.",
|
||||
"pad.modals.rejected.cause": "Počas prehliadania poznámkové bloku mohlo dôjsť k aktualizácii servera alebo je niekde v Etherpade chyba. Skúste stránku načítať znovu.",
|
||||
"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.share": "Zdieľať tento Pad",
|
||||
"pad.share": "Zdieľať tento poznámkový blok",
|
||||
"pad.share.readonly": "Len na čítanie",
|
||||
"pad.share.link": "Odkaz",
|
||||
"pad.share.emebdcode": "Vložiť URL",
|
||||
"pad.chat": "Chat",
|
||||
"pad.chat.title": "Otvoriť chat tohoto Padu.",
|
||||
"pad.chat": "Rozhovor",
|
||||
"pad.chat.title": "Otvoriť rozhovor tohoto poznámkového bloku.",
|
||||
"pad.chat.loadmessages": "Načítať ďalšie správy",
|
||||
"pad.chat.stick.title": "Prilepiť rozhovor na obrazovku",
|
||||
"pad.chat.writeMessage.placeholder": "Sem napíšte svoju správu",
|
||||
"timeslider.followContents": "Sledovať aktualizácie obsahu poznámkového bloku",
|
||||
"timeslider.pageTitle": "Časová os {{appTitle}}",
|
||||
"timeslider.toolbar.returnbutton": "Návrat do Padu",
|
||||
"timeslider.toolbar.returnbutton": "Späť do poznámkového bloku",
|
||||
"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.playPause": "Pustiť / Pozastaviť obsah padu",
|
||||
"timeslider.backRevision": "Ísť v tomto pade a revíziu späť",
|
||||
"timeslider.forwardRevision": "Ísť v tomto pade o revíziu vpred",
|
||||
"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",
|
||||
"timeslider.dateformat": "{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}",
|
||||
"timeslider.month.january": "januára",
|
||||
"timeslider.month.february": "februára",
|
||||
|
@ -110,19 +154,20 @@
|
|||
"timeslider.month.october": "októbra",
|
||||
"timeslider.month.november": "novembra",
|
||||
"timeslider.month.december": "decembra",
|
||||
"timeslider.unnamedauthors": "{{num}} {[ plural(num) one: nemenovaný autor, few: nemenovaní autori, other: nemenovaných autorov ]}",
|
||||
"timeslider.unnamedauthors": "{[plural(num) one:Počet nemenovaných autorov:, other: Počet nemenovaných autorov:]} {{num}}",
|
||||
"pad.savedrevs.marked": "Táto revízia bola označená ako uložená",
|
||||
"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.editbar.clearcolors": "Skutočne odstrániť autorské farby z celého dokumentu?",
|
||||
"pad.impexp.importbutton": "Importovať",
|
||||
"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...",
|
||||
"pad.impexp.confirmimport": "Import súboru prepíše celý súčasný obsah Padu. Skutočne si želáte vykonať túto akciu?",
|
||||
"pad.impexp.confirmimport": "Import súboru prepíše celý súčasný obsah poznámkového bloku. Skutočne si želáte vykonať túto akciu?",
|
||||
"pad.impexp.convertFailed": "Tento súbor nie je možné importovať. Použite prosím iný formát súboru alebo nakopírujte text manuálne",
|
||||
"pad.impexp.padHasData": "Nebolo možné importovať tento súbor, pretože tento pad už bol pozmenený. Importujte prosím súbor do nového padu",
|
||||
"pad.impexp.padHasData": "Nebolo možné importovať tento súbor, pretože tento poznámkový blok už bol pozmenený. Importujte prosím súbor do nového poznámkového bloku",
|
||||
"pad.impexp.uploadFailed": "Nahrávanie zlyhalo, skúste to prosím znovu",
|
||||
"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.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"
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
"pad.modals.unauth": "Nepooblaščen dostop",
|
||||
"pad.modals.unauth.explanation": "Med ogledovanjem strani so se dovoljenja za ogled spremenila. Poskusite se znova povezati.",
|
||||
"pad.modals.looping.explanation": "Zaznane so težave pri komunikaciji s strežnikom za usklajevanje.",
|
||||
"pad.modals.looping.cause": "Morda ste se povezali preko neustrezno nastavljenega požarnega zidu ali posredniškega strežnika.",
|
||||
"pad.modals.looping.cause": "Morda ste se povezali skozi neustrezno nastavljen požarni zid ali s posredniškim strežnikom.",
|
||||
"pad.modals.initsocketfail": "Strežnik je nedosegljiv.",
|
||||
"pad.modals.initsocketfail.explanation": "Povezava s strežnikom za usklajevanje ni mogoča.",
|
||||
"pad.modals.initsocketfail.cause": "Najverjetneje gre za težavo z vašim brskalnikom, ali internetno povezavo.",
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
"admin_settings.current_restart.value": "Rinise Etherpad-in",
|
||||
"admin_settings.current_save.value": "Ruaji Rregullimet",
|
||||
"admin_settings.page-title": "Rregullime - Etherpad",
|
||||
"index.newPad": "Bllok i ri",
|
||||
"index.newPad": "Bllok i Ri",
|
||||
"index.createOpenPad": "ose krijoni/hapni një Bllok me emrin:",
|
||||
"index.openPad": "hapni një Bllok ekzistues me emrin:",
|
||||
"pad.toolbar.bold.title": "Të trasha (Ctrl-B)",
|
||||
|
@ -56,7 +56,7 @@
|
|||
"pad.toolbar.clearAuthorship.title": "Hiqu Ngjyra Autorësish (Ctrl+Shift+C)",
|
||||
"pad.toolbar.import_export.title": "Importoni/Eksportoni nga/në formate të tjera kartelash",
|
||||
"pad.toolbar.timeslider.title": "Rrjedha kohore",
|
||||
"pad.toolbar.savedRevision.title": "Ruaje rishikimin",
|
||||
"pad.toolbar.savedRevision.title": "Ruaje Rishikimin",
|
||||
"pad.toolbar.settings.title": "Rregullime",
|
||||
"pad.toolbar.embed.title": "Ndajeni me të tjerët dhe Trupëzojeni këtë bllok",
|
||||
"pad.toolbar.showusers.title": "Shfaq përdoruesit në këtë bllok",
|
||||
|
@ -66,7 +66,7 @@
|
|||
"pad.noCookie": "S’u gjet dot cookie. Ju lutemi, lejoni cookie-t te shfletuesi juaj! Sesioni dhe rregullimet tuaja s’do të ruhen nga një sesion në tjetër. Kjo mund të vijë ngaqë Etherpad përfshihet brenda një iFrame në disa shfletues. Ju lutemi, sigurohuni që Etherpad-i të jetë në të njëjtën nënpërkatësi/përkatësi si iFrame-i mëmë.",
|
||||
"pad.permissionDenied": "S’keni leje të hyni në këtë bllok",
|
||||
"pad.settings.padSettings": "Rregullime Blloku",
|
||||
"pad.settings.myView": "Pamja ime",
|
||||
"pad.settings.myView": "Pamja Ime",
|
||||
"pad.settings.stickychat": "Fjalosje përherë në ekran",
|
||||
"pad.settings.chatandusers": "Shfaq Fjalosje dhe Përdorues",
|
||||
"pad.settings.colorcheck": "Ngjyra autorësish",
|
||||
|
@ -90,12 +90,12 @@
|
|||
"pad.importExport.abiword.innerHTML": "Mund të importoni vetëm prej formati tekst i thjeshtë ose HTML. Për veçori më të thelluara importimi, ju lutemi, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instaloni AbiWord-in ose LibreOffice</a>.",
|
||||
"pad.modals.connected": "I lidhur.",
|
||||
"pad.modals.reconnecting": "Po rilidheni te blloku juaj…",
|
||||
"pad.modals.forcereconnect": "Rilidhje e detyruar",
|
||||
"pad.modals.forcereconnect": "Detyro rilidhje",
|
||||
"pad.modals.reconnecttimer": "Provë për rilidhje pas",
|
||||
"pad.modals.cancel": "Anuloje",
|
||||
"pad.modals.userdup": "Hapur në një tjetër dritare",
|
||||
"pad.modals.userdup.explanation": "Ky bllok duket se gjendet i hapur në më shumë se një dritare shfletuesi në këtë kompjuter.",
|
||||
"pad.modals.userdup.advice": "Rilidhuni që të përdoret kjo dritare.",
|
||||
"pad.modals.userdup.advice": "Që të përdoret kjo dritare, rilidhuni.",
|
||||
"pad.modals.unauth": "I paautorizuar",
|
||||
"pad.modals.unauth.explanation": "Lejet tuaja ndryshuan teksa shihnit këtë dritare. Provoni të rilidheni.",
|
||||
"pad.modals.looping.explanation": "Ka probleme komunikimi me shërbyesin e njëkohësimit.",
|
||||
|
@ -103,10 +103,10 @@
|
|||
"pad.modals.initsocketfail": "Shërbyesi është i pakapshëm.",
|
||||
"pad.modals.initsocketfail.explanation": "S’u lidh dot te shërbyesi i njëkohësimit.",
|
||||
"pad.modals.initsocketfail.cause": "Ka gjasa që kjo vjen për shkak të një problemi me shfletuesin tuaj ose lidhjen tuaj në internet.",
|
||||
"pad.modals.slowcommit.explanation": "Shërbyesi nuk po përgjigjet.",
|
||||
"pad.modals.slowcommit.explanation": "Shërbyesi s’po përgjigjet.",
|
||||
"pad.modals.slowcommit.cause": "Kjo mund të vijë për shkak problemesh lidhjeje me rrjetin.",
|
||||
"pad.modals.badChangeset.explanation": "Një përpunim që keni bërë u vlerësua si i paligjshëm nga shërbyesi i njëkohësimit.",
|
||||
"pad.modals.badChangeset.cause": "Kjo mund të jetë për shkak të një formësimi të gabuar të shërbyesit ose ndonjë tjetër sjelljeje të papritur. Ju lutemi, lidhuni me përgjegjësin e shërbimit, nëse mendoni që ky është një gabim. Provoni të rilidheni që të vazhdoni përpunimin.",
|
||||
"pad.modals.badChangeset.explanation": "Një përpunim që keni bërë, u vlerësua si i paligjshëm nga shërbyesi i njëkohësimit.",
|
||||
"pad.modals.badChangeset.cause": "Kjo mund të jetë për shkak të një formësimi të gabuar të shërbyesit ose ndonjë tjetër sjelljeje të papritur. Ju lutemi, lidhuni me përgjegjësin e shërbimit, nëse mendoni se ky është një gabim. Që të vazhdoni përpunimin, provoni të rilidheni.",
|
||||
"pad.modals.corruptPad.explanation": "Blloku te i cili po përpiqeni të hyni është i dëmtuar.",
|
||||
"pad.modals.corruptPad.cause": "Kjo mund të vijë nga një formësim i gabuar shërbyesi ose ndonjë tjetër sjellje e papritur. Ju lutemi, lidhuni me përgjegjësin e shërbimit.",
|
||||
"pad.modals.deleted": "I fshirë.",
|
||||
|
@ -131,7 +131,7 @@
|
|||
"timeslider.pageTitle": "Rrjedhë kohore e {{appTitle}}",
|
||||
"timeslider.toolbar.returnbutton": "Rikthehuni te blloku",
|
||||
"timeslider.toolbar.authors": "Autorë:",
|
||||
"timeslider.toolbar.authorsList": "S’ka autorë",
|
||||
"timeslider.toolbar.authorsList": "S’ka Autorë",
|
||||
"timeslider.toolbar.exportlink.title": "Eksportoje",
|
||||
"timeslider.exportCurrent": "Eksportojeni versionin e tanishëm si:",
|
||||
"timeslider.version": "Versioni {{version}}",
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
"timeslider.month.march": "Marso",
|
||||
"timeslider.month.april": "Apriłe",
|
||||
"timeslider.month.may": "Majo",
|
||||
"timeslider.month.june": "Xugno",
|
||||
"timeslider.month.june": "Zugno",
|
||||
"timeslider.month.july": "Lujo",
|
||||
"timeslider.month.august": "Agosto",
|
||||
"timeslider.month.september": "Setenbre",
|
||||
|
|
|
@ -831,7 +831,7 @@ exports.getStats = async () => {
|
|||
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
|
||||
|
||||
// gets a pad safe
|
||||
async function getPadSafe(padID, shouldExist, text) {
|
||||
const getPadSafe = async (padID, shouldExist, text) => {
|
||||
// check if padID is a string
|
||||
if (typeof padID !== 'string') {
|
||||
throw new CustomError('padID is not a string', 'apierror');
|
||||
|
@ -857,7 +857,7 @@ async function getPadSafe(padID, shouldExist, text) {
|
|||
|
||||
// pad exists, let's get it
|
||||
return padManager.getPad(padID, text);
|
||||
}
|
||||
};
|
||||
|
||||
// checks if a rev is a legal number
|
||||
// pre-condition is that `rev` is not undefined
|
||||
|
|
|
@ -135,7 +135,7 @@ exports.createAuthorIfNotExistsFor = async (authorMapper, name) => {
|
|||
* @param {String} mapperkey The database key name for this mapper
|
||||
* @param {String} mapper The mapper
|
||||
*/
|
||||
async function mapAuthorWithDBKey(mapperkey, mapper) {
|
||||
const mapAuthorWithDBKey = async (mapperkey, mapper) => {
|
||||
// try to map to an author
|
||||
const author = await db.get(`${mapperkey}:${mapper}`);
|
||||
|
||||
|
@ -156,13 +156,13 @@ async function mapAuthorWithDBKey(mapperkey, mapper) {
|
|||
|
||||
// return the author
|
||||
return {authorID: author};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal function that creates the database entry for an author
|
||||
* @param {String} name The name of the author
|
||||
*/
|
||||
exports.createAuthor = (name) => {
|
||||
exports.createAuthor = async (name) => {
|
||||
// create the new author name
|
||||
const author = `a.${randomString(16)}`;
|
||||
|
||||
|
@ -174,8 +174,7 @@ exports.createAuthor = (name) => {
|
|||
};
|
||||
|
||||
// set the global author db entry
|
||||
// NB: no await, since we're not waiting for the DB set to finish
|
||||
db.set(`globalAuthor:${author}`, authorObj);
|
||||
await db.set(`globalAuthor:${author}`, authorObj);
|
||||
|
||||
return {authorID: author};
|
||||
};
|
||||
|
@ -184,34 +183,35 @@ exports.createAuthor = (name) => {
|
|||
* Returns the Author Obj of the author
|
||||
* @param {String} author The id of the author
|
||||
*/
|
||||
exports.getAuthor = (author) => db.get(`globalAuthor:${author}`);
|
||||
exports.getAuthor = async (author) => await db.get(`globalAuthor:${author}`);
|
||||
|
||||
/**
|
||||
* Returns the color Id of the author
|
||||
* @param {String} author The id of the author
|
||||
*/
|
||||
exports.getAuthorColorId = (author) => db.getSub(`globalAuthor:${author}`, ['colorId']);
|
||||
exports.getAuthorColorId = async (author) => await db.getSub(`globalAuthor:${author}`, ['colorId']);
|
||||
|
||||
/**
|
||||
* Sets the color Id of the author
|
||||
* @param {String} author The id of the author
|
||||
* @param {String} colorId The color id of the author
|
||||
*/
|
||||
exports.setAuthorColorId = (author, colorId) => db.setSub(
|
||||
exports.setAuthorColorId = async (author, colorId) => await db.setSub(
|
||||
`globalAuthor:${author}`, ['colorId'], colorId);
|
||||
|
||||
/**
|
||||
* Returns the name of the author
|
||||
* @param {String} author The id of the author
|
||||
*/
|
||||
exports.getAuthorName = (author) => db.getSub(`globalAuthor:${author}`, ['name']);
|
||||
exports.getAuthorName = async (author) => await db.getSub(`globalAuthor:${author}`, ['name']);
|
||||
|
||||
/**
|
||||
* Sets the name of the author
|
||||
* @param {String} author The id of the author
|
||||
* @param {String} name The name of the author
|
||||
*/
|
||||
exports.setAuthorName = (author, name) => db.setSub(`globalAuthor:${author}`, ['name'], name);
|
||||
exports.setAuthorName = async (author, name) => await db.setSub(
|
||||
`globalAuthor:${author}`, ['name'], name);
|
||||
|
||||
/**
|
||||
* Returns an array of all pads this author contributed to
|
||||
|
@ -261,7 +261,7 @@ exports.addPad = async (authorID, padID) => {
|
|||
author.padIDs[padID] = 1; // anything, because value is not used
|
||||
|
||||
// save the new element back
|
||||
db.set(`globalAuthor:${authorID}`, author);
|
||||
await db.set(`globalAuthor:${authorID}`, author);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,7 +29,7 @@ const util = require('util');
|
|||
|
||||
// set database settings
|
||||
const db =
|
||||
new ueberDB.database(settings.dbType, settings.dbSettings, null, log4js.getLogger('ueberDB'));
|
||||
new ueberDB.Database(settings.dbType, settings.dbSettings, null, log4js.getLogger('ueberDB'));
|
||||
|
||||
/**
|
||||
* The UeberDB Object that provides the database functions
|
||||
|
|
|
@ -32,7 +32,7 @@ exports.cleanText = (txt) => txt.replace(/\r\n/g, '\n')
|
|||
.replace(/\t/g, ' ')
|
||||
.replace(/\xa0/g, ' ');
|
||||
|
||||
const Pad = function Pad(id) {
|
||||
const Pad = function (id) {
|
||||
this.atext = Changeset.makeAText('\n');
|
||||
this.pool = new AttributePool();
|
||||
this.head = -1;
|
||||
|
@ -44,32 +44,29 @@ const Pad = function Pad(id) {
|
|||
|
||||
exports.Pad = Pad;
|
||||
|
||||
Pad.prototype.apool = function apool() {
|
||||
Pad.prototype.apool = function () {
|
||||
return this.pool;
|
||||
};
|
||||
|
||||
Pad.prototype.getHeadRevisionNumber = function getHeadRevisionNumber() {
|
||||
Pad.prototype.getHeadRevisionNumber = function () {
|
||||
return this.head;
|
||||
};
|
||||
|
||||
Pad.prototype.getSavedRevisionsNumber = function getSavedRevisionsNumber() {
|
||||
Pad.prototype.getSavedRevisionsNumber = function () {
|
||||
return this.savedRevisions.length;
|
||||
};
|
||||
|
||||
Pad.prototype.getSavedRevisionsList = function getSavedRevisionsList() {
|
||||
const savedRev = [];
|
||||
for (const rev in this.savedRevisions) {
|
||||
savedRev.push(this.savedRevisions[rev].revNum);
|
||||
}
|
||||
Pad.prototype.getSavedRevisionsList = function () {
|
||||
const savedRev = this.savedRevisions.map((rev) => rev.revNum);
|
||||
savedRev.sort((a, b) => a - b);
|
||||
return savedRev;
|
||||
};
|
||||
|
||||
Pad.prototype.getPublicStatus = function getPublicStatus() {
|
||||
Pad.prototype.getPublicStatus = function () {
|
||||
return this.publicStatus;
|
||||
};
|
||||
|
||||
Pad.prototype.appendRevision = async function appendRevision(aChangeset, author) {
|
||||
Pad.prototype.appendRevision = async function (aChangeset, author) {
|
||||
if (!author) {
|
||||
author = '';
|
||||
}
|
||||
|
@ -115,7 +112,7 @@ Pad.prototype.appendRevision = async function appendRevision(aChangeset, author)
|
|||
};
|
||||
|
||||
// save all attributes to the database
|
||||
Pad.prototype.saveToDatabase = async function saveToDatabase() {
|
||||
Pad.prototype.saveToDatabase = async function () {
|
||||
const dbObject = {};
|
||||
|
||||
for (const attr in this) {
|
||||
|
@ -133,24 +130,24 @@ Pad.prototype.saveToDatabase = async function saveToDatabase() {
|
|||
};
|
||||
|
||||
// get time of last edit (changeset application)
|
||||
Pad.prototype.getLastEdit = function getLastEdit() {
|
||||
Pad.prototype.getLastEdit = function () {
|
||||
const revNum = this.getHeadRevisionNumber();
|
||||
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
||||
};
|
||||
|
||||
Pad.prototype.getRevisionChangeset = function getRevisionChangeset(revNum) {
|
||||
Pad.prototype.getRevisionChangeset = function (revNum) {
|
||||
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['changeset']);
|
||||
};
|
||||
|
||||
Pad.prototype.getRevisionAuthor = function getRevisionAuthor(revNum) {
|
||||
Pad.prototype.getRevisionAuthor = function (revNum) {
|
||||
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'author']);
|
||||
};
|
||||
|
||||
Pad.prototype.getRevisionDate = function getRevisionDate(revNum) {
|
||||
Pad.prototype.getRevisionDate = function (revNum) {
|
||||
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
||||
};
|
||||
|
||||
Pad.prototype.getAllAuthors = function getAllAuthors() {
|
||||
Pad.prototype.getAllAuthors = function () {
|
||||
const authors = [];
|
||||
|
||||
for (const key in this.pool.numToAttrib) {
|
||||
|
@ -162,7 +159,7 @@ Pad.prototype.getAllAuthors = function getAllAuthors() {
|
|||
return authors;
|
||||
};
|
||||
|
||||
Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText(targetRev) {
|
||||
Pad.prototype.getInternalRevisionAText = async function (targetRev) {
|
||||
const keyRev = this.getKeyRevisionNumber(targetRev);
|
||||
|
||||
// find out which changesets are needed
|
||||
|
@ -197,11 +194,11 @@ Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText
|
|||
return atext;
|
||||
};
|
||||
|
||||
Pad.prototype.getRevision = function getRevisionChangeset(revNum) {
|
||||
Pad.prototype.getRevision = function (revNum) {
|
||||
return db.get(`pad:${this.id}:revs:${revNum}`);
|
||||
};
|
||||
|
||||
Pad.prototype.getAllAuthorColors = async function getAllAuthorColors() {
|
||||
Pad.prototype.getAllAuthorColors = async function () {
|
||||
const authors = this.getAllAuthors();
|
||||
const returnTable = {};
|
||||
const colorPalette = authorManager.getColorPalette();
|
||||
|
@ -215,7 +212,7 @@ Pad.prototype.getAllAuthorColors = async function getAllAuthorColors() {
|
|||
return returnTable;
|
||||
};
|
||||
|
||||
Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, endRev) {
|
||||
Pad.prototype.getValidRevisionRange = function (startRev, endRev) {
|
||||
startRev = parseInt(startRev, 10);
|
||||
const head = this.getHeadRevisionNumber();
|
||||
endRev = endRev ? parseInt(endRev, 10) : head;
|
||||
|
@ -236,15 +233,15 @@ Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, e
|
|||
return null;
|
||||
};
|
||||
|
||||
Pad.prototype.getKeyRevisionNumber = function getKeyRevisionNumber(revNum) {
|
||||
Pad.prototype.getKeyRevisionNumber = function (revNum) {
|
||||
return Math.floor(revNum / 100) * 100;
|
||||
};
|
||||
|
||||
Pad.prototype.text = function text() {
|
||||
Pad.prototype.text = function () {
|
||||
return this.atext.text;
|
||||
};
|
||||
|
||||
Pad.prototype.setText = async function setText(newText) {
|
||||
Pad.prototype.setText = async function (newText) {
|
||||
// clean the new text
|
||||
newText = exports.cleanText(newText);
|
||||
|
||||
|
@ -264,7 +261,7 @@ Pad.prototype.setText = async function setText(newText) {
|
|||
await this.appendRevision(changeset);
|
||||
};
|
||||
|
||||
Pad.prototype.appendText = async function appendText(newText) {
|
||||
Pad.prototype.appendText = async function (newText) {
|
||||
// clean the new text
|
||||
newText = exports.cleanText(newText);
|
||||
|
||||
|
@ -277,7 +274,7 @@ Pad.prototype.appendText = async function appendText(newText) {
|
|||
await this.appendRevision(changeset);
|
||||
};
|
||||
|
||||
Pad.prototype.appendChatMessage = async function appendChatMessage(text, userId, time) {
|
||||
Pad.prototype.appendChatMessage = async function (text, userId, time) {
|
||||
this.chatHead++;
|
||||
// save the chat entry in the database
|
||||
await Promise.all([
|
||||
|
@ -286,7 +283,7 @@ Pad.prototype.appendChatMessage = async function appendChatMessage(text, userId,
|
|||
]);
|
||||
};
|
||||
|
||||
Pad.prototype.getChatMessage = async function getChatMessage(entryNum) {
|
||||
Pad.prototype.getChatMessage = async function (entryNum) {
|
||||
// get the chat entry
|
||||
const entry = await db.get(`pad:${this.id}:chat:${entryNum}`);
|
||||
|
||||
|
@ -298,7 +295,7 @@ Pad.prototype.getChatMessage = async function getChatMessage(entryNum) {
|
|||
return entry;
|
||||
};
|
||||
|
||||
Pad.prototype.getChatMessages = async function getChatMessages(start, end) {
|
||||
Pad.prototype.getChatMessages = async function (start, end) {
|
||||
// collect the numbers of chat entries and in which order we need them
|
||||
const neededEntries = [];
|
||||
for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) {
|
||||
|
@ -326,7 +323,7 @@ Pad.prototype.getChatMessages = async function getChatMessages(start, end) {
|
|||
return cleanedEntries;
|
||||
};
|
||||
|
||||
Pad.prototype.init = async function init(text) {
|
||||
Pad.prototype.init = async function (text) {
|
||||
// replace text with default text if text isn't set
|
||||
if (text == null) {
|
||||
text = settings.defaultPadText;
|
||||
|
@ -355,8 +352,7 @@ Pad.prototype.init = async function init(text) {
|
|||
hooks.callAll('padLoad', {pad: this});
|
||||
};
|
||||
|
||||
Pad.prototype.copy = async function copy(destinationID, force) {
|
||||
let destGroupID;
|
||||
Pad.prototype.copy = async function (destinationID, force) {
|
||||
const sourceID = this.id;
|
||||
|
||||
// Kick everyone from this pad.
|
||||
|
@ -367,16 +363,11 @@ Pad.prototype.copy = async function copy(destinationID, force) {
|
|||
// flush the source pad:
|
||||
await this.saveToDatabase();
|
||||
|
||||
|
||||
try {
|
||||
// if it's a group pad, let's make sure the group exists.
|
||||
destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||
|
||||
// if force is true and already exists a Pad with the same id, remove that Pad
|
||||
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// copy the 'pad' entry
|
||||
const pad = await db.get(`pad:${sourceID}`);
|
||||
|
@ -423,7 +414,7 @@ Pad.prototype.copy = async function copy(destinationID, force) {
|
|||
return {padID: destinationID};
|
||||
};
|
||||
|
||||
Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAndReturnIt(destinationID) {
|
||||
Pad.prototype.checkIfGroupExistAndReturnIt = async function (destinationID) {
|
||||
let destGroupID = false;
|
||||
|
||||
if (destinationID.indexOf('$') >= 0) {
|
||||
|
@ -438,7 +429,7 @@ Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAnd
|
|||
return destGroupID;
|
||||
};
|
||||
|
||||
Pad.prototype.removePadIfForceIsTrueAndAlreadyExist = async function removePadIfForceIsTrueAndAlreadyExist(destinationID, force) {
|
||||
Pad.prototype.removePadIfForceIsTrueAndAlreadyExist = async function (destinationID, force) {
|
||||
// if the pad exists, we should abort, unless forced.
|
||||
const exists = await padManager.doesPadExist(destinationID);
|
||||
|
||||
|
@ -461,29 +452,24 @@ Pad.prototype.removePadIfForceIsTrueAndAlreadyExist = async function removePadIf
|
|||
}
|
||||
};
|
||||
|
||||
Pad.prototype.copyAuthorInfoToDestinationPad = function copyAuthorInfoToDestinationPad(destinationID) {
|
||||
Pad.prototype.copyAuthorInfoToDestinationPad = function (destinationID) {
|
||||
// add the new sourcePad to all authors who contributed to the old one
|
||||
this.getAllAuthors().forEach((authorID) => {
|
||||
authorManager.addPad(authorID, destinationID);
|
||||
});
|
||||
};
|
||||
|
||||
Pad.prototype.copyPadWithoutHistory = async function copyPadWithoutHistory(destinationID, force) {
|
||||
let destGroupID;
|
||||
Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) {
|
||||
const sourceID = this.id;
|
||||
|
||||
// flush the source pad
|
||||
this.saveToDatabase();
|
||||
|
||||
try {
|
||||
// if it's a group pad, let's make sure the group exists.
|
||||
destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||
|
||||
// if force is true and already exists a Pad with the same id, remove that Pad
|
||||
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const sourcePad = await padManager.getPad(sourceID);
|
||||
|
||||
|
@ -526,7 +512,7 @@ Pad.prototype.copyPadWithoutHistory = async function copyPadWithoutHistory(desti
|
|||
};
|
||||
|
||||
|
||||
Pad.prototype.remove = async function remove() {
|
||||
Pad.prototype.remove = async function () {
|
||||
const padID = this.id;
|
||||
const p = [];
|
||||
|
||||
|
@ -579,12 +565,12 @@ Pad.prototype.remove = async function remove() {
|
|||
};
|
||||
|
||||
// set in db
|
||||
Pad.prototype.setPublicStatus = async function setPublicStatus(publicStatus) {
|
||||
Pad.prototype.setPublicStatus = async function (publicStatus) {
|
||||
this.publicStatus = publicStatus;
|
||||
await this.saveToDatabase();
|
||||
};
|
||||
|
||||
Pad.prototype.addSavedRevision = async function addSavedRevision(revNum, savedById, label) {
|
||||
Pad.prototype.addSavedRevision = async function (revNum, savedById, label) {
|
||||
// if this revision is already saved, return silently
|
||||
for (const i in this.savedRevisions) {
|
||||
if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {
|
||||
|
@ -605,6 +591,6 @@ Pad.prototype.addSavedRevision = async function addSavedRevision(revNum, savedBy
|
|||
await this.saveToDatabase();
|
||||
};
|
||||
|
||||
Pad.prototype.getSavedRevisions = function getSavedRevisions() {
|
||||
Pad.prototype.getSavedRevisions = function () {
|
||||
return this.savedRevisions;
|
||||
};
|
||||
|
|
|
@ -28,7 +28,7 @@ const randomString = require('../utils/randomstring');
|
|||
* checks if the id pattern matches a read-only pad id
|
||||
* @param {String} the pad's id
|
||||
*/
|
||||
exports.isReadOnlyId = (id) => id.indexOf('r.') === 0;
|
||||
exports.isReadOnlyId = (id) => id.startsWith('r.');
|
||||
|
||||
/**
|
||||
* returns a read only id for a pad
|
||||
|
@ -59,7 +59,7 @@ exports.getPadId = (readOnlyId) => db.get(`readonly2pad:${readOnlyId}`);
|
|||
* @param {String} padIdOrReadonlyPadId read only id or real pad id
|
||||
*/
|
||||
exports.getIds = async (id) => {
|
||||
const readonly = (id.indexOf('r.') === 0);
|
||||
const readonly = exports.isReadOnlyId(id);
|
||||
|
||||
// Might be null, if this is an unknown read-only id
|
||||
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id);
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
const authorManager = require('./AuthorManager');
|
||||
const hooks = require('../../static/js/pluginfw/hooks.js');
|
||||
const padManager = require('./PadManager');
|
||||
const readOnlyManager = require('./ReadOnlyManager');
|
||||
const sessionManager = require('./SessionManager');
|
||||
const settings = require('../utils/Settings');
|
||||
const webaccess = require('../hooks/express/webaccess');
|
||||
|
@ -56,6 +57,15 @@ exports.checkAccess = async (padID, sessionCookie, token, userSettings) => {
|
|||
|
||||
let canCreate = !settings.editOnly;
|
||||
|
||||
if (readOnlyManager.isReadOnlyId(padID)) {
|
||||
canCreate = false;
|
||||
padID = await readOnlyManager.getPadId(padID);
|
||||
if (padID == null) {
|
||||
authLogger.debug('access denied: read-only pad ID for a pad that does not exist');
|
||||
return DENY;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication and authorization checks.
|
||||
if (settings.loadTest) {
|
||||
console.warn(
|
||||
|
|
|
@ -250,7 +250,7 @@ const listSessionsWithDBKey = async (dbkey) => {
|
|||
const sessions = sessionObject ? sessionObject.sessionIDs : null;
|
||||
|
||||
// iterate through the sessions and get the sessioninfos
|
||||
for (const sessionID in sessions) {
|
||||
for (const sessionID of Object.keys(sessions || {})) {
|
||||
try {
|
||||
const sessionInfo = await exports.getSessionInfo(sessionID);
|
||||
sessions[sessionID] = sessionInfo;
|
||||
|
|
|
@ -42,7 +42,7 @@ const runTests = () => {
|
|||
|
||||
const literal = (v) => {
|
||||
if ((typeof v) === 'string') {
|
||||
return `"${v.replace(/[\\\"]/g, '\\$1').replace(/\n/g, '\\n')}"`;
|
||||
return `"${v.replace(/[\\"]/g, '\\$1').replace(/\n/g, '\\n')}"`;
|
||||
} else { return JSON.stringify(v); }
|
||||
};
|
||||
|
||||
|
|
|
@ -50,20 +50,35 @@ exports.socketio = () => {
|
|||
};
|
||||
|
||||
/**
|
||||
* A associative array that saves information about a session
|
||||
* key = sessionId
|
||||
* values = padId, readonlyPadId, readonly, author, rev
|
||||
* padId = the real padId of the pad
|
||||
* readonlyPadId = The readonly pad id of the pad
|
||||
* readonly = Wether the client has only read access (true) or read/write access (false)
|
||||
* rev = That last revision that was send to this client
|
||||
* author = the author ID used for this session
|
||||
* Contains information about socket.io connections:
|
||||
* - key: Socket.io socket ID.
|
||||
* - value: Object that is initially empty immediately after connect. Once the client's
|
||||
* CLIENT_READY message is processed, it has the following properties:
|
||||
* - auth: Object with the following properties copied from the client's CLIENT_READY message:
|
||||
* - padID: Pad ID requested by the user. Unlike the padId property described below, this
|
||||
* may be a read-only pad ID.
|
||||
* - sessionID: Copied from the client's sessionID cookie, which should be the value
|
||||
* returned from the createSession() HTTP API. This will be null/undefined if
|
||||
* createSession() isn't used or the portal doesn't set the sessionID cookie.
|
||||
* - token: User-supplied token.
|
||||
* - author: The user's author ID.
|
||||
* - padId: The real (not read-only) ID of the pad.
|
||||
* - readonlyPadId: The read-only ID of the pad.
|
||||
* - readonly: Whether the client has read-only access (true) or read/write access (false).
|
||||
* - rev: The last revision that was sent to the client.
|
||||
*/
|
||||
const sessioninfos = {};
|
||||
exports.sessioninfos = sessioninfos;
|
||||
|
||||
// Measure total amount of users
|
||||
stats.gauge('totalUsers', () => Object.keys(socketio.sockets.sockets).length);
|
||||
stats.gauge('activePads', () => {
|
||||
const padIds = new Set();
|
||||
for (const {padId} of Object.values(sessioninfos)) {
|
||||
if (!padId) continue;
|
||||
padIds.add(padId);
|
||||
}
|
||||
return padIds.size;
|
||||
});
|
||||
|
||||
/**
|
||||
* A changeset queue per pad that is processed by handleUserChanges()
|
||||
|
@ -94,18 +109,6 @@ exports.handleConnect = (socket) => {
|
|||
|
||||
// Initialize sessioninfos for this new session
|
||||
sessioninfos[socket.id] = {};
|
||||
|
||||
stats.gauge('activePads', () => {
|
||||
const padIds = [];
|
||||
for (const session of Object.keys(sessioninfos)) {
|
||||
if (sessioninfos[session].padId) {
|
||||
if (padIds.indexOf(sessioninfos[session].padId) === -1) {
|
||||
padIds.push(sessioninfos[session].padId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return padIds.length;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -210,22 +213,17 @@ exports.handleMessage = async (socket, message) => {
|
|||
|
||||
const auth = thisSession.auth;
|
||||
if (!auth) {
|
||||
console.error('Auth was never applied to a session. If you are using the ' +
|
||||
'stress-test tool then restart Etherpad and the Stress test tool.');
|
||||
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
|
||||
const msg = JSON.stringify(message, null, 2);
|
||||
messageLogger.error(`Dropping pre-CLIENT_READY message from IP ${ip}: ${msg}`);
|
||||
messageLogger.debug(
|
||||
'If you are using the stress-test tool then restart Etherpad and the Stress test tool.');
|
||||
return;
|
||||
}
|
||||
|
||||
// check if pad is requested via readOnly
|
||||
let padId = auth.padID;
|
||||
|
||||
if (padId.indexOf('r.') === 0) {
|
||||
// Pad is readOnly, first get the real Pad ID
|
||||
padId = await readOnlyManager.getPadId(padId);
|
||||
}
|
||||
|
||||
const {session: {user} = {}} = socket.client.request;
|
||||
const {accessStatus, authorID} =
|
||||
await securityManager.checkAccess(padId, auth.sessionID, auth.token, user);
|
||||
await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user);
|
||||
if (accessStatus !== 'grant') {
|
||||
// Access denied. Send the reason to the user.
|
||||
socket.json.send({accessStatus});
|
||||
|
@ -717,13 +715,13 @@ exports.updatePadClients = async (pad) => {
|
|||
// but benefit of reusing cached revision object is HUGE
|
||||
const revCache = {};
|
||||
|
||||
// go through all sessions on this pad
|
||||
for (const socket of roomSockets) {
|
||||
const sid = socket.id;
|
||||
await Promise.all(roomSockets.map(async (socket) => {
|
||||
const sessioninfo = sessioninfos[socket.id];
|
||||
// The user might have disconnected since _getRoomSockets() was called.
|
||||
if (sessioninfo == null) return;
|
||||
|
||||
// send them all new changesets
|
||||
while (sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()) {
|
||||
const r = sessioninfos[sid].rev + 1;
|
||||
while (sessioninfo.rev < pad.getHeadRevisionNumber()) {
|
||||
const r = sessioninfo.rev + 1;
|
||||
let revision = revCache[r];
|
||||
if (!revision) {
|
||||
revision = await pad.getRevision(r);
|
||||
|
@ -734,33 +732,34 @@ exports.updatePadClients = async (pad) => {
|
|||
const revChangeset = revision.changeset;
|
||||
const currentTime = revision.meta.timestamp;
|
||||
|
||||
// next if session has not been deleted
|
||||
if (sessioninfos[sid] == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (author === sessioninfos[sid].author) {
|
||||
socket.json.send({type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev: r}});
|
||||
let msg;
|
||||
if (author === sessioninfo.author) {
|
||||
msg = {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev: r}};
|
||||
} else {
|
||||
const forWire = Changeset.prepareForWire(revChangeset, pad.pool);
|
||||
const wireMsg = {type: 'COLLABROOM',
|
||||
data: {type: 'NEW_CHANGES',
|
||||
msg = {
|
||||
type: 'COLLABROOM',
|
||||
data: {
|
||||
type: 'NEW_CHANGES',
|
||||
newRev: r,
|
||||
changeset: forWire.translated,
|
||||
apool: forWire.pool,
|
||||
author,
|
||||
currentTime,
|
||||
timeDelta: currentTime - sessioninfos[sid].time}};
|
||||
|
||||
socket.json.send(wireMsg);
|
||||
}
|
||||
|
||||
if (sessioninfos[sid]) {
|
||||
sessioninfos[sid].time = currentTime;
|
||||
sessioninfos[sid].rev = r;
|
||||
timeDelta: currentTime - sessioninfo.time,
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
socket.json.send(msg);
|
||||
} catch (err) {
|
||||
messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);
|
||||
return;
|
||||
}
|
||||
sessioninfo.time = currentTime;
|
||||
sessioninfo.rev = r;
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1122,36 +1121,27 @@ const handleClientReady = async (socket, message, authorID) => {
|
|||
|
||||
// Save the current revision in sessioninfos, should be the same as in clientVars
|
||||
sessionInfo.rev = pad.getHeadRevisionNumber();
|
||||
}
|
||||
|
||||
// prepare the notification for the other users on the pad, that this user joined
|
||||
const messageToTheOtherUsers = {
|
||||
// Notify other users about this new user.
|
||||
socket.broadcast.to(padIds.padId).json.send({
|
||||
type: 'COLLABROOM',
|
||||
data: {
|
||||
type: 'USER_NEWINFO',
|
||||
userInfo: {
|
||||
colorId: authorColorId,
|
||||
name: authorName,
|
||||
userId: authorID,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Add the authorname of this new User, if avaiable
|
||||
if (authorName != null) {
|
||||
messageToTheOtherUsers.data.userInfo.name = authorName;
|
||||
}
|
||||
|
||||
// notify all existing users about new user
|
||||
socket.broadcast.to(padIds.padId).json.send(messageToTheOtherUsers);
|
||||
|
||||
// Get sessions for this pad and update them (in parallel)
|
||||
// Notify this new user about other users.
|
||||
await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {
|
||||
// Jump over, if this session is the connection session
|
||||
if (roomSocket.id === socket.id) {
|
||||
return;
|
||||
}
|
||||
if (roomSocket.id === socket.id) return;
|
||||
|
||||
// Since sessioninfos might change while being enumerated, check if the
|
||||
// sessionID is still assigned to a valid session
|
||||
// sessioninfos might change while enumerating, so check if the sessionID is still assigned to a
|
||||
// valid session.
|
||||
const sessionInfo = sessioninfos[roomSocket.id];
|
||||
if (sessionInfo == null) return;
|
||||
|
||||
|
@ -1172,7 +1162,6 @@ const handleClientReady = async (socket, message, authorID) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Send the new User a Notification about this other user
|
||||
const msg = {
|
||||
type: 'COLLABROOM',
|
||||
data: {
|
||||
|
@ -1187,7 +1176,6 @@ const handleClientReady = async (socket, message, authorID) => {
|
|||
|
||||
socket.json.send(msg);
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
const path = require('path');
|
||||
const eejs = require('../../eejs');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const toolbar = require('../../utils/toolbar');
|
||||
const hooks = require('../../../static/js/pluginfw/hooks');
|
||||
const settings = require('../../utils/Settings');
|
||||
const util = require('util');
|
||||
const webaccess = require('./webaccess');
|
||||
|
||||
exports.expressCreateServer = (hookName, args, cb) => {
|
||||
|
@ -46,14 +49,15 @@ exports.expressCreateServer = (hookName, args, cb) => {
|
|||
// serve pad.html under /p
|
||||
args.app.get('/p/:pad', (req, res, next) => {
|
||||
// The below might break for pads being rewritten
|
||||
const isReadOnly =
|
||||
req.url.indexOf('/p/r.') === 0 || !webaccess.userCanModify(req.params.pad, req);
|
||||
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
|
||||
|
||||
hooks.callAll('padInitToolbar', {
|
||||
toolbar,
|
||||
isReadOnly,
|
||||
});
|
||||
|
||||
// can be removed when require-kernel is dropped
|
||||
res.header('Feature-Policy', 'sync-xhr \'self\'');
|
||||
res.send(eejs.require('ep_etherpad-lite/templates/pad.html', {
|
||||
req,
|
||||
toolbar,
|
||||
|
@ -73,24 +77,25 @@ exports.expressCreateServer = (hookName, args, cb) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// serve favicon.ico from all path levels except as a pad name
|
||||
args.app.get(/\/favicon.ico$/, (req, res) => {
|
||||
let filePath = path.join(
|
||||
settings.root,
|
||||
'src',
|
||||
'static',
|
||||
'skins',
|
||||
settings.skinName,
|
||||
'favicon.ico'
|
||||
);
|
||||
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
|
||||
res.sendFile(filePath, (err) => {
|
||||
// there is no custom favicon, send the default favicon
|
||||
if (err) {
|
||||
filePath = path.join(settings.root, 'src', 'static', 'favicon.ico');
|
||||
res.sendFile(filePath);
|
||||
args.app.get('/favicon.ico', (req, res, next) => {
|
||||
(async () => {
|
||||
const fns = [
|
||||
...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),
|
||||
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),
|
||||
path.join(settings.root, 'src', 'static', 'favicon.ico'),
|
||||
];
|
||||
for (const fn of fns) {
|
||||
try {
|
||||
await fsp.access(fn, fs.constants.R_OK);
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
|
||||
await util.promisify(res.sendFile.bind(res))(fn);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
})().catch((err) => next(err || new Error(err)));
|
||||
});
|
||||
|
||||
return cb();
|
||||
|
|
|
@ -1,98 +1,82 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const util = require('util');
|
||||
const fsp = require('fs').promises;
|
||||
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||
const sanitizePathname = require('../../utils/sanitizePathname');
|
||||
const settings = require('../../utils/Settings');
|
||||
|
||||
exports.expressCreateServer = (hookName, args, cb) => {
|
||||
args.app.get('/tests/frontend/specs_list.js', async (req, res) => {
|
||||
const [coreTests, pluginTests] = await Promise.all([
|
||||
exports.getCoreTests(),
|
||||
exports.getPluginTests(),
|
||||
]);
|
||||
|
||||
// merge the two sets of results
|
||||
let files = [].concat(coreTests, pluginTests).sort();
|
||||
|
||||
// Keep only *.js files
|
||||
files = files.filter((f) => f.endsWith('.js'));
|
||||
|
||||
// remove admin tests if the setting to enable them isn't in settings.json
|
||||
if (!settings.enableAdminUITests) {
|
||||
files = files.filter((file) => file.indexOf('admin') !== 0);
|
||||
// Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/'
|
||||
// instead of path.sep to separate pathname components.
|
||||
const findSpecs = async (specDir) => {
|
||||
let dirents;
|
||||
try {
|
||||
dirents = await fsp.readdir(specDir, {withFileTypes: true});
|
||||
} catch (err) {
|
||||
if (['ENOENT', 'ENOTDIR'].includes(err.code)) return [];
|
||||
throw err;
|
||||
}
|
||||
const specs = [];
|
||||
await Promise.all(dirents.map(async (dirent) => {
|
||||
if (dirent.isDirectory()) {
|
||||
const subdirSpecs = await findSpecs(path.join(specDir, dirent.name));
|
||||
specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`));
|
||||
return;
|
||||
}
|
||||
if (!dirent.name.endsWith('.js')) return;
|
||||
specs.push(dirent.name);
|
||||
}));
|
||||
return specs;
|
||||
};
|
||||
|
||||
console.debug('Sent browser the following test specs:', files);
|
||||
res.setHeader('content-type', 'application/javascript');
|
||||
res.end(`var specs_list = ${JSON.stringify(files)};\n`);
|
||||
exports.expressCreateServer = (hookName, args, cb) => {
|
||||
args.app.get('/tests/frontend/frontendTestSpecs.json', (req, res, next) => {
|
||||
(async () => {
|
||||
const modules = [];
|
||||
await Promise.all(Object.entries(plugins.plugins).map(async ([plugin, def]) => {
|
||||
let {package: {path: pluginPath}} = def;
|
||||
if (!pluginPath.endsWith(path.sep)) pluginPath += path.sep;
|
||||
const specDir = `${plugin === 'ep_etherpad-lite' ? '' : 'static/'}tests/frontend/specs`;
|
||||
for (const spec of await findSpecs(path.join(pluginPath, specDir))) {
|
||||
if (plugin === 'ep_etherpad-lite' && !settings.enableAdminUITests &&
|
||||
spec.startsWith('admin')) continue;
|
||||
modules.push(`${plugin}/${specDir}/${spec.replace(/\.js$/, '')}`);
|
||||
}
|
||||
}));
|
||||
// Sort plugin tests before core tests.
|
||||
modules.sort((a, b) => {
|
||||
a = String(a);
|
||||
b = String(b);
|
||||
const aCore = a.startsWith('ep_etherpad-lite/');
|
||||
const bCore = b.startsWith('ep_etherpad-lite/');
|
||||
if (aCore === bCore) return a.localeCompare(b);
|
||||
return aCore ? 1 : -1;
|
||||
});
|
||||
console.debug('Sent browser the following test spec modules:', modules);
|
||||
res.json(modules);
|
||||
})().catch((err) => next(err || new Error(err)));
|
||||
});
|
||||
|
||||
const rootTestFolder = path.join(settings.root, 'src/tests/frontend/');
|
||||
|
||||
const url2FilePath = (url) => {
|
||||
let subPath = url.substr('/tests/frontend'.length);
|
||||
if (subPath === '') {
|
||||
subPath = 'index.html';
|
||||
}
|
||||
subPath = subPath.split('?')[0];
|
||||
|
||||
let filePath = path.join(rootTestFolder, subPath);
|
||||
|
||||
// make sure we jail the paths to the test folder, otherwise serve index
|
||||
if (filePath.indexOf(rootTestFolder) !== 0) {
|
||||
filePath = path.join(rootTestFolder, 'index.html');
|
||||
}
|
||||
return filePath;
|
||||
};
|
||||
|
||||
args.app.get('/tests/frontend/specs/*', (req, res) => {
|
||||
const specFilePath = url2FilePath(req.url);
|
||||
const specFileName = path.basename(specFilePath);
|
||||
|
||||
fs.readFile(specFilePath, (err, content) => {
|
||||
if (err) { return res.send(500); }
|
||||
|
||||
content = `describe(${JSON.stringify(specFileName)}, function(){${content}});`;
|
||||
|
||||
if (!specFilePath.endsWith('index.html')) {
|
||||
res.setHeader('content-type', 'application/javascript');
|
||||
}
|
||||
res.send(content);
|
||||
});
|
||||
args.app.get('/tests/frontend/index.html', (req, res) => {
|
||||
res.redirect(['./', ...req.url.split('?').slice(1)].join('?'));
|
||||
});
|
||||
|
||||
args.app.get('/tests/frontend/*', (req, res) => {
|
||||
const filePath = url2FilePath(req.url);
|
||||
res.sendFile(filePath);
|
||||
// The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here
|
||||
// uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the
|
||||
// version used with Express v4.x) interprets '.' and '*' differently than regexp.
|
||||
args.app.get('/tests/frontend/:file([\\d\\D]{0,})', (req, res, next) => {
|
||||
(async () => {
|
||||
let file = sanitizePathname(req.params.file);
|
||||
if (['', '.', './'].includes(file)) file = 'index.html';
|
||||
res.sendFile(path.join(rootTestFolder, file));
|
||||
})().catch((err) => next(err || new Error(err)));
|
||||
});
|
||||
|
||||
args.app.get('/tests/frontend', (req, res) => {
|
||||
res.redirect('/tests/frontend/index.html');
|
||||
res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?'));
|
||||
});
|
||||
|
||||
return cb();
|
||||
};
|
||||
|
||||
const readdir = util.promisify(fs.readdir);
|
||||
|
||||
exports.getPluginTests = async (callback) => {
|
||||
const moduleDir = 'node_modules/';
|
||||
const specPath = '/static/tests/frontend/specs/';
|
||||
const staticDir = '/static/plugins/';
|
||||
|
||||
const pluginSpecs = [];
|
||||
|
||||
const plugins = await readdir(moduleDir);
|
||||
const promises = plugins
|
||||
.map((plugin) => [plugin, moduleDir + plugin + specPath])
|
||||
.filter(([plugin, specDir]) => fs.existsSync(specDir)) // check plugin exists
|
||||
.map(([plugin, specDir]) => readdir(specDir)
|
||||
.then((specFiles) => specFiles.map((spec) => {
|
||||
pluginSpecs.push(staticDir + plugin + specPath + spec);
|
||||
})));
|
||||
|
||||
return Promise.all(promises).then(() => pluginSpecs);
|
||||
};
|
||||
|
||||
exports.getCoreTests = () => readdir('src/tests/frontend/specs');
|
||||
|
|
|
@ -23,7 +23,7 @@ const staticPathsRE = new RegExp(`^/(?:${[
|
|||
'robots.txt',
|
||||
'static/.*',
|
||||
'stats/?',
|
||||
'tests/frontend(?:/.*)?'
|
||||
'tests/frontend(?:/.*)?',
|
||||
].join('|')})$`);
|
||||
|
||||
exports.normalizeAuthzLevel = (level) => {
|
||||
|
@ -70,14 +70,19 @@ const checkAccess = async (req, res, next) => {
|
|||
// This helper is used in steps 2 and 4 below, so it may be called twice per access: once before
|
||||
// authentication is checked and once after (if settings.requireAuthorization is true).
|
||||
const authorize = async () => {
|
||||
const grant = (level) => {
|
||||
const grant = async (level) => {
|
||||
level = exports.normalizeAuthzLevel(level);
|
||||
if (!level) return false;
|
||||
const user = req.session.user;
|
||||
if (user == null) return true; // This will happen if authentication is not required.
|
||||
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
|
||||
if (encodedPadId == null) return true;
|
||||
const padId = decodeURIComponent(encodedPadId);
|
||||
let padId = decodeURIComponent(encodedPadId);
|
||||
if (readOnlyManager.isReadOnlyId(padId)) {
|
||||
// pad is read-only, first get the real pad ID
|
||||
padId = await readOnlyManager.getPadId(padId);
|
||||
if (padId == null) return false;
|
||||
}
|
||||
// The user was granted access to a pad. Remember the authorization level in the user's
|
||||
// settings so that SecurityManager can approve or deny specific actions.
|
||||
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
||||
|
@ -85,13 +90,13 @@ const checkAccess = async (req, res, next) => {
|
|||
return true;
|
||||
};
|
||||
const isAuthenticated = req.session && req.session.user;
|
||||
if (isAuthenticated && req.session.user.is_admin) return grant('create');
|
||||
if (isAuthenticated && req.session.user.is_admin) return await grant('create');
|
||||
const requireAuthn = requireAdmin || settings.requireAuthentication;
|
||||
if (!requireAuthn) return grant('create');
|
||||
if (!isAuthenticated) return grant(false);
|
||||
if (requireAdmin && !req.session.user.is_admin) return grant(false);
|
||||
if (!settings.requireAuthorization) return grant('create');
|
||||
return grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
|
||||
if (!requireAuthn) return await grant('create');
|
||||
if (!isAuthenticated) return await grant(false);
|
||||
if (requireAdmin && !req.session.user.is_admin) return await grant(false);
|
||||
if (!settings.requireAuthorization) return await grant('create');
|
||||
return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
|
||||
};
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -3,7 +3,6 @@ const securityManager = require('./db/SecurityManager');
|
|||
|
||||
// checks for padAccess
|
||||
module.exports = async (req, res) => {
|
||||
try {
|
||||
const {session: {user} = {}} = req;
|
||||
const accessObj = await securityManager.checkAccess(
|
||||
req.params.pad, req.cookies.sessionID, req.cookies.token, user);
|
||||
|
@ -16,8 +15,4 @@ module.exports = async (req, res) => {
|
|||
res.status(403).send("403 - Can't touch this");
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
// @TODO - send internal server error here?
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -27,17 +27,22 @@
|
|||
const log4js = require('log4js');
|
||||
log4js.replaceConsole();
|
||||
|
||||
// wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and it
|
||||
// should be above everything else so that it can hook in before resources are used.
|
||||
const wtfnode = require('wtfnode');
|
||||
const settings = require('./utils/Settings');
|
||||
|
||||
let wtfnode;
|
||||
if (settings.dumpOnUncleanExit) {
|
||||
// wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and
|
||||
// it should be above everything else so that it can hook in before resources are used.
|
||||
wtfnode = require('wtfnode');
|
||||
}
|
||||
|
||||
/*
|
||||
* early check for version compatibility before calling
|
||||
* any modules that require newer versions of NodeJS
|
||||
*/
|
||||
const NodeVersion = require('./utils/NodeVersion');
|
||||
NodeVersion.enforceMinNodeVersion('10.17.0');
|
||||
NodeVersion.checkDeprecationStatus('10.17.0', '1.8.8');
|
||||
NodeVersion.enforceMinNodeVersion('12.13.0');
|
||||
NodeVersion.checkDeprecationStatus('12.13.0', '1.8.14');
|
||||
|
||||
const UpdateCheck = require('./utils/UpdateCheck');
|
||||
const db = require('./db/DB');
|
||||
|
@ -45,7 +50,6 @@ const express = require('./hooks/express');
|
|||
const hooks = require('../static/js/pluginfw/hooks');
|
||||
const pluginDefs = require('../static/js/pluginfw/plugin_defs');
|
||||
const plugins = require('../static/js/pluginfw/plugins');
|
||||
const settings = require('./utils/Settings');
|
||||
const stats = require('./stats');
|
||||
|
||||
const logger = log4js.getLogger('server');
|
||||
|
@ -248,16 +252,25 @@ exports.exit = async (err = null) => {
|
|||
exitGate = new Gate();
|
||||
state = State.EXITING;
|
||||
exitGate.resolve();
|
||||
|
||||
// Node.js should exit on its own without further action. Add a timeout to force Node.js to exit
|
||||
// just in case something failed to get cleaned up during the shutdown hook. unref() is called on
|
||||
// the timeout so that the timeout itself does not prevent Node.js from exiting.
|
||||
// just in case something failed to get cleaned up during the shutdown hook. unref() is called
|
||||
// on the timeout so that the timeout itself does not prevent Node.js from exiting.
|
||||
setTimeout(() => {
|
||||
logger.error('Something that should have been cleaned up during the shutdown hook (such as ' +
|
||||
'a timer, worker thread, or open connection) is preventing Node.js from exiting');
|
||||
|
||||
if (settings.dumpOnUncleanExit) {
|
||||
wtfnode.dump();
|
||||
} else {
|
||||
logger.error('Enable `dumpOnUncleanExit` setting to get a dump of objects preventing a ' +
|
||||
'clean exit');
|
||||
}
|
||||
|
||||
logger.error('Forcing an unclean exit...');
|
||||
process.exit(1);
|
||||
}, 5000).unref();
|
||||
|
||||
logger.info('Waiting for Node.js to exit...');
|
||||
state = State.WAITING_FOR_EXIT;
|
||||
/* eslint-enable no-process-exit */
|
||||
|
|
|
@ -18,16 +18,21 @@
|
|||
|
||||
const db = require('../db/DB');
|
||||
const hooks = require('../../static/js/pluginfw/hooks');
|
||||
const log4js = require('log4js');
|
||||
const supportedElems = require('../../static/js/contentcollector').supportedElems;
|
||||
|
||||
const logger = log4js.getLogger('ImportEtherpad');
|
||||
|
||||
exports.setPadRaw = (padId, r) => {
|
||||
const records = JSON.parse(r);
|
||||
|
||||
// get supported block Elements from plugins, we will use this later.
|
||||
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
||||
supportedElems.push(element);
|
||||
supportedElems.add(element);
|
||||
});
|
||||
|
||||
const unsupportedElements = new Set();
|
||||
|
||||
Object.keys(records).forEach(async (key) => {
|
||||
let value = records[key];
|
||||
|
||||
|
@ -64,10 +69,7 @@ exports.setPadRaw = (padId, r) => {
|
|||
if (value.pool) {
|
||||
for (const attrib of Object.keys(value.pool.numToAttrib)) {
|
||||
const attribName = value.pool.numToAttrib[attrib][0];
|
||||
if (supportedElems.indexOf(attribName) === -1) {
|
||||
console.warn('Plugin missing: ' +
|
||||
`You might want to install a plugin to support this node name: ${attribName}`);
|
||||
}
|
||||
if (!supportedElems.has(attribName)) unsupportedElements.add(attribName);
|
||||
}
|
||||
}
|
||||
const oldPadId = key.split(':');
|
||||
|
@ -92,4 +94,9 @@ exports.setPadRaw = (padId, r) => {
|
|||
// Write the value to the server
|
||||
await db.set(newKey, value);
|
||||
});
|
||||
|
||||
if (unsupportedElements.size) {
|
||||
logger.warn('Ignoring unsupported elements (you might want to install a plugin): ' +
|
||||
`${[...unsupportedElements].join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const settings = require('./Settings');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
@ -30,6 +29,7 @@ const RequireKernel = require('etherpad-require-kernel');
|
|||
const mime = require('mime-types');
|
||||
const Threads = require('threads');
|
||||
const log4js = require('log4js');
|
||||
const sanitizePathname = require('./sanitizePathname');
|
||||
|
||||
const logger = log4js.getLogger('Minify');
|
||||
|
||||
|
@ -41,6 +41,7 @@ const LIBRARY_WHITELIST = [
|
|||
'async',
|
||||
'js-cookie',
|
||||
'security',
|
||||
'split-grid',
|
||||
'tinycon',
|
||||
'underscore',
|
||||
'unorm',
|
||||
|
@ -48,7 +49,7 @@ const LIBRARY_WHITELIST = [
|
|||
|
||||
// What follows is a terrible hack to avoid loop-back within the server.
|
||||
// TODO: Serve files from another service, or directly from the file system.
|
||||
const requestURI = async (url, method, headers) => await new Promise((resolve, reject) => {
|
||||
const requestURI = async (url, method, headers) => {
|
||||
const parsedUrl = new URL(url);
|
||||
let status = 500;
|
||||
const content = [];
|
||||
|
@ -58,7 +59,9 @@ const requestURI = async (url, method, headers) => await new Promise((resolve, r
|
|||
params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\/static\//, '')},
|
||||
headers,
|
||||
};
|
||||
const mockResponse = {
|
||||
let mockResponse;
|
||||
const p = new Promise((resolve) => {
|
||||
mockResponse = {
|
||||
writeHead: (_status, _headers) => {
|
||||
status = _status;
|
||||
for (const header in _headers) {
|
||||
|
@ -81,11 +84,21 @@ const requestURI = async (url, method, headers) => await new Promise((resolve, r
|
|||
resolve([status, headers, content.join('')]);
|
||||
},
|
||||
};
|
||||
minify(mockRequest, mockResponse).catch(reject);
|
||||
});
|
||||
await minify(mockRequest, mockResponse);
|
||||
return await p;
|
||||
};
|
||||
|
||||
const requestURIs = (locations, method, headers, callback) => {
|
||||
Promise.all(locations.map((loc) => requestURI(loc, method, headers))).then((responses) => {
|
||||
Promise.all(locations.map(async (loc) => {
|
||||
try {
|
||||
return await requestURI(loc, method, headers);
|
||||
} catch (err) {
|
||||
logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` +
|
||||
`${JSON.stringify(headers)}) failed: ${err.stack || err}`);
|
||||
return [500, headers, ''];
|
||||
}
|
||||
})).then((responses) => {
|
||||
const statuss = responses.map((x) => x[0]);
|
||||
const headerss = responses.map((x) => x[1]);
|
||||
const contentss = responses.map((x) => x[2]);
|
||||
|
@ -93,36 +106,6 @@ const requestURIs = (locations, method, headers, callback) => {
|
|||
});
|
||||
};
|
||||
|
||||
const sanitizePathname = (p) => {
|
||||
// Replace all backslashes with forward slashes to support Windows. This MUST be done BEFORE path
|
||||
// normalization, otherwise an attacker will be able to read arbitrary files anywhere on the
|
||||
// filesystem. See https://nvd.nist.gov/vuln/detail/CVE-2015-3297. Node.js treats both the
|
||||
// backlash and the forward slash characters as pathname component separators on Windows so this
|
||||
// does not change the meaning of the pathname.
|
||||
p = p.replace(/\\/g, '/');
|
||||
// The Node.js documentation says that path.join() normalizes, and the documentation for
|
||||
// path.normalize() says that it resolves '..' and '.' components. The word "resolve" implies that
|
||||
// it examines the filesystem to resolve symbolic links, so 'a/../b' might not be the same thing
|
||||
// as 'b'. Most path normalization functions from other libraries (e.g. Python's
|
||||
// os.path.normpath()) clearly state that they do not examine the filesystem -- they are simple
|
||||
// string manipulations. Node.js's path.normalize() probably also does a simple string
|
||||
// manipulation, but if not it must be given a real pathname. Join with ROOT_DIR here just in
|
||||
// case. ROOT_DIR will be removed later.
|
||||
p = path.join(ROOT_DIR, p);
|
||||
// Prevent attempts to read outside of ROOT_DIR via extra '..' components. ROOT_DIR is assumed to
|
||||
// be normalized.
|
||||
assert(ROOT_DIR.endsWith(path.sep));
|
||||
if (!p.startsWith(ROOT_DIR)) throw new Error(`attempt to read outside ROOT_DIR (${ROOT_DIR})`);
|
||||
// Convert back to a relative pathname.
|
||||
p = p.slice(ROOT_DIR.length);
|
||||
// On Windows, path.normalize replaces forward slashes with backslashes. Convert back to forward
|
||||
// slashes. THIS IS DANGEROUS UNLESS BACKSLASHES ARE REPLACED WITH FORWARD SLASHES BEFORE PATH
|
||||
// NORMALIZATION, otherwise on POSIXish systems '..\\' in the input pathname would not be
|
||||
// normalized away before being converted to '../'.
|
||||
p = p.replace(/\\/g, '/');
|
||||
return p;
|
||||
};
|
||||
|
||||
const compatPaths = {
|
||||
'js/browser.js': 'js/vendors/browser.js',
|
||||
'js/farbtastic.js': 'js/vendors/farbtastic.js',
|
||||
|
@ -183,6 +166,8 @@ const minify = async (req, res) => {
|
|||
filename = path.join('../node_modules/', library, libraryPath);
|
||||
}
|
||||
}
|
||||
const [, spec] = /^plugins\/ep_etherpad-lite\/(tests\/frontend\/specs\/.*)/.exec(filename) || [];
|
||||
if (spec != null) filename = `../${spec}`;
|
||||
|
||||
const contentType = mime.lookup(filename);
|
||||
|
||||
|
@ -243,7 +228,7 @@ const statFile = async (filename, dirStatLimit) => {
|
|||
try {
|
||||
stats = await fs.stat(path.resolve(ROOT_DIR, filename));
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
|
||||
// Stat the directory instead.
|
||||
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
|
||||
return [date, false];
|
||||
|
|
|
@ -50,11 +50,12 @@ console.log('All relative paths will be interpreted relative to the identified '
|
|||
exports.title = 'Etherpad';
|
||||
|
||||
/**
|
||||
* The app favicon fully specified url, visible e.g. in the browser window
|
||||
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
||||
* used if one is provided by the skin, otherwise the default Etherpad favicon
|
||||
* is used. If this is a relative path it is interpreted as relative to the
|
||||
* Etherpad root directory.
|
||||
*/
|
||||
exports.favicon = 'favicon.ico';
|
||||
exports.faviconPad = `../${exports.favicon}`;
|
||||
exports.faviconTimeslider = `../../${exports.favicon}`;
|
||||
exports.favicon = null;
|
||||
|
||||
/*
|
||||
* Skin name.
|
||||
|
@ -140,7 +141,7 @@ exports.padOptions = {
|
|||
alwaysShowChat: false,
|
||||
chatAndUsers: false,
|
||||
lang: 'en-gb',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether certain shortcut keys are enabled for a user in the pad
|
||||
|
@ -168,7 +169,7 @@ exports.padShortcutEnabled = {
|
|||
ctrlHome: true,
|
||||
pageUp: true,
|
||||
pageDown: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The toolbar buttons and order.
|
||||
|
@ -250,6 +251,11 @@ exports.automaticReconnectionTimeout = 0;
|
|||
*/
|
||||
exports.loadTest = false;
|
||||
|
||||
/**
|
||||
* Disable dump of objects preventing a clean exit
|
||||
*/
|
||||
exports.dumpOnUncleanExit = false;
|
||||
|
||||
/**
|
||||
* Enable indentation on new lines
|
||||
*/
|
||||
|
@ -460,7 +466,7 @@ exports.getEpVersion = () => require('../../package.json').version;
|
|||
* both "settings.json" and "credentials.json".
|
||||
*/
|
||||
const storeSettings = (settingsObj) => {
|
||||
for (const i in settingsObj) {
|
||||
for (const i of Object.keys(settingsObj || {})) {
|
||||
// test if the setting starts with a lowercase character
|
||||
if (i.charAt(0).search('[a-z]') !== 0) {
|
||||
console.warn(`Settings should start with a lowercase character: '${i}'`);
|
||||
|
@ -503,17 +509,13 @@ const coerceValue = (stringValue) => {
|
|||
return +stringValue;
|
||||
}
|
||||
|
||||
// the boolean literal case is easy.
|
||||
if (stringValue === 'true') {
|
||||
return true;
|
||||
switch (stringValue) {
|
||||
case 'true': return true;
|
||||
case 'false': return false;
|
||||
case 'undefined': return undefined;
|
||||
case 'null': return null;
|
||||
default: return stringValue;
|
||||
}
|
||||
|
||||
if (stringValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise, return this value as-is
|
||||
return stringValue;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -597,7 +599,9 @@ const lookupEnvironmentVariables = (obj) => {
|
|||
|
||||
if ((envVarValue === undefined) && (defaultValue === undefined)) {
|
||||
console.warn(`Environment variable "${envVarName}" does not contain any value for ` +
|
||||
`configuration key "${key}", and no default was given. Returning null.`);
|
||||
`configuration key "${key}", and no default was given. Using null. ` +
|
||||
'THIS BEHAVIOR MAY CHANGE IN A FUTURE VERSION OF ETHERPAD; you should ' +
|
||||
'explicitly use "null" as the default if you want to continue to use null.');
|
||||
|
||||
/*
|
||||
* We have to return null, because if we just returned undefined, the
|
||||
|
@ -821,5 +825,9 @@ exports.reloadSettings = () => {
|
|||
console.log(`Random string used for versioning assets: ${exports.randomVersionString}`);
|
||||
};
|
||||
|
||||
exports.exportedForTestingOnly = {
|
||||
parseSettings,
|
||||
};
|
||||
|
||||
// initially load settings
|
||||
exports.reloadSettings();
|
||||
|
|
23
src/node/utils/sanitizePathname.js
Normal file
23
src/node/utils/sanitizePathname.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Normalizes p and ensures that it is a relative path that does not reach outside. See
|
||||
// https://nvd.nist.gov/vuln/detail/CVE-2015-3297 for additional context.
|
||||
module.exports = (p, pathApi = path) => {
|
||||
// The documentation for path.normalize() says that it resolves '..' and '.' segments. The word
|
||||
// "resolve" implies that it examines the filesystem to resolve symbolic links, so 'a/../b' might
|
||||
// not be the same thing as 'b'. Most path normalization functions from other libraries (e.g.,
|
||||
// Python's os.path.normpath()) clearly state that they do not examine the filesystem. Here we
|
||||
// assume Node.js's path.normalize() does the same; that it is only a simple string manipulation.
|
||||
p = pathApi.normalize(p);
|
||||
if (pathApi.isAbsolute(p)) throw new Error(`absolute paths are forbidden: ${p}`);
|
||||
if (p.split(pathApi.sep)[0] === '..') throw new Error(`directory traversal: ${p}`);
|
||||
// On Windows, path normalization replaces forwardslashes with backslashes. Convert them back to
|
||||
// forwardslashes. Node.js treats both the backlash and the forwardslash characters as pathname
|
||||
// component separators on Windows so this does not change the meaning of the pathname on Windows.
|
||||
// THIS CONVERSION MUST ONLY BE DONE ON WINDOWS, otherwise on POSIXish systems '..\\' in the input
|
||||
// pathname would not be normalized away before being converted to '../'.
|
||||
if (pathApi.sep === '\\') p = p.replace(/\\/g, '/');
|
||||
return p;
|
||||
};
|
2099
src/package-lock.json
generated
2099
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -42,7 +42,7 @@
|
|||
"etherpad-yajsml": "0.0.4",
|
||||
"express": "4.17.1",
|
||||
"express-rate-limit": "5.2.6",
|
||||
"express-session": "1.17.1",
|
||||
"express-session": "1.17.2",
|
||||
"find-root": "1.1.0",
|
||||
"formidable": "1.2.2",
|
||||
"http-errors": "1.8.0",
|
||||
|
@ -54,8 +54,8 @@
|
|||
"measured-core": "1.51.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"nodeify": "1.0.1",
|
||||
"npm": "6.14.11",
|
||||
"openapi-backend": "^3.9.0",
|
||||
"npm": "6.14.13",
|
||||
"openapi-backend": "^3.9.1",
|
||||
"proxy-addr": "^2.0.6",
|
||||
"rate-limiter-flexible": "^2.1.4",
|
||||
"rehype": "^10.0.0",
|
||||
|
@ -69,33 +69,34 @@
|
|||
"threads": "^1.4.0",
|
||||
"tiny-worker": "^2.3.0",
|
||||
"tinycon": "0.6.8",
|
||||
"ueberdb2": "^1.4.4",
|
||||
"underscore": "1.12.0",
|
||||
"ueberdb2": "^1.4.7",
|
||||
"underscore": "1.13.1",
|
||||
"unorm": "1.6.0",
|
||||
"wtfnode": "^0.8.4"
|
||||
"wtfnode": "^0.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"etherpad-lite": "node/server.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.20.0",
|
||||
"eslint-config-etherpad": "^1.0.26",
|
||||
"eslint-plugin-cypress": "^2.11.2",
|
||||
"eslint": "^7.28.0",
|
||||
"eslint-config-etherpad": "^2.0.0",
|
||||
"eslint-plugin-cypress": "^2.11.3",
|
||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
||||
"eslint-plugin-mocha": "^8.0.0",
|
||||
"eslint-plugin-mocha": "^9.0.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"eslint-plugin-you-dont-need-lodash-underscore": "^6.11.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
|
||||
"etherpad-cli-client": "0.0.9",
|
||||
"mocha": "7.1.2",
|
||||
"mocha-froth": "^0.2.10",
|
||||
"openapi-schema-validation": "^0.4.2",
|
||||
"selenium-webdriver": "^4.0.0-beta.3",
|
||||
"set-cookie-parser": "^2.4.6",
|
||||
"sinon": "^9.2.0",
|
||||
"split-grid": "^1.0.11",
|
||||
"superagent": "^3.8.3",
|
||||
"supertest": "4.0.2",
|
||||
"wd": "1.12.1"
|
||||
"supertest": "4.0.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"ignorePatterns": [
|
||||
|
@ -234,7 +235,7 @@
|
|||
"root": true
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.17.0 || >=11.14.0",
|
||||
"node": ">=12.13.0",
|
||||
"npm": ">=5.5.1"
|
||||
},
|
||||
"repository": {
|
||||
|
@ -246,6 +247,6 @@
|
|||
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
|
||||
"test-container": "mocha --timeout 5000 tests/container/specs/api"
|
||||
},
|
||||
"version": "1.8.13",
|
||||
"version": "1.8.14",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
|
|
@ -58,11 +58,6 @@ html.outer-editor, html.inner-editor {
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
#innerdocbody.authorColors span {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
option {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
|
|
@ -24,6 +24,9 @@
|
|||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttonicon:before, [class^="buttonicon-"]:before, [class*=" buttonicon-"]:before {
|
||||
|
@ -34,9 +37,6 @@
|
|||
font-size: 15px;
|
||||
display: inline-block;
|
||||
text-decoration: inherit;
|
||||
width: 1em;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
|
||||
/* For safety - reset parent styles, that can break glyph codes*/
|
||||
font-variant: normal;
|
||||
|
|
|
@ -63,15 +63,20 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
|
|||
@param attribs: an array of attributes
|
||||
*/
|
||||
setAttributesOnRange(start, end, attribs) {
|
||||
if (start[0] < 0) throw new RangeError('selection start line number is negative');
|
||||
if (start[1] < 0) throw new RangeError('selection start column number is negative');
|
||||
if (end[0] < 0) throw new RangeError('selection end line number is negative');
|
||||
if (end[1] < 0) throw new RangeError('selection end column number is negative');
|
||||
if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) {
|
||||
throw new RangeError('selection ends before it starts');
|
||||
}
|
||||
|
||||
// instead of applying the attributes to the whole range at once, we need to apply them
|
||||
// line by line, to be able to disregard the "*" used as line marker. For more details,
|
||||
// see https://github.com/ether/etherpad-lite/issues/2772
|
||||
let allChangesets;
|
||||
for (let row = start[0]; row <= end[0]; row++) {
|
||||
const rowRange = this._findRowRange(row, start, end);
|
||||
const startCol = rowRange[0];
|
||||
const endCol = rowRange[1];
|
||||
|
||||
const [startCol, endCol] = this._findRowRange(row, start, end);
|
||||
const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs);
|
||||
|
||||
// compose changesets of all rows into a single changeset
|
||||
|
@ -89,35 +94,33 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
|
|||
},
|
||||
|
||||
_findRowRange(row, start, end) {
|
||||
let startCol, endCol;
|
||||
if (row < start[0] || row > end[0]) throw new RangeError(`line ${row} not in selection`);
|
||||
if (row >= this.rep.lines.length()) throw new RangeError(`selected line ${row} does not exist`);
|
||||
|
||||
const startLineOffset = this.rep.lines.offsetOfIndex(row);
|
||||
const endLineOffset = this.rep.lines.offsetOfIndex(row + 1);
|
||||
const lineLength = endLineOffset - startLineOffset;
|
||||
// Subtract 1 for the end-of-line '\n' (it is never selected).
|
||||
const lineLength =
|
||||
this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1;
|
||||
const markerWidth = this.lineHasMarker(row) ? 1 : 0;
|
||||
if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`);
|
||||
|
||||
// find column where range on this row starts
|
||||
if (row === start[0]) { // are we on the first row of range?
|
||||
startCol = start[1];
|
||||
} else {
|
||||
startCol = this.lineHasMarker(row) ? 1 : 0; // remove "*" used as line marker
|
||||
}
|
||||
const startCol = row === start[0] ? start[1] : markerWidth;
|
||||
if (startCol - markerWidth < 0) throw new RangeError('selection starts before line start');
|
||||
if (startCol > lineLength) throw new RangeError('selection starts after line end');
|
||||
|
||||
// find column where range on this row ends
|
||||
if (row === end[0]) { // are we on the last row of range?
|
||||
endCol = end[1]; // if so, get the end of range, not end of row
|
||||
} else {
|
||||
endCol = lineLength - 1; // remove "\n"
|
||||
}
|
||||
const endCol = row === end[0] ? end[1] : lineLength;
|
||||
if (endCol - markerWidth < 0) throw new RangeError('selection ends before line start');
|
||||
if (endCol > lineLength) throw new RangeError('selection ends after line end');
|
||||
if (startCol > endCol) throw new RangeError('selection ends before it starts');
|
||||
|
||||
return [startCol, endCol];
|
||||
},
|
||||
|
||||
/*
|
||||
Sets attributes on a range, by line
|
||||
@param row the row where range is
|
||||
@param startCol column where range starts
|
||||
@param endCol column where range ends
|
||||
@param attribs: an array of attributes
|
||||
/**
|
||||
* Sets attributes on a range, by line
|
||||
* @param row the row where range is
|
||||
* @param startCol column where range starts
|
||||
* @param endCol column where range ends (one past the last selected column)
|
||||
* @param attribs an array of attributes
|
||||
*/
|
||||
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
|
||||
const builder = Changeset.builder(this.rep.lines.totalWidth());
|
||||
|
|
|
@ -108,7 +108,6 @@ exports.newLen = (cs) => exports.unpack(cs).newLen;
|
|||
* @return {Op} type object iterator
|
||||
*/
|
||||
exports.opIterator = (opsStr, optStartIndex) => {
|
||||
// print(opsStr);
|
||||
const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g;
|
||||
const startIndex = (optStartIndex || 0);
|
||||
let curIndex = startIndex;
|
||||
|
@ -126,10 +125,9 @@ exports.opIterator = (opsStr, optStartIndex) => {
|
|||
return result;
|
||||
};
|
||||
let regexResult = nextRegexMatch();
|
||||
const obj = exports.newOp();
|
||||
|
||||
const next = (optObj) => {
|
||||
const op = (optObj || obj);
|
||||
const next = (optOp) => {
|
||||
const op = optOp || exports.newOp();
|
||||
if (regexResult[0]) {
|
||||
op.attribs = regexResult[1];
|
||||
op.lines = exports.parseNum(regexResult[2] || 0);
|
||||
|
@ -645,15 +643,8 @@ exports.textLinesMutator = (lines) => {
|
|||
curLine += L;
|
||||
curCol = 0;
|
||||
}
|
||||
// print(inSplice+" / "+isCurLineInSplice()+" / "+curSplice[0]+" /
|
||||
// "+curSplice[1]+" / "+lines.length);
|
||||
/* if (inSplice && (! isCurLineInSplice()) && (curSplice[0] + curSplice[1] < lines.length)) {
|
||||
print("BLAH");
|
||||
putCurLineInSplice();
|
||||
}*/
|
||||
// tests case foo in remove(), which isn't otherwise covered in current impl
|
||||
}
|
||||
// debugPrint("skip");
|
||||
};
|
||||
|
||||
const skip = (N, L, includeInSplice) => {
|
||||
|
@ -668,7 +659,6 @@ exports.textLinesMutator = (lines) => {
|
|||
putCurLineInSplice();
|
||||
}
|
||||
curCol += N;
|
||||
// debugPrint("skip");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -685,10 +675,8 @@ exports.textLinesMutator = (lines) => {
|
|||
return lines_slice(m, m + k).join('');
|
||||
};
|
||||
if (isCurLineInSplice()) {
|
||||
// print(curCol);
|
||||
if (curCol === 0) {
|
||||
removed = curSplice[curSplice.length - 1];
|
||||
// print("FOO"); // case foo
|
||||
curSplice.length--;
|
||||
removed += nextKLinesText(L - 1);
|
||||
curSplice[1] += L - 1;
|
||||
|
@ -705,7 +693,6 @@ exports.textLinesMutator = (lines) => {
|
|||
removed = nextKLinesText(L);
|
||||
curSplice[1] += L;
|
||||
}
|
||||
// debugPrint("remove");
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
@ -723,7 +710,6 @@ exports.textLinesMutator = (lines) => {
|
|||
removed = curSplice[sline].substring(curCol, curCol + N);
|
||||
curSplice[sline] = curSplice[sline].substring(0, curCol) +
|
||||
curSplice[sline].substring(curCol + N);
|
||||
// debugPrint("remove");
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
|
@ -737,13 +723,6 @@ exports.textLinesMutator = (lines) => {
|
|||
if (L) {
|
||||
const newLines = exports.splitTextLines(text);
|
||||
if (isCurLineInSplice()) {
|
||||
// if (curCol == 0) {
|
||||
// curSplice.length--;
|
||||
// curSplice[1]--;
|
||||
// Array.prototype.push.apply(curSplice, newLines);
|
||||
// curLine += newLines.length;
|
||||
// }
|
||||
// else {
|
||||
const sline = curSplice.length - 1;
|
||||
const theLine = curSplice[sline];
|
||||
const lineCol = curCol;
|
||||
|
@ -754,7 +733,6 @@ exports.textLinesMutator = (lines) => {
|
|||
curLine += newLines.length;
|
||||
curSplice.push(theLine.substring(lineCol));
|
||||
curCol = 0;
|
||||
// }
|
||||
} else {
|
||||
Array.prototype.push.apply(curSplice, newLines);
|
||||
curLine += newLines.length;
|
||||
|
@ -768,12 +746,10 @@ exports.textLinesMutator = (lines) => {
|
|||
curSplice[sline].substring(curCol);
|
||||
curCol += text.length;
|
||||
}
|
||||
// debugPrint("insert");
|
||||
}
|
||||
};
|
||||
|
||||
const hasMore = () => {
|
||||
// print(lines.length+" / "+inSplice+" / "+(curSplice.length - 2)+" / "+curSplice[1]);
|
||||
let docLines = lines_length();
|
||||
if (inSplice) {
|
||||
docLines += curSplice.length - 2 - curSplice[1];
|
||||
|
@ -785,7 +761,6 @@ exports.textLinesMutator = (lines) => {
|
|||
if (inSplice) {
|
||||
leaveSplice();
|
||||
}
|
||||
// debugPrint("close");
|
||||
};
|
||||
|
||||
const self = {
|
||||
|
@ -827,7 +802,6 @@ exports.applyZip = (in1, idx1, in2, idx2, func) => {
|
|||
if ((!op2.opcode) && iter2.hasNext()) iter2.next(op2);
|
||||
func(op1, op2, opOut);
|
||||
if (opOut.opcode) {
|
||||
// print(opOut.toSource());
|
||||
assem.append(opOut);
|
||||
opOut.opcode = '';
|
||||
}
|
||||
|
@ -1012,7 +986,6 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => {
|
|||
buf.append('*');
|
||||
buf.append(exports.numToString(pool.putAttrib(atts[i])));
|
||||
}
|
||||
// print(att1+" / "+att2+" / "+buf.toString());
|
||||
return buf.toString();
|
||||
};
|
||||
|
||||
|
@ -1024,7 +997,6 @@ exports._slicerZipperFunc = (attOp, csOp, opOut, pool) => {
|
|||
// attOp is the op from the sequence that is being operated on, either an
|
||||
// attribution string or the earlier of two exportss being composed.
|
||||
// pool can be null if definitely not needed.
|
||||
// print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
|
||||
if (attOp.opcode === '-') {
|
||||
exports.copyOp(attOp, opOut);
|
||||
attOp.opcode = '';
|
||||
|
@ -1121,15 +1093,7 @@ exports.applyToAttribution = (cs, astr, pool) => {
|
|||
(op1, op2, opOut) => exports._slicerZipperFunc(op1, op2, opOut, pool));
|
||||
};
|
||||
|
||||
/* exports.oneInsertedLineAtATimeOpIterator = function(opsStr, optStartIndex, charBank) {
|
||||
var iter = exports.opIterator(opsStr, optStartIndex);
|
||||
var bankIndex = 0;
|
||||
|
||||
};*/
|
||||
|
||||
exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||
// dmesg(cs);
|
||||
// dmesg(lines.toSource()+" ->");
|
||||
const unpacked = exports.unpack(cs);
|
||||
const csIter = exports.opIterator(unpacked.ops);
|
||||
const csBank = unpacked.charBank;
|
||||
|
@ -1155,7 +1119,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
|||
let lineAssem = null;
|
||||
|
||||
const outputMutOp = (op) => {
|
||||
// print("outputMutOp: "+op.toSource());
|
||||
if (!lineAssem) {
|
||||
lineAssem = exports.mergingOpAssembler();
|
||||
}
|
||||
|
@ -1175,17 +1138,12 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
|||
if ((!csOp.opcode) && csIter.hasNext()) {
|
||||
csIter.next(csOp);
|
||||
}
|
||||
// print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
|
||||
// print(csOp.opcode+"/"+csOp.lines+"/"+csOp.attribs+"/"+lineAssem+
|
||||
// "/"+lineIter+"/"+(lineIter?lineIter.hasNext():null));
|
||||
// print("csOp: "+csOp.toSource());
|
||||
if ((!csOp.opcode) && (!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) {
|
||||
break; // done
|
||||
} else if (csOp.opcode === '=' && csOp.lines > 0 && (!csOp.attribs) &&
|
||||
(!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) {
|
||||
// skip multiple lines; this is what makes small changes not order of the document size
|
||||
mut.skipLines(csOp.lines);
|
||||
// print("skipped: "+csOp.lines);
|
||||
csOp.opcode = '';
|
||||
} else if (csOp.opcode === '+') {
|
||||
if (csOp.lines > 1) {
|
||||
|
@ -1206,7 +1164,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
|||
if ((!attOp.opcode) && isNextMutOp()) {
|
||||
nextMutOp(attOp);
|
||||
}
|
||||
// print("attOp: "+attOp.toSource());
|
||||
exports._slicerZipperFunc(attOp, csOp, opOut, pool);
|
||||
if (opOut.opcode) {
|
||||
outputMutOp(opOut);
|
||||
|
@ -1217,8 +1174,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
|||
|
||||
exports.assert(!lineAssem, `line assembler not finished:${cs}`);
|
||||
mut.close();
|
||||
|
||||
// dmesg("-> "+lines.toSource());
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1300,11 +1255,6 @@ exports.compose = (cs1, cs2, pool) => {
|
|||
const bankAssem = exports.stringAssembler();
|
||||
|
||||
const newOps = exports.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, (op1, op2, opOut) => {
|
||||
// var debugBuilder = exports.stringAssembler();
|
||||
// debugBuilder.append(exports.opString(op1));
|
||||
// debugBuilder.append(',');
|
||||
// debugBuilder.append(exports.opString(op2));
|
||||
// debugBuilder.append(' / ');
|
||||
const op1code = op1.opcode;
|
||||
const op2code = op2.opcode;
|
||||
if (op1code === '+' && op2code === '-') {
|
||||
|
@ -1318,13 +1268,6 @@ exports.compose = (cs1, cs2, pool) => {
|
|||
bankAssem.append(bankIter1.take(opOut.chars));
|
||||
}
|
||||
}
|
||||
|
||||
// debugBuilder.append(exports.opString(op1));
|
||||
// debugBuilder.append(',');
|
||||
// debugBuilder.append(exports.opString(op2));
|
||||
// debugBuilder.append(' -> ');
|
||||
// debugBuilder.append(exports.opString(opOut));
|
||||
// print(debugBuilder.toString());
|
||||
});
|
||||
|
||||
return exports.pack(len1, len3, newOps, bankAssem.toString());
|
||||
|
@ -2197,7 +2140,6 @@ exports._slicerZipperFuncWithDeletions = (attOp, csOp, opOut, pool) => {
|
|||
// attOp is the op from the sequence that is being operated on, either an
|
||||
// attribution string or the earlier of two exportss being composed.
|
||||
// pool can be null if definitely not needed.
|
||||
// print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
|
||||
if (attOp.opcode === '-') {
|
||||
exports.copyOp(attOp, opOut);
|
||||
attOp.opcode = '';
|
||||
|
|
|
@ -141,7 +141,7 @@ const Ace2Editor = function () {
|
|||
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
|
||||
|
||||
this.getInInternationalComposition =
|
||||
() => loaded ? info.ace_getInInternationalComposition() : false;
|
||||
() => loaded ? info.ace_getInInternationalComposition() : null;
|
||||
|
||||
// prepareUserChangeset:
|
||||
// Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes
|
||||
|
@ -278,9 +278,8 @@ const Ace2Editor = function () {
|
|||
innerDocument.head.appendChild(innerStyle);
|
||||
const headLines = [];
|
||||
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
|
||||
const tmp = innerDocument.createElement('div');
|
||||
tmp.innerHTML = headLines.join('\n');
|
||||
while (tmp.firstChild) innerDocument.head.appendChild(tmp.firstChild);
|
||||
innerDocument.head.appendChild(
|
||||
innerDocument.createRange().createContextualFragment(headLines.join('\n')));
|
||||
|
||||
// <body> tag
|
||||
innerDocument.body.id = 'innerdocbody';
|
||||
|
|
|
@ -86,14 +86,24 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
let outsideKeyPress = (e) => true;
|
||||
let outsideNotifyDirty = noop;
|
||||
|
||||
// selFocusAtStart -- determines whether the selection extends "backwards", so that the focus
|
||||
// point (controlled with the arrow keys) is at the beginning; not supported in IE, though
|
||||
// native IE selections have that behavior (which we try not to interfere with).
|
||||
// Must be false if selection is collapsed!
|
||||
// Document representation.
|
||||
const rep = {
|
||||
// Each entry in this skip list is an object created by createDomLineEntry(). The object
|
||||
// represents a line (paragraph) of content.
|
||||
lines: new SkipList(),
|
||||
// Points at the start of the selection. Represented as [zeroBasedLineNumber,
|
||||
// zeroBasedColumnNumber].
|
||||
// TODO: If the selection starts at the beginning of a line, I think this could be either
|
||||
// [lineNumber, 0] or [previousLineNumber, previousLineLength]. Need to confirm.
|
||||
selStart: null,
|
||||
// Points at the character just past the last selected character. Same representation as
|
||||
// selStart.
|
||||
// TODO: If the last selected character is the last character of a line, I think this could be
|
||||
// either [lineNumber, lineLength] or [lineNumber+1, 0]. Need to confirm.
|
||||
selEnd: null,
|
||||
// Whether the selection extends "backwards", so that the focus point (controlled with the arrow
|
||||
// keys) is at the beginning. This is not supported in IE, though native IE selections have that
|
||||
// behavior (which we try not to interfere with). Must be false if selection is collapsed!
|
||||
selFocusAtStart: false,
|
||||
alltext: '',
|
||||
alines: [],
|
||||
|
@ -136,17 +146,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
for (let i = 0; i < names.length; ++i) console[names[i]] = noop;
|
||||
}
|
||||
|
||||
let PROFILER = window.PROFILER;
|
||||
if (!PROFILER) {
|
||||
PROFILER = () => ({
|
||||
start: noop,
|
||||
mark: noop,
|
||||
literal: noop,
|
||||
end: noop,
|
||||
cancel: noop,
|
||||
});
|
||||
}
|
||||
|
||||
// "dmesg" is for displaying messages in the in-page output pane
|
||||
// visible when "?djs=1" is appended to the pad URL. It generally
|
||||
// remains a no-op unless djs is enabled, but we make a habit of
|
||||
|
@ -227,18 +226,18 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
if ((typeof info.fade) === 'number') {
|
||||
bgcolor = fadeColor(bgcolor, info.fade);
|
||||
}
|
||||
|
||||
const authorStyle = cssManagers.inner.selectorStyle(authorSelector);
|
||||
const parentAuthorStyle = cssManagers.parent.selectorStyle(authorSelector);
|
||||
|
||||
// author color
|
||||
authorStyle.backgroundColor = bgcolor;
|
||||
parentAuthorStyle.backgroundColor = bgcolor;
|
||||
|
||||
const textColor =
|
||||
colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName);
|
||||
authorStyle.color = textColor;
|
||||
parentAuthorStyle.color = textColor;
|
||||
const styles = [
|
||||
cssManagers.inner.selectorStyle(authorSelector),
|
||||
cssManagers.parent.selectorStyle(authorSelector),
|
||||
];
|
||||
for (const style of styles) {
|
||||
style.backgroundColor = bgcolor;
|
||||
style.color = textColor;
|
||||
style['padding-top'] = '3px';
|
||||
style['padding-bottom'] = '4px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -301,12 +300,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const inCallStack = (type, action) => {
|
||||
if (disposed) return;
|
||||
|
||||
if (currentCallStack) {
|
||||
// Do not uncomment this in production. It will break Etherpad being provided in iFrames.
|
||||
// I am leaving this in for testing usefulness.
|
||||
// top.console.error(`Can't enter callstack ${type}, already in ${currentCallStack.type}`);
|
||||
}
|
||||
|
||||
const newEditEvent = (eventType) => ({
|
||||
eventType,
|
||||
backset: null,
|
||||
|
@ -388,11 +381,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
if (cleanExit) {
|
||||
submitOldEvent(cs.editEvent);
|
||||
if (cs.domClean && cs.type !== 'setup') {
|
||||
// if (cs.isUserChange)
|
||||
// {
|
||||
// if (cs.repChanged) parenModule.notifyChange();
|
||||
// else parenModule.notifyTick();
|
||||
// }
|
||||
if (cs.selectionAffected) {
|
||||
updateBrowserSelectionFromRep();
|
||||
}
|
||||
|
@ -668,9 +656,11 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
};
|
||||
|
||||
// This methed exposes a setter for some ace properties
|
||||
// @param key the name of the parameter
|
||||
// @param value the value to set to
|
||||
/**
|
||||
* This methed exposes a setter for some ace properties
|
||||
* @param key the name of the parameter
|
||||
* @param value the value to set to
|
||||
*/
|
||||
editorInfo.ace_setProperty = (key, value) => {
|
||||
// These properties are exposed
|
||||
const setters = {
|
||||
|
@ -753,7 +743,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
let printedTrace = false;
|
||||
const isTimeUp = () => {
|
||||
if (exceededAlready) {
|
||||
if ((!printedTrace)) { // && now() - startTime - ms > 300) {
|
||||
if ((!printedTrace)) {
|
||||
printedTrace = true;
|
||||
}
|
||||
return true;
|
||||
|
@ -949,17 +939,12 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
clearObservedChanges();
|
||||
|
||||
const getCleanNodeByKey = (key) => {
|
||||
const p = PROFILER('getCleanNodeByKey', false); // eslint-disable-line new-cap
|
||||
p.extra = 0;
|
||||
let n = doc.getElementById(key);
|
||||
// copying and pasting can lead to duplicate ids
|
||||
while (n && isNodeDirty(n)) {
|
||||
p.extra++;
|
||||
n.id = '';
|
||||
n = doc.getElementById(key);
|
||||
}
|
||||
p.literal(p.extra, 'extra');
|
||||
p.end();
|
||||
return n;
|
||||
};
|
||||
|
||||
|
@ -1025,9 +1010,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
if (currentCallStack.observedSelection) return;
|
||||
currentCallStack.observedSelection = true;
|
||||
|
||||
const p = PROFILER('getSelection', false); // eslint-disable-line new-cap
|
||||
const selection = getSelection();
|
||||
p.end();
|
||||
|
||||
if (selection) {
|
||||
const node1 = topLevel(selection.startPoint.node);
|
||||
|
@ -1060,17 +1043,13 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false;
|
||||
|
||||
const p = PROFILER('incorp', false); // eslint-disable-line new-cap
|
||||
|
||||
// returns true if dom changes were made
|
||||
if (!root.firstChild) {
|
||||
root.innerHTML = '<div><!-- --></div>';
|
||||
}
|
||||
|
||||
p.mark('obs');
|
||||
observeChangesAroundSelection();
|
||||
observeSuspiciousNodes();
|
||||
p.mark('dirty');
|
||||
let dirtyRanges = getDirtyRanges();
|
||||
let dirtyRangesCheckOut = true;
|
||||
let j = 0;
|
||||
|
@ -1100,7 +1079,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
clearObservedChanges();
|
||||
|
||||
p.mark('getsel');
|
||||
const selection = getSelection();
|
||||
|
||||
let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection
|
||||
|
@ -1108,8 +1086,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const splicesToDo = [];
|
||||
let netNumLinesChangeSoFar = 0;
|
||||
const toDeleteAtEnd = [];
|
||||
p.mark('ranges');
|
||||
p.literal(dirtyRanges.length, 'numdirt');
|
||||
const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]]
|
||||
while (i < dirtyRanges.length) {
|
||||
const range = dirtyRanges[i];
|
||||
|
@ -1176,7 +1152,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
entries.push(newEntry);
|
||||
lineNodeInfos[k] = newEntry.domInfo;
|
||||
}
|
||||
// var fragment = magicdom.wrapDom(document.createDocumentFragment());
|
||||
domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]);
|
||||
dirtyNodes.forEach((n) => {
|
||||
toDeleteAtEnd.push(n);
|
||||
|
@ -1198,25 +1173,19 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const domChanges = (splicesToDo.length > 0);
|
||||
|
||||
// update the representation
|
||||
p.mark('splice');
|
||||
splicesToDo.forEach((splice) => {
|
||||
doIncorpLineSplice(splice[0], splice[1], splice[2], splice[3], splice[4]);
|
||||
});
|
||||
|
||||
// do DOM inserts
|
||||
p.mark('insert');
|
||||
domInsertsNeeded.forEach((ins) => {
|
||||
insertDomLines(ins[0], ins[1]);
|
||||
});
|
||||
|
||||
p.mark('del');
|
||||
// delete old dom nodes
|
||||
toDeleteAtEnd.forEach((n) => {
|
||||
// var id = n.uniqueId();
|
||||
// parent of n may not be "root" in IE due to non-tree-shaped DOM (wtf)
|
||||
if (n.parentNode) n.parentNode.removeChild(n);
|
||||
|
||||
// dmesg(htmlPrettyEscape(htmlForRemovedChild(n)));
|
||||
});
|
||||
|
||||
// needed to stop chrome from breaking the ui when long strings without spaces are pasted
|
||||
|
@ -1224,11 +1193,9 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
$('#innerdocbody').scrollLeft(0);
|
||||
}
|
||||
|
||||
p.mark('findsel');
|
||||
// if the nodes that define the selection weren't encountered during
|
||||
// content collection, figure out where those nodes are now.
|
||||
if (selection && !selStart) {
|
||||
// if (domChanges) dmesg("selection not collected");
|
||||
const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', {
|
||||
callstack: currentCallStack,
|
||||
editorInfo,
|
||||
|
@ -1266,14 +1233,12 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length;
|
||||
}
|
||||
|
||||
p.mark('repsel');
|
||||
// update rep if we have a new selection
|
||||
// NOTE: IE loses the selection when you click stuff in e.g. the
|
||||
// editbar, so removing the selection when it's lost is not a good
|
||||
// idea.
|
||||
if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart);
|
||||
// update browser selection
|
||||
p.mark('browsel');
|
||||
if (selection && (domChanges || isCaret())) {
|
||||
// if no DOM changes (not this case), want to treat range selection delicately,
|
||||
// e.g. in IE not lose which end of the selection is the focus/anchor;
|
||||
|
@ -1283,12 +1248,8 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
currentCallStack.domClean = true;
|
||||
|
||||
p.mark('fixview');
|
||||
|
||||
fixView();
|
||||
|
||||
p.end('END');
|
||||
|
||||
return domChanges;
|
||||
};
|
||||
|
||||
|
@ -1311,11 +1272,9 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
if (infoStructs.length < 1) return;
|
||||
|
||||
infoStructs.forEach((info) => {
|
||||
const p2 = PROFILER('insertLine', false); // eslint-disable-line new-cap
|
||||
const node = info.node;
|
||||
const key = uniqueId(node);
|
||||
let entry;
|
||||
p2.mark('findEntry');
|
||||
if (lastEntry) {
|
||||
// optimization to avoid recalculation
|
||||
const next = rep.lines.next(lastEntry);
|
||||
|
@ -1325,16 +1284,13 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
}
|
||||
if (!entry) {
|
||||
p2.literal(1, 'nonopt');
|
||||
entry = rep.lines.atKey(key);
|
||||
lineStartOffset = rep.lines.offsetOfKey(key);
|
||||
} else { p2.literal(0, 'nonopt'); }
|
||||
}
|
||||
lastEntry = entry;
|
||||
p2.mark('spans');
|
||||
getSpansForLine(entry, (tokenText, tokenClass) => {
|
||||
info.appendSpan(tokenText, tokenClass);
|
||||
}, lineStartOffset);
|
||||
p2.mark('addLine');
|
||||
info.prepareForAdd();
|
||||
entry.lineMarker = info.lineMarker;
|
||||
if (!nodeToAddAfter) {
|
||||
|
@ -1344,9 +1300,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
nodeToAddAfter = node;
|
||||
info.notifyAdded();
|
||||
p2.mark('markClean');
|
||||
markNodeClean(node);
|
||||
p2.end();
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -1359,8 +1313,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
editorInfo.ace_isCaret = isCaret;
|
||||
|
||||
// prereq: isCaret()
|
||||
|
||||
|
||||
const caretLine = () => rep.selStart[0];
|
||||
|
||||
editorInfo.ace_caretLine = caretLine;
|
||||
|
@ -1398,9 +1350,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const getPointForLineAndChar = (lineAndChar) => {
|
||||
const line = lineAndChar[0];
|
||||
let charsLeft = lineAndChar[1];
|
||||
// Do not uncomment this in production it will break iFrames.
|
||||
// top.console.log("line: %d, key: %s, node: %o", line, rep.lines.atIndex(line).key,
|
||||
// getCleanNodeByKey(rep.lines.atIndex(line).key));
|
||||
const lineEntry = rep.lines.atIndex(line);
|
||||
charsLeft -= lineEntry.lineMarker;
|
||||
if (charsLeft < 0) {
|
||||
|
@ -1574,7 +1523,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);
|
||||
}
|
||||
|
||||
// (function doRecordUndoInformation(changes) {
|
||||
((changes) => {
|
||||
const editEvent = currentCallStack.editEvent;
|
||||
if (editEvent.eventType === 'nonundoable') {
|
||||
|
@ -1597,7 +1545,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
})(changes);
|
||||
|
||||
// rep.alltext = Changeset.applyToText(changes, rep.alltext);
|
||||
Changeset.mutateAttributionLines(changes, rep.alines, rep.apool);
|
||||
|
||||
if (changesetTracker.isTracking()) {
|
||||
|
@ -1605,8 +1552,8 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Converts the position of a char (index in String) into a [row, col] tuple
|
||||
/**
|
||||
* Converts the position of a char (index in String) into a [row, col] tuple
|
||||
*/
|
||||
const lineAndColumnFromChar = (x) => {
|
||||
const lineEntry = rep.lines.atOffset(x);
|
||||
|
@ -1994,7 +1941,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
theChangeset = builder.toString();
|
||||
}
|
||||
|
||||
// dmesg(htmlPrettyEscape(theChangeset));
|
||||
doRepApplyChangeset(theChangeset);
|
||||
}
|
||||
|
||||
|
@ -2170,13 +2116,8 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
|
||||
return true;
|
||||
// Do not uncomment this in production it will break iFrames.
|
||||
// top.console.log("selStart: %o, selEnd: %o, focusAtStart: %s", rep.selStart, rep.selEnd,
|
||||
// String(!!rep.selFocusAtStart));
|
||||
}
|
||||
return false;
|
||||
// Do not uncomment this in production it will break iFrames.
|
||||
// top.console.log("%o %o %s", rep.selStart, rep.selEnd, rep.selFocusAtStart);
|
||||
};
|
||||
|
||||
const isPadLoading = (eventType) => (
|
||||
|
@ -2228,10 +2169,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
// indicating inserted content. for example, [0,0] means content was inserted
|
||||
// at the top of the document, while [3,4] means line 3 was deleted, modified,
|
||||
// or replaced with one or more new lines of content. ranges do not touch.
|
||||
const p = PROFILER('getDirtyRanges', false); // eslint-disable-line new-cap
|
||||
p.forIndices = 0;
|
||||
p.consecutives = 0;
|
||||
p.corrections = 0;
|
||||
|
||||
const cleanNodeForIndexCache = {};
|
||||
const N = rep.lines.length(); // old number of lines
|
||||
|
@ -2242,7 +2179,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
// in the document, return that node.
|
||||
// if (i) is out of bounds, return true. else return false.
|
||||
if (cleanNodeForIndexCache[i] === undefined) {
|
||||
p.forIndices++;
|
||||
let result;
|
||||
if (i < 0 || i >= N) {
|
||||
result = true; // truthy, but no actual node
|
||||
|
@ -2258,7 +2194,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
const isConsecutive = (i) => {
|
||||
if (isConsecutiveCache[i] === undefined) {
|
||||
p.consecutives++;
|
||||
isConsecutiveCache[i] = (() => {
|
||||
// returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes,
|
||||
// or document boundaries, are consecutive in the changed DOM
|
||||
|
@ -2320,7 +2255,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
const correctlyAssignLine = (line) => {
|
||||
if (correctedLines[line]) return true;
|
||||
p.corrections++;
|
||||
correctedLines[line] = true;
|
||||
// "line" is an index of a line in the un-updated rep.
|
||||
// returns whether line was already correctly assigned (i.e. correctly
|
||||
|
@ -2384,16 +2318,13 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
};
|
||||
|
||||
if (N === 0) {
|
||||
p.cancel();
|
||||
if (!isConsecutive(0)) {
|
||||
splitRange(0, 0);
|
||||
}
|
||||
} else {
|
||||
p.mark('topbot');
|
||||
detectChangesAroundLine(0, 1);
|
||||
detectChangesAroundLine(N - 1, 1);
|
||||
|
||||
p.mark('obs');
|
||||
for (const k in observedChanges.cleanNodesNearChanges) {
|
||||
if (observedChanges.cleanNodesNearChanges[k]) {
|
||||
const key = k.substring(1);
|
||||
|
@ -2403,10 +2334,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
}
|
||||
}
|
||||
p.mark('stats&calc');
|
||||
p.literal(p.forIndices, 'byidx');
|
||||
p.literal(p.consecutives, 'cons');
|
||||
p.literal(p.corrections, 'corr');
|
||||
}
|
||||
|
||||
const dirtyRanges = [];
|
||||
|
@ -2414,8 +2341,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]);
|
||||
}
|
||||
|
||||
p.end();
|
||||
|
||||
return dirtyRanges;
|
||||
};
|
||||
|
||||
|
@ -2428,13 +2353,11 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
};
|
||||
|
||||
const isNodeDirty = (n) => {
|
||||
const p = PROFILER('cleanCheck', false); // eslint-disable-line new-cap
|
||||
if (n.parentNode !== root) return true;
|
||||
const data = getAssoc(n, 'dirtiness');
|
||||
if (!data) return true;
|
||||
if (n.id !== data.nodeId) return true;
|
||||
if (n.innerHTML !== data.knownHTML) return true;
|
||||
p.end();
|
||||
return false;
|
||||
};
|
||||
|
||||
|
@ -2641,7 +2564,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const tabSize = THE_TAB.length;
|
||||
const toDelete = ((col2 - 1) % tabSize) + 1;
|
||||
performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], '');
|
||||
// scrollSelectionIntoView();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
@ -2730,7 +2652,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
const altKey = evt.altKey;
|
||||
const shiftKey = evt.shiftKey;
|
||||
|
||||
// dmesg("keyevent type: "+type+", which: "+which);
|
||||
// Don't take action based on modifier keys going up and down.
|
||||
// Modifier keys do not generate "keypress" events.
|
||||
// 224 is the command-key under Mac Firefox.
|
||||
|
@ -2930,7 +2851,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
fastIncorp(4);
|
||||
evt.preventDefault();
|
||||
doReturnKey();
|
||||
// scrollSelectionIntoView();
|
||||
scheduler.setTimeout(() => {
|
||||
outerWin.scrollBy(-100, 0);
|
||||
}, 0);
|
||||
|
@ -2979,7 +2899,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
fastIncorp(5);
|
||||
evt.preventDefault();
|
||||
doTabKey(evt.shiftKey);
|
||||
// scrollSelectionIntoView();
|
||||
specialHandled = true;
|
||||
}
|
||||
if ((!specialHandled) &&
|
||||
|
@ -3273,7 +3192,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
if (isCollapsed) {
|
||||
const diveDeep = () => {
|
||||
while (p.node.childNodes.length > 0) {
|
||||
// && (p.node == root || p.node.parentNode == root)) {
|
||||
if (p.index === 0) {
|
||||
p.node = p.node.firstChild;
|
||||
p.maxIndex = nodeMaxIndex(p.node);
|
||||
|
@ -3504,16 +3422,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
const teardown = () => _teardownActions.forEach((a) => a());
|
||||
|
||||
let inInternationalComposition = false;
|
||||
const handleCompositionEvent = (evt) => {
|
||||
// international input events, fired in FF3, at least; allow e.g. Japanese input
|
||||
if (evt.type === 'compositionstart') {
|
||||
inInternationalComposition = true;
|
||||
} else if (evt.type === 'compositionend') {
|
||||
inInternationalComposition = false;
|
||||
}
|
||||
};
|
||||
|
||||
let inInternationalComposition = null;
|
||||
editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
|
||||
|
||||
const bindTheEventHandlers = () => {
|
||||
|
@ -3523,9 +3432,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
$(document).on('click', handleClick);
|
||||
// dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer
|
||||
$(outerWin.document).on('click', hideEditBarDropdowns);
|
||||
// Disabled: https://github.com/ether/etherpad-lite/issues/2546
|
||||
// Will break OL re-numbering: https://github.com/ether/etherpad-lite/pull/2533
|
||||
// $(document).on("cut", handleCut);
|
||||
|
||||
// If non-nullish, pasting on a link should be suppressed.
|
||||
let suppressPasteOnLink = null;
|
||||
|
@ -3602,8 +3508,15 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
});
|
||||
});
|
||||
|
||||
$(document.documentElement).on('compositionstart', handleCompositionEvent);
|
||||
$(document.documentElement).on('compositionend', handleCompositionEvent);
|
||||
$(document.documentElement).on('compositionstart', () => {
|
||||
if (inInternationalComposition) return;
|
||||
inInternationalComposition = new Promise((resolve) => {
|
||||
$(document.documentElement).one('compositionend', () => {
|
||||
inInternationalComposition = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const topLevel = (n) => {
|
||||
|
@ -3717,7 +3630,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
|
||||
const mods = [];
|
||||
for (let n = firstLine; n <= lastLine; n++) {
|
||||
// var t = '';
|
||||
let level = 0;
|
||||
let togglingOn = true;
|
||||
const listType = /([a-z]+)([0-9]+)/.exec(getLineListType(n));
|
||||
|
@ -3728,7 +3640,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
|||
}
|
||||
|
||||
if (listType) {
|
||||
// t = listType[1];
|
||||
level = Number(listType[2]);
|
||||
}
|
||||
const t = getLineListType(n);
|
||||
|
|
|
@ -195,7 +195,7 @@ const getSelectionRange = () => {
|
|||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (selection.rangeCount > 0) {
|
||||
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
|
||||
return selection.getRangeAt(0);
|
||||
} else {
|
||||
return null;
|
||||
|
|
|
@ -109,14 +109,6 @@ exports.chat = (() => {
|
|||
// correct the time
|
||||
msg.time += this._pad.clientTimeOffset;
|
||||
|
||||
// create the time string
|
||||
let minutes = `${new Date(msg.time).getMinutes()}`;
|
||||
let hours = `${new Date(msg.time).getHours()}`;
|
||||
if (minutes.length === 1) minutes = `0${minutes}`;
|
||||
if (hours.length === 1) hours = `0${hours}`;
|
||||
const timeStr = `${hours}:${minutes}`;
|
||||
|
||||
// create the authorclass
|
||||
if (!msg.userId) {
|
||||
/*
|
||||
* If, for a bug or a database corruption, the message coming from the
|
||||
|
@ -129,24 +121,25 @@ exports.chat = (() => {
|
|||
'Replacing with "unknown". This may be a bug or a database corruption.');
|
||||
}
|
||||
|
||||
const authorClass = `author-${msg.userId.replace(/[^a-y0-9]/g, (c) => {
|
||||
const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
|
||||
if (c === '.') return '-';
|
||||
return `z${c.charCodeAt(0)}z`;
|
||||
})}`;
|
||||
|
||||
const text = padutils.escapeHtmlWithClickableLinks(msg.text, '_blank');
|
||||
|
||||
const authorName = msg.userName == null ? html10n.get('pad.userlist.unnamed')
|
||||
: padutils.escapeHtml(msg.userName);
|
||||
|
||||
// the hook args
|
||||
const ctx = {
|
||||
authorName,
|
||||
authorName: msg.userName != null ? msg.userName : html10n.get('pad.userlist.unnamed'),
|
||||
author: msg.userId,
|
||||
text,
|
||||
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
|
||||
sticky: false,
|
||||
timestamp: msg.time,
|
||||
timeStr,
|
||||
timeStr: (() => {
|
||||
let minutes = `${new Date(msg.time).getMinutes()}`;
|
||||
let hours = `${new Date(msg.time).getHours()}`;
|
||||
if (minutes.length === 1) minutes = `0${minutes}`;
|
||||
if (hours.length === 1) hours = `0${hours}`;
|
||||
return `${hours}:${minutes}`;
|
||||
})(),
|
||||
duration: 4000,
|
||||
};
|
||||
|
||||
|
@ -159,7 +152,7 @@ exports.chat = (() => {
|
|||
// does this message contain this user's name? (is the curretn user mentioned?)
|
||||
const myName = $('#myusernameedit').val();
|
||||
const wasMentioned =
|
||||
text.toLowerCase().indexOf(myName.toLowerCase()) !== -1 && myName !== 'undefined';
|
||||
ctx.text.toLowerCase().indexOf(myName.toLowerCase()) !== -1 && myName !== 'undefined';
|
||||
|
||||
// If the user was mentioned, make the message sticky
|
||||
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
|
||||
|
@ -170,11 +163,23 @@ exports.chat = (() => {
|
|||
|
||||
// Call chat message hook
|
||||
hooks.aCallAll('chatNewMessage', ctx, () => {
|
||||
const html =
|
||||
`<p data-authorId='${msg.userId}' class='${authorClass}'><b>${authorName}:</b>` +
|
||||
`<span class='time ${authorClass}'>${ctx.timeStr}</span> ${ctx.text}</p>`;
|
||||
if (isHistoryAdd) $(html).insertAfter('#chatloadmessagesbutton');
|
||||
else $('#chattext').append(html);
|
||||
const cls = authorClass(ctx.author);
|
||||
const chatMsg = $('<p>')
|
||||
.attr('data-authorId', ctx.author)
|
||||
.addClass(cls)
|
||||
.append($('<b>').text(`${ctx.authorName}:`))
|
||||
.append($('<span>')
|
||||
.addClass('time')
|
||||
.addClass(cls)
|
||||
// Hook functions are trusted to not introduce an XSS vulnerability by adding
|
||||
// unescaped user input to ctx.timeStr.
|
||||
.html(ctx.timeStr))
|
||||
.append(' ')
|
||||
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
|
||||
// introduce an XSS vulnerability by adding unescaped user input.
|
||||
.append($('<div>').html(ctx.text).contents());
|
||||
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
|
||||
else $('#chattext').append(chatMsg);
|
||||
|
||||
// should we increment the counter??
|
||||
if (increment && !isHistoryAdd) {
|
||||
|
@ -185,10 +190,11 @@ exports.chat = (() => {
|
|||
|
||||
if (!chatOpen && ctx.duration > 0) {
|
||||
$.gritter.add({
|
||||
// Note: ctx.authorName and ctx.text are already HTML-escaped.
|
||||
text: $('<p>')
|
||||
.append($('<span>').addClass('author-name').html(ctx.authorName))
|
||||
.append(ctx.text),
|
||||
.append($('<span>').addClass('author-name').text(ctx.authorName))
|
||||
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
|
||||
// to not introduce an XSS vulnerability by adding unescaped user input.
|
||||
.append($('<div>').html(ctx.text).contents()),
|
||||
sticky: ctx.sticky,
|
||||
time: 5000,
|
||||
position: 'bottom',
|
||||
|
|
|
@ -39,22 +39,18 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
pad = _pad; // Inject pad to avoid a circular dependency.
|
||||
|
||||
let rev = serverVars.rev;
|
||||
let state = 'IDLE';
|
||||
let committing = false;
|
||||
let stateMessage;
|
||||
let channelState = 'CONNECTING';
|
||||
let lastCommitTime = 0;
|
||||
let initialStartConnectTime = 0;
|
||||
let commitDelay = 500;
|
||||
|
||||
const userId = initialUserInfo.userId;
|
||||
// var socket;
|
||||
const userSet = {}; // userId -> userInfo
|
||||
userSet[userId] = initialUserInfo;
|
||||
|
||||
const caughtErrors = [];
|
||||
const caughtErrorCatchers = [];
|
||||
const caughtErrorTimes = [];
|
||||
const msgQueue = [];
|
||||
|
||||
let isPendingRevision = false;
|
||||
|
||||
const callbacks = {
|
||||
|
@ -78,77 +74,49 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
}
|
||||
|
||||
const handleUserChanges = () => {
|
||||
if (editor.getInInternationalComposition()) return;
|
||||
if (editor.getInInternationalComposition()) {
|
||||
// handleUserChanges() will be called again once composition ends so there's no need to set up
|
||||
// a future call before returning.
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if ((!getSocket()) || channelState === 'CONNECTING') {
|
||||
if (channelState === 'CONNECTING' && (((+new Date()) - initialStartConnectTime) > 20000)) {
|
||||
if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
|
||||
setChannelState('DISCONNECTED', 'initsocketfail');
|
||||
} else {
|
||||
// check again in a bit
|
||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 1000);
|
||||
setTimeout(handleUserChanges, 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const t = (+new Date());
|
||||
|
||||
if (state !== 'IDLE') {
|
||||
if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 20000) {
|
||||
if (committing) {
|
||||
if (now - lastCommitTime > 20000) {
|
||||
// a commit is taking too long
|
||||
setChannelState('DISCONNECTED', 'slowcommit');
|
||||
} else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
|
||||
} else if (now - lastCommitTime > 5000) {
|
||||
callbacks.onConnectionTrouble('SLOW');
|
||||
} else {
|
||||
// run again in a few seconds, to detect a disconnect
|
||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
||||
setTimeout(handleUserChanges, 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const earliestCommit = lastCommitTime + 500;
|
||||
if (t < earliestCommit) {
|
||||
setTimeout(
|
||||
wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges),
|
||||
earliestCommit - t);
|
||||
const earliestCommit = lastCommitTime + commitDelay;
|
||||
if (now < earliestCommit) {
|
||||
setTimeout(handleUserChanges, earliestCommit - now);
|
||||
return;
|
||||
}
|
||||
|
||||
// apply msgQueue changeset.
|
||||
if (msgQueue.length !== 0) {
|
||||
let msg;
|
||||
while ((msg = msgQueue.shift())) {
|
||||
const newRev = msg.newRev;
|
||||
rev = newRev;
|
||||
if (msg.type === 'ACCEPT_COMMIT') {
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
callCatchingErrors('onInternalAction', () => {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
});
|
||||
callCatchingErrors('onConnectionTrouble', () => {
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
});
|
||||
handleUserChanges();
|
||||
} else if (msg.type === 'NEW_CHANGES') {
|
||||
const changeset = msg.changeset;
|
||||
const author = (msg.author || '');
|
||||
const apool = msg.apool;
|
||||
|
||||
editor.applyChangesToBase(changeset, author, apool);
|
||||
}
|
||||
}
|
||||
if (isPendingRevision) {
|
||||
setIsPendingRevision(false);
|
||||
}
|
||||
}
|
||||
|
||||
let sentMessage = false;
|
||||
// Check if there are any pending revisions to be received from server.
|
||||
// Allow only if there are no pending revisions to be received from server
|
||||
if (!isPendingRevision) {
|
||||
const userChangesData = editor.prepareUserChangeset();
|
||||
if (userChangesData.changeset) {
|
||||
lastCommitTime = t;
|
||||
state = 'COMMITTING';
|
||||
lastCommitTime = now;
|
||||
committing = true;
|
||||
stateMessage = {
|
||||
type: 'USER_CHANGES',
|
||||
baseRev: rev,
|
||||
|
@ -161,20 +129,30 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
}
|
||||
} else {
|
||||
// run again in a few seconds, to check if there was a reconnection attempt
|
||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
||||
setTimeout(handleUserChanges, 3000);
|
||||
}
|
||||
|
||||
if (sentMessage) {
|
||||
// run again in a few seconds, to detect a disconnect
|
||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
||||
setTimeout(handleUserChanges, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const acceptCommit = () => {
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
try {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
} catch (err) { /* intentionally ignored */ }
|
||||
handleUserChanges();
|
||||
};
|
||||
|
||||
const setUpSocket = () => {
|
||||
setChannelState('CONNECTED');
|
||||
doDeferredActions();
|
||||
|
||||
initialStartConnectTime = +new Date();
|
||||
initialStartConnectTime = Date.now();
|
||||
};
|
||||
|
||||
const sendMessage = (msg) => {
|
||||
|
@ -186,24 +164,20 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
});
|
||||
};
|
||||
|
||||
const wrapRecordingErrors = (catcher, func) => function (...args) {
|
||||
try {
|
||||
return func.call(this, ...args);
|
||||
} catch (e) {
|
||||
caughtErrors.push(e);
|
||||
caughtErrorCatchers.push(catcher);
|
||||
caughtErrorTimes.push(+new Date());
|
||||
// console.dir({catcher: catcher, e: e});
|
||||
throw e;
|
||||
const serverMessageTaskQueue = new class {
|
||||
constructor() {
|
||||
this._promiseChain = Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const callCatchingErrors = (catcher, func) => {
|
||||
try {
|
||||
wrapRecordingErrors(catcher, func)();
|
||||
} catch (e) { /* absorb*/
|
||||
async enqueue(fn) {
|
||||
const taskPromise = this._promiseChain.then(fn);
|
||||
// Use .catch() to prevent rejections from halting the queue.
|
||||
this._promiseChain = taskPromise.catch(() => {});
|
||||
// Do NOT do `return await this._promiseChain;` because the caller would not see an error if
|
||||
// fn() throws/rejects (due to the .catch() added above).
|
||||
return await taskPromise;
|
||||
}
|
||||
};
|
||||
}();
|
||||
|
||||
const handleMessageFromServer = (evt) => {
|
||||
if (!getSocket()) return;
|
||||
|
@ -213,117 +187,61 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
const msg = wrapper.data;
|
||||
|
||||
if (msg.type === 'NEW_CHANGES') {
|
||||
const newRev = msg.newRev;
|
||||
const changeset = msg.changeset;
|
||||
const author = (msg.author || '');
|
||||
const apool = msg.apool;
|
||||
|
||||
// When inInternationalComposition, msg pushed msgQueue.
|
||||
if (msgQueue.length > 0 || editor.getInInternationalComposition()) {
|
||||
const oldRev = msgQueue.length > 0 ? msgQueue[msgQueue.length - 1].newRev : rev;
|
||||
if (newRev !== (oldRev + 1)) {
|
||||
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${oldRev + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
||||
return;
|
||||
}
|
||||
msgQueue.push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
serverMessageTaskQueue.enqueue(async () => {
|
||||
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
|
||||
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
|
||||
// currently composing a character then execution will continue without error.
|
||||
// * We assume that it is not possible for a new 'compositionstart' event to fire after
|
||||
// the `await` but before the next line of code after the `await` (or, if it is
|
||||
// possible, that the chances are so small or the consequences so minor that it's not
|
||||
// worth addressing).
|
||||
await editor.getInInternationalComposition();
|
||||
const {newRev, changeset, author = '', apool} = msg;
|
||||
if (newRev !== (rev + 1)) {
|
||||
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
||||
return;
|
||||
}
|
||||
rev = newRev;
|
||||
|
||||
editor.applyChangesToBase(changeset, author, apool);
|
||||
});
|
||||
} else if (msg.type === 'ACCEPT_COMMIT') {
|
||||
serverMessageTaskQueue.enqueue(() => {
|
||||
const newRev = msg.newRev;
|
||||
if (msgQueue.length > 0) {
|
||||
if (newRev !== (msgQueue[msgQueue.length - 1].newRev + 1)) {
|
||||
window.console.warn('bad message revision on ACCEPT_COMMIT: ' +
|
||||
`${newRev} not ${msgQueue[msgQueue.length - 1][0] + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||
return;
|
||||
}
|
||||
msgQueue.push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRev !== (rev + 1)) {
|
||||
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||
return;
|
||||
}
|
||||
rev = newRev;
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
callCatchingErrors('onInternalAction', () => {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
acceptCommit();
|
||||
});
|
||||
callCatchingErrors('onConnectionTrouble', () => {
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
});
|
||||
handleUserChanges();
|
||||
} else if (msg.type === 'CLIENT_RECONNECT') {
|
||||
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
|
||||
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
|
||||
serverMessageTaskQueue.enqueue(() => {
|
||||
if (msg.noChanges) {
|
||||
// If no revisions are pending, just make everything normal
|
||||
setIsPendingRevision(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const headRev = msg.headRev;
|
||||
const newRev = msg.newRev;
|
||||
const changeset = msg.changeset;
|
||||
const author = (msg.author || '');
|
||||
const apool = msg.apool;
|
||||
|
||||
if (msgQueue.length > 0) {
|
||||
if (newRev !== (msgQueue[msgQueue.length - 1].newRev + 1)) {
|
||||
window.console.warn('bad message revision on CLIENT_RECONNECT: ' +
|
||||
`${newRev} not ${msgQueue[msgQueue.length - 1][0] + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||
return;
|
||||
}
|
||||
msg.type = 'NEW_CHANGES';
|
||||
msgQueue.push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const {headRev, newRev, changeset, author = '', apool} = msg;
|
||||
if (newRev !== (rev + 1)) {
|
||||
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
|
||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||
return;
|
||||
}
|
||||
|
||||
rev = newRev;
|
||||
if (author === pad.getUserId()) {
|
||||
editor.applyPreparedChangesetToBase();
|
||||
setStateIdle();
|
||||
callCatchingErrors('onInternalAction', () => {
|
||||
callbacks.onInternalAction('commitAcceptedByServer');
|
||||
});
|
||||
callCatchingErrors('onConnectionTrouble', () => {
|
||||
callbacks.onConnectionTrouble('OK');
|
||||
});
|
||||
handleUserChanges();
|
||||
acceptCommit();
|
||||
} else {
|
||||
editor.applyChangesToBase(changeset, author, apool);
|
||||
}
|
||||
|
||||
if (newRev === headRev) {
|
||||
// Once we have applied all pending revisions, make everything normal
|
||||
setIsPendingRevision(false);
|
||||
}
|
||||
} else if (msg.type === 'NO_COMMIT_PENDING') {
|
||||
if (state === 'COMMITTING') {
|
||||
// server missed our commit message; abort that commit
|
||||
setStateIdle();
|
||||
handleUserChanges();
|
||||
}
|
||||
});
|
||||
} else if (msg.type === 'USER_NEWINFO') {
|
||||
const userInfo = msg.userInfo;
|
||||
const id = userInfo.userId;
|
||||
|
@ -496,7 +414,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
const obj = {};
|
||||
obj.userInfo = userSet[userId];
|
||||
obj.baseRev = rev;
|
||||
if (state === 'COMMITTING' && stateMessage) {
|
||||
if (committing && stateMessage) {
|
||||
obj.committedChangeset = stateMessage.changeset;
|
||||
obj.committedChangesetAPool = stateMessage.apool;
|
||||
editor.applyPreparedChangesetToBase();
|
||||
|
@ -510,7 +428,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
};
|
||||
|
||||
const setStateIdle = () => {
|
||||
state = 'IDLE';
|
||||
committing = false;
|
||||
callbacks.onInternalAction('newlyIdle');
|
||||
schedulePerhapsCallIdleFuncs();
|
||||
};
|
||||
|
@ -528,7 +446,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
|
||||
const schedulePerhapsCallIdleFuncs = () => {
|
||||
setTimeout(() => {
|
||||
if (state === 'IDLE') {
|
||||
if (!committing) {
|
||||
while (idleFuncs.length > 0) {
|
||||
const f = idleFuncs.shift();
|
||||
f();
|
||||
|
@ -571,6 +489,8 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
setChannelState,
|
||||
setStateIdle,
|
||||
setIsPendingRevision,
|
||||
set commitDelay(ms) { commitDelay = ms; },
|
||||
get commitDelay() { return commitDelay; },
|
||||
};
|
||||
|
||||
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
|
||||
|
@ -578,8 +498,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
|||
|
||||
editor.setProperty('userAuthor', userId);
|
||||
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
|
||||
editor.setUserChangeNotificationCallback(
|
||||
wrapRecordingErrors('handleUserChanges', handleUserChanges));
|
||||
editor.setUserChangeNotificationCallback(handleUserChanges);
|
||||
|
||||
setUpSocket();
|
||||
return self;
|
||||
|
|
|
@ -56,7 +56,7 @@ const getAttribute = (n, a) => {
|
|||
return null;
|
||||
};
|
||||
// supportedElems are Supported natively within Etherpad and don't require a plugin
|
||||
const supportedElems = [
|
||||
const supportedElems = new Set([
|
||||
'author',
|
||||
'b',
|
||||
'bold',
|
||||
|
@ -76,7 +76,7 @@ const supportedElems = [
|
|||
'span',
|
||||
'u',
|
||||
'ul',
|
||||
];
|
||||
]);
|
||||
|
||||
const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => {
|
||||
const _blockElems = {
|
||||
|
@ -88,7 +88,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
|||
|
||||
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
||||
_blockElems[element] = 1;
|
||||
supportedElems.push(element);
|
||||
supportedElems.add(element);
|
||||
});
|
||||
|
||||
const isBlockElement = (n) => !!_blockElems[tagName(n) || ''];
|
||||
|
@ -318,6 +318,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
|||
cc.incrementAttrib(state, na);
|
||||
};
|
||||
cc.collectContent = function (node, state) {
|
||||
let unsupportedElements = null;
|
||||
if (!state) {
|
||||
state = {
|
||||
flags: { /* name -> nesting counter*/
|
||||
|
@ -333,16 +334,15 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
|||
'list': 'bullet1',
|
||||
*/
|
||||
},
|
||||
unsupportedElements: new Set(),
|
||||
};
|
||||
unsupportedElements = state.unsupportedElements;
|
||||
}
|
||||
const localAttribs = state.localAttribs;
|
||||
state.localAttribs = null;
|
||||
const isBlock = isBlockElement(node);
|
||||
if (!isBlock && node.name && (node.name !== 'body')) {
|
||||
if (supportedElems.indexOf(node.name) === -1) {
|
||||
console.warn('Plugin missing: ' +
|
||||
`You might want to install a plugin to support this node name: ${node.name}`);
|
||||
}
|
||||
if (!supportedElems.has(node.name)) state.unsupportedElements.add(node.name);
|
||||
}
|
||||
const isEmpty = _isEmpty(node, state);
|
||||
if (isBlock) _ensureColumnZero(state);
|
||||
|
@ -621,6 +621,10 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
|||
}
|
||||
}
|
||||
state.localAttribs = localAttribs;
|
||||
if (unsupportedElements && unsupportedElements.size) {
|
||||
console.warn('Ignoring unsupported elements (you might want to install a plugin): ' +
|
||||
`${[...unsupportedElements].join(', ')}`);
|
||||
}
|
||||
};
|
||||
// can pass a falsy value for end of doc
|
||||
cc.notifyNextNode = (node) => {
|
||||
|
|
|
@ -218,7 +218,14 @@ const sendClientReady = (isReconnect, messageType) => {
|
|||
};
|
||||
|
||||
const handshake = () => {
|
||||
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
|
||||
// unescape neccesary due to Safari and Opera interpretation of spaces
|
||||
padId = decodeURIComponent(padId);
|
||||
|
||||
// padId is used here for sharding / scaling. We prefix the padId with padId: so it's clear
|
||||
// to the proxy/gateway/whatever that this is a pad connection and should be treated as such
|
||||
socket = pad.socket = socketio.connect(exports.baseURL, '/', {
|
||||
query: {padId},
|
||||
reconnectionAttempts: 5,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
|
|
|
@ -21,15 +21,15 @@ const Cookies = require('./pad_utils').Cookies;
|
|||
exports.padcookie = new class {
|
||||
constructor() {
|
||||
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
|
||||
}
|
||||
|
||||
init() {
|
||||
const prefs = this.readPrefs_() || {};
|
||||
delete prefs.userId;
|
||||
delete prefs.name;
|
||||
delete prefs.colorId;
|
||||
this.prefs_ = prefs;
|
||||
this.savePrefs_();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.writePrefs_(prefs);
|
||||
// Re-read the saved cookie to test if cookies are enabled.
|
||||
if (this.readPrefs_() == null) {
|
||||
$.gritter.add({
|
||||
title: 'Error',
|
||||
|
@ -50,16 +50,21 @@ exports.padcookie = new class {
|
|||
}
|
||||
}
|
||||
|
||||
savePrefs_() {
|
||||
Cookies.set(this.cookieName_, JSON.stringify(this.prefs_), {expires: 365 * 100});
|
||||
writePrefs_(prefs) {
|
||||
Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100});
|
||||
}
|
||||
|
||||
getPref(prefName) {
|
||||
return this.prefs_[prefName];
|
||||
return this.readPrefs_()[prefName];
|
||||
}
|
||||
|
||||
setPref(prefName, value) {
|
||||
this.prefs_[prefName] = value;
|
||||
this.savePrefs_();
|
||||
const prefs = this.readPrefs_();
|
||||
prefs[prefName] = value;
|
||||
this.writePrefs_(prefs);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.writePrefs_({});
|
||||
}
|
||||
}();
|
||||
|
|
|
@ -311,6 +311,9 @@ padutils.setupGlobalExceptionHandler = () => {
|
|||
} else {
|
||||
throw new Error(`unknown event: ${e.toString()}`);
|
||||
}
|
||||
if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) {
|
||||
msg = `${err.name}: ${msg}`;
|
||||
}
|
||||
const errorId = randomString(20);
|
||||
|
||||
let msgAlreadyVisible = false;
|
||||
|
@ -328,12 +331,12 @@ padutils.setupGlobalExceptionHandler = () => {
|
|||
$('<p>')
|
||||
.text('If the problem persists, please send this error message to your webmaster:'),
|
||||
$('<div>').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em')
|
||||
.append($('<b>').addClass('error-msg').text(msg)).append($('<br>'))
|
||||
.append(txt(`at ${url} at line ${linenumber}`)).append($('<br>'))
|
||||
.append(txt(`ErrorId: ${errorId}`)).append($('<br>'))
|
||||
.append(txt(type)).append($('<br>'))
|
||||
.append(txt(`URL: ${window.location.href}`)).append($('<br>'))
|
||||
.append(txt(`UserAgent: ${navigator.userAgent}`)).append($('<br>'))
|
||||
.append($('<b>').addClass('error-msg').text(msg)).append($('<br>'))
|
||||
.append(txt(`at ${url} at line ${linenumber}`)).append($('<br>')),
|
||||
.append(txt(`UserAgent: ${navigator.userAgent}`)).append($('<br>')),
|
||||
];
|
||||
|
||||
$.gritter.add({
|
||||
|
|
|
@ -22,66 +22,57 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const Ace2Common = require('./ace2_common');
|
||||
const _ = require('./underscore');
|
||||
const _entryWidth = (e) => (e && e.width) || 0;
|
||||
|
||||
const noop = Ace2Common.noop;
|
||||
|
||||
function SkipList() {
|
||||
let PROFILER = window.PROFILER;
|
||||
if (!PROFILER) {
|
||||
PROFILER = () => ({
|
||||
start: noop,
|
||||
mark: noop,
|
||||
literal: noop,
|
||||
end: noop,
|
||||
cancel: noop,
|
||||
});
|
||||
class Node {
|
||||
constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) {
|
||||
this.key = entry != null ? entry.key : null;
|
||||
this.entry = entry;
|
||||
this.levels = levels;
|
||||
this.upPtrs = Array(levels).fill(null);
|
||||
this.downPtrs = Array(levels).fill(null);
|
||||
this.downSkips = Array(levels).fill(downSkips);
|
||||
this.downSkipWidths = Array(levels).fill(downSkipWidths);
|
||||
}
|
||||
|
||||
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N
|
||||
const start = {
|
||||
key: null,
|
||||
levels: 1,
|
||||
upPtrs: [null],
|
||||
downPtrs: [null],
|
||||
downSkips: [1],
|
||||
downSkipWidths: [0],
|
||||
};
|
||||
const end = {
|
||||
key: null,
|
||||
levels: 1,
|
||||
upPtrs: [null],
|
||||
downPtrs: [null],
|
||||
downSkips: [null],
|
||||
downSkipWidths: [null],
|
||||
};
|
||||
let numNodes = 0;
|
||||
let totalWidth = 0;
|
||||
const keyToNodeMap = {};
|
||||
start.downPtrs[0] = end;
|
||||
end.upPtrs[0] = start;
|
||||
// a "point" object at location x allows modifications immediately after the first
|
||||
// x elements of the skiplist, such as multiple inserts or deletes.
|
||||
// After an insert or delete using point P, the point is still valid and points
|
||||
// to the same index in the skiplist. Other operations with other points invalidate
|
||||
// this point.
|
||||
propagateWidthChange() {
|
||||
const oldWidth = this.downSkipWidths[0];
|
||||
const newWidth = _entryWidth(this.entry);
|
||||
const widthChange = newWidth - oldWidth;
|
||||
let n = this;
|
||||
let lvl = 0;
|
||||
while (lvl < n.levels) {
|
||||
n.downSkipWidths[lvl] += widthChange;
|
||||
lvl++;
|
||||
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
|
||||
n = n.upPtrs[lvl - 1];
|
||||
}
|
||||
}
|
||||
return widthChange;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const _getPoint = (targetLoc) => {
|
||||
const numLevels = start.levels;
|
||||
// A "point" object at index x allows modifications immediately after the first x elements of the
|
||||
// skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point
|
||||
// is still valid and points to the same index in the skiplist. Other operations with other points
|
||||
// invalidate this point.
|
||||
class Point {
|
||||
constructor(skipList, loc) {
|
||||
this._skipList = skipList;
|
||||
this.loc = loc;
|
||||
const numLevels = this._skipList._start.levels;
|
||||
let lvl = numLevels - 1;
|
||||
let i = -1;
|
||||
let ws = 0;
|
||||
const nodes = new Array(numLevels);
|
||||
const idxs = new Array(numLevels);
|
||||
const widthSkips = new Array(numLevels);
|
||||
nodes[lvl] = start;
|
||||
nodes[lvl] = this._skipList._start;
|
||||
idxs[lvl] = -1;
|
||||
widthSkips[lvl] = 0;
|
||||
while (lvl >= 0) {
|
||||
let n = nodes[lvl];
|
||||
while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < targetLoc)) {
|
||||
while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) {
|
||||
i += n.downSkips[lvl];
|
||||
ws += n.downSkipWidths[lvl];
|
||||
n = n.downPtrs[lvl];
|
||||
|
@ -94,50 +85,27 @@ function SkipList() {
|
|||
nodes[lvl] = n;
|
||||
}
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
idxs,
|
||||
loc: targetLoc,
|
||||
widthSkips,
|
||||
toString: () => `getPoint(${targetLoc})`,
|
||||
};
|
||||
};
|
||||
|
||||
const _getNodeAtOffset = (targetOffset) => {
|
||||
let i = 0;
|
||||
let n = start;
|
||||
let lvl = start.levels - 1;
|
||||
while (lvl >= 0 && n.downPtrs[lvl]) {
|
||||
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
|
||||
i += n.downSkipWidths[lvl];
|
||||
n = n.downPtrs[lvl];
|
||||
this.idxs = idxs;
|
||||
this.nodes = nodes;
|
||||
this.widthSkips = widthSkips;
|
||||
}
|
||||
lvl--;
|
||||
|
||||
toString() {
|
||||
return `Point(${this.loc})`;
|
||||
}
|
||||
if (n === start) return (start.downPtrs[0] || null);
|
||||
else if (n === end) return (targetOffset === totalWidth ? (end.upPtrs[0] || null) : null);
|
||||
return n;
|
||||
};
|
||||
|
||||
const _entryWidth = (e) => (e && e.width) || 0;
|
||||
insert(entry) {
|
||||
if (entry.key == null) throw new Error('entry.key must not be null');
|
||||
if (this._skipList.containsKey(entry.key)) {
|
||||
throw new Error(`an entry with key ${entry.key} already exists`);
|
||||
}
|
||||
|
||||
const _insertKeyAtPoint = (point, newKey, entry) => {
|
||||
const p = PROFILER('insertKey', false); // eslint-disable-line new-cap
|
||||
const newNode = {
|
||||
key: newKey,
|
||||
levels: 0,
|
||||
upPtrs: [],
|
||||
downPtrs: [],
|
||||
downSkips: [],
|
||||
downSkipWidths: [],
|
||||
};
|
||||
p.mark('donealloc');
|
||||
const pNodes = point.nodes;
|
||||
const pIdxs = point.idxs;
|
||||
const pLoc = point.loc;
|
||||
const widthLoc = point.widthSkips[0] + point.nodes[0].downSkipWidths[0];
|
||||
const newNode = new Node(entry);
|
||||
const pNodes = this.nodes;
|
||||
const pIdxs = this.idxs;
|
||||
const pLoc = this.loc;
|
||||
const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];
|
||||
const newWidth = _entryWidth(entry);
|
||||
p.mark('loop1');
|
||||
|
||||
// The new node will have at least level 1
|
||||
// With a proability of 0.01^(n-1) the nodes level will be >= n
|
||||
|
@ -145,17 +113,17 @@ function SkipList() {
|
|||
const lvl = newNode.levels;
|
||||
newNode.levels++;
|
||||
if (lvl === pNodes.length) {
|
||||
// assume we have just passed the end of point.nodes, and reached one level greater
|
||||
// assume we have just passed the end of this.nodes, and reached one level greater
|
||||
// than the skiplist currently supports
|
||||
pNodes[lvl] = start;
|
||||
pNodes[lvl] = this._skipList._start;
|
||||
pIdxs[lvl] = -1;
|
||||
start.levels++;
|
||||
end.levels++;
|
||||
start.downPtrs[lvl] = end;
|
||||
end.upPtrs[lvl] = start;
|
||||
start.downSkips[lvl] = numNodes + 1;
|
||||
start.downSkipWidths[lvl] = totalWidth;
|
||||
point.widthSkips[lvl] = 0;
|
||||
this._skipList._start.levels++;
|
||||
this._skipList._end.levels++;
|
||||
this._skipList._start.downPtrs[lvl] = this._skipList._end;
|
||||
this._skipList._end.upPtrs[lvl] = this._skipList._start;
|
||||
this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1;
|
||||
this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth;
|
||||
this.widthSkips[lvl] = 0;
|
||||
}
|
||||
const me = newNode;
|
||||
const up = pNodes[lvl];
|
||||
|
@ -168,31 +136,24 @@ function SkipList() {
|
|||
me.upPtrs[lvl] = up;
|
||||
me.downPtrs[lvl] = down;
|
||||
down.upPtrs[lvl] = me;
|
||||
const widthSkip1 = widthLoc - point.widthSkips[lvl];
|
||||
const widthSkip1 = widthLoc - this.widthSkips[lvl];
|
||||
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
|
||||
up.downSkipWidths[lvl] = widthSkip1;
|
||||
me.downSkipWidths[lvl] = widthSkip2;
|
||||
}
|
||||
p.mark('loop2');
|
||||
p.literal(pNodes.length, 'PNL');
|
||||
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
|
||||
const up = pNodes[lvl];
|
||||
up.downSkips[lvl]++;
|
||||
up.downSkipWidths[lvl] += newWidth;
|
||||
}
|
||||
p.mark('map');
|
||||
keyToNodeMap[`$KEY$${newKey}`] = newNode;
|
||||
numNodes++;
|
||||
totalWidth += newWidth;
|
||||
p.end();
|
||||
};
|
||||
this._skipList._keyToNodeMap.set(newNode.key, newNode);
|
||||
this._skipList._totalWidth += newWidth;
|
||||
}
|
||||
|
||||
const _getNodeAtPoint = (point) => point.nodes[0].downPtrs[0];
|
||||
|
||||
const _deleteKeyAtPoint = (point) => {
|
||||
const elem = point.nodes[0].downPtrs[0];
|
||||
delete() {
|
||||
const elem = this.nodes[0].downPtrs[0];
|
||||
const elemWidth = _entryWidth(elem.entry);
|
||||
for (let i = 0; i < point.nodes.length; i++) {
|
||||
for (let i = 0; i < this.nodes.length; i++) {
|
||||
if (i < elem.levels) {
|
||||
const up = elem.upPtrs[i];
|
||||
const down = elem.downPtrs[i];
|
||||
|
@ -203,59 +164,76 @@ function SkipList() {
|
|||
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
|
||||
up.downSkipWidths[i] = totalWidthSkip;
|
||||
} else {
|
||||
const up = point.nodes[i];
|
||||
const up = this.nodes[i];
|
||||
up.downSkips[i]--;
|
||||
up.downSkipWidths[i] -= elemWidth;
|
||||
}
|
||||
}
|
||||
delete keyToNodeMap[`$KEY$${elem.key}`];
|
||||
numNodes--;
|
||||
totalWidth -= elemWidth;
|
||||
};
|
||||
this._skipList._keyToNodeMap.delete(elem.key);
|
||||
this._skipList._totalWidth -= elemWidth;
|
||||
}
|
||||
|
||||
const _propagateWidthChange = (node) => {
|
||||
const oldWidth = node.downSkipWidths[0];
|
||||
const newWidth = _entryWidth(node.entry);
|
||||
const widthChange = newWidth - oldWidth;
|
||||
let n = node;
|
||||
let lvl = 0;
|
||||
while (lvl < n.levels) {
|
||||
n.downSkipWidths[lvl] += widthChange;
|
||||
lvl++;
|
||||
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
|
||||
n = n.upPtrs[lvl - 1];
|
||||
getNode() {
|
||||
return this.nodes[0].downPtrs[0];
|
||||
}
|
||||
}
|
||||
totalWidth += widthChange;
|
||||
};
|
||||
|
||||
const _getNodeIndex = (node, byWidth) => {
|
||||
/**
|
||||
* The skip-list contains "entries", JavaScript objects that each must have a unique "key"
|
||||
* property that is a string.
|
||||
*/
|
||||
class SkipList {
|
||||
constructor() {
|
||||
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N
|
||||
this._start = new Node(null, 1);
|
||||
this._end = new Node(null, 1, null, null);
|
||||
this._totalWidth = 0;
|
||||
this._keyToNodeMap = new Map();
|
||||
this._start.downPtrs[0] = this._end;
|
||||
this._end.upPtrs[0] = this._start;
|
||||
}
|
||||
|
||||
_getNodeAtOffset(targetOffset) {
|
||||
let i = 0;
|
||||
let n = this._start;
|
||||
let lvl = this._start.levels - 1;
|
||||
while (lvl >= 0 && n.downPtrs[lvl]) {
|
||||
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
|
||||
i += n.downSkipWidths[lvl];
|
||||
n = n.downPtrs[lvl];
|
||||
}
|
||||
lvl--;
|
||||
}
|
||||
if (n === this._start) return (this._start.downPtrs[0] || null);
|
||||
if (n === this._end) {
|
||||
return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
_getNodeIndex(node, byWidth) {
|
||||
let dist = (byWidth ? 0 : -1);
|
||||
let n = node;
|
||||
while (n !== start) {
|
||||
while (n !== this._start) {
|
||||
const lvl = n.levels - 1;
|
||||
n = n.upPtrs[lvl];
|
||||
if (byWidth) dist += n.downSkipWidths[lvl];
|
||||
else dist += n.downSkips[lvl];
|
||||
}
|
||||
return dist;
|
||||
};
|
||||
|
||||
const _getNodeByKey = (key) => keyToNodeMap[`$KEY$${key}`];
|
||||
}
|
||||
|
||||
// Returns index of first entry such that entryFunc(entry) is truthy,
|
||||
// or length() if no such entry. Assumes all falsy entries come before
|
||||
// all truthy entries.
|
||||
|
||||
|
||||
const _search = (entryFunc) => {
|
||||
let low = start;
|
||||
let lvl = start.levels - 1;
|
||||
search(entryFunc) {
|
||||
let low = this._start;
|
||||
let lvl = this._start.levels - 1;
|
||||
let lowIndex = -1;
|
||||
|
||||
const f = (node) => {
|
||||
if (node === start) return false;
|
||||
else if (node === end) return true;
|
||||
if (node === this._start) return false;
|
||||
else if (node === this._end) return true;
|
||||
else return entryFunc(node.entry);
|
||||
};
|
||||
|
||||
|
@ -269,97 +247,85 @@ function SkipList() {
|
|||
lvl--;
|
||||
}
|
||||
return lowIndex + 1;
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
The skip-list contains "entries", JavaScript objects that each must have a unique "key" property
|
||||
that is a string.
|
||||
*/
|
||||
const self = this;
|
||||
_.extend(this, {
|
||||
length: () => numNodes,
|
||||
atIndex: (i) => {
|
||||
length() { return this._keyToNodeMap.size; }
|
||||
|
||||
atIndex(i) {
|
||||
if (i < 0) console.warn(`atIndex(${i})`);
|
||||
if (i >= numNodes) console.warn(`atIndex(${i}>=${numNodes})`);
|
||||
return _getNodeAtPoint(_getPoint(i)).entry;
|
||||
},
|
||||
if (i >= this._keyToNodeMap.size) console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
|
||||
return (new Point(this, i)).getNode().entry;
|
||||
}
|
||||
|
||||
// differs from Array.splice() in that new elements are in an array, not varargs
|
||||
splice: (start, deleteCount, newEntryArray) => {
|
||||
splice(start, deleteCount, newEntryArray) {
|
||||
if (start < 0) console.warn(`splice(${start}, ...)`);
|
||||
if (start + deleteCount > numNodes) {
|
||||
console.warn(`splice(${start}, ${deleteCount}, ...), N=${numNodes}`);
|
||||
console.warn('%s %s %s', typeof start, typeof deleteCount, typeof numNodes);
|
||||
if (start + deleteCount > this._keyToNodeMap.size) {
|
||||
console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`);
|
||||
console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size);
|
||||
console.trace();
|
||||
}
|
||||
|
||||
if (!newEntryArray) newEntryArray = [];
|
||||
const pt = _getPoint(start);
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
_deleteKeyAtPoint(pt);
|
||||
}
|
||||
const pt = new Point(this, start);
|
||||
for (let i = 0; i < deleteCount; i++) pt.delete();
|
||||
for (let i = (newEntryArray.length - 1); i >= 0; i--) {
|
||||
const entry = newEntryArray[i];
|
||||
_insertKeyAtPoint(pt, entry.key, entry);
|
||||
const node = _getNodeByKey(entry.key);
|
||||
node.entry = entry;
|
||||
pt.insert(entry);
|
||||
}
|
||||
},
|
||||
next: (entry) => _getNodeByKey(entry.key).downPtrs[0].entry || null,
|
||||
prev: (entry) => _getNodeByKey(entry.key).upPtrs[0].entry || null,
|
||||
push: (entry) => {
|
||||
self.splice(numNodes, 0, [entry]);
|
||||
},
|
||||
slice: (start, end) => {
|
||||
}
|
||||
|
||||
next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; }
|
||||
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; }
|
||||
push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); }
|
||||
|
||||
slice(start, end) {
|
||||
// act like Array.slice()
|
||||
if (start === undefined) start = 0;
|
||||
else if (start < 0) start += numNodes;
|
||||
if (end === undefined) end = numNodes;
|
||||
else if (end < 0) end += numNodes;
|
||||
else if (start < 0) start += this._keyToNodeMap.size;
|
||||
if (end === undefined) end = this._keyToNodeMap.size;
|
||||
else if (end < 0) end += this._keyToNodeMap.size;
|
||||
|
||||
if (start < 0) start = 0;
|
||||
if (start > numNodes) start = numNodes;
|
||||
if (start > this._keyToNodeMap.size) start = this._keyToNodeMap.size;
|
||||
if (end < 0) end = 0;
|
||||
if (end > numNodes) end = numNodes;
|
||||
if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size;
|
||||
|
||||
window.dmesg(String([start, end, numNodes]));
|
||||
window.dmesg(String([start, end, this._keyToNodeMap.size]));
|
||||
if (end <= start) return [];
|
||||
let n = self.atIndex(start);
|
||||
let n = this.atIndex(start);
|
||||
const array = [n];
|
||||
for (let i = 1; i < (end - start); i++) {
|
||||
n = self.next(n);
|
||||
n = this.next(n);
|
||||
array.push(n);
|
||||
}
|
||||
return array;
|
||||
},
|
||||
atKey: (key) => _getNodeByKey(key).entry,
|
||||
indexOfKey: (key) => _getNodeIndex(_getNodeByKey(key)),
|
||||
indexOfEntry: (entry) => self.indexOfKey(entry.key),
|
||||
containsKey: (key) => !!(_getNodeByKey(key)),
|
||||
}
|
||||
|
||||
atKey(key) { return this._keyToNodeMap.get(key).entry; }
|
||||
indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); }
|
||||
indexOfEntry(entry) { return this.indexOfKey(entry.key); }
|
||||
containsKey(key) { return this._keyToNodeMap.has(key); }
|
||||
// gets the last entry starting at or before the offset
|
||||
atOffset: (offset) => _getNodeAtOffset(offset).entry,
|
||||
keyAtOffset: (offset) => self.atOffset(offset).key,
|
||||
offsetOfKey: (key) => _getNodeIndex(_getNodeByKey(key), true),
|
||||
offsetOfEntry: (entry) => self.offsetOfKey(entry.key),
|
||||
setEntryWidth: (entry, width) => {
|
||||
atOffset(offset) { return this._getNodeAtOffset(offset).entry; }
|
||||
keyAtOffset(offset) { return this.atOffset(offset).key; }
|
||||
offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); }
|
||||
offsetOfEntry(entry) { return this.offsetOfKey(entry.key); }
|
||||
setEntryWidth(entry, width) {
|
||||
entry.width = width;
|
||||
_propagateWidthChange(_getNodeByKey(entry.key));
|
||||
},
|
||||
totalWidth: () => totalWidth,
|
||||
offsetOfIndex: (i) => {
|
||||
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
|
||||
}
|
||||
totalWidth() { return this._totalWidth; }
|
||||
offsetOfIndex(i) {
|
||||
if (i < 0) return 0;
|
||||
if (i >= numNodes) return totalWidth;
|
||||
return self.offsetOfEntry(self.atIndex(i));
|
||||
},
|
||||
indexOfOffset: (offset) => {
|
||||
if (i >= this._keyToNodeMap.size) return this._totalWidth;
|
||||
return this.offsetOfEntry(this.atIndex(i));
|
||||
}
|
||||
indexOfOffset(offset) {
|
||||
if (offset <= 0) return 0;
|
||||
if (offset >= totalWidth) return numNodes;
|
||||
return self.indexOfEntry(self.atOffset(offset));
|
||||
},
|
||||
search: (entryFunc) => _search(entryFunc),
|
||||
// debugToString: _debugToString,
|
||||
debugGetPoint: _getPoint,
|
||||
debugDepth: () => start.levels,
|
||||
});
|
||||
if (offset >= this._totalWidth) return this._keyToNodeMap.size;
|
||||
return this.indexOfEntry(this.atOffset(offset));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SkipList;
|
||||
|
|
|
@ -52,7 +52,7 @@ const init = () => {
|
|||
Cookies.set('token', token, {expires: 60});
|
||||
}
|
||||
|
||||
socket = socketio.connect(exports.baseURL);
|
||||
socket = socketio.connect(exports.baseURL, '/', {query: {padId}});
|
||||
|
||||
// send the ready message once we're connected
|
||||
socket.on('connect', () => {
|
||||
|
|
|
@ -23,9 +23,3 @@ button, .btn
|
|||
color: #485365;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.buttonicon:before, [class^="buttonicon-"]:before, [class*=" buttonicon-"]:before {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
||||
<link rel="shortcut icon" href="<%=settings.favicon%>">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
|
||||
<link rel="localizations" type="application/l10n+json" href="locales.json">
|
||||
<script type="text/javascript" src="static/js/vendors/html10n.js?v=<%=settings.randomVersionString%>"></script>
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
||||
<link rel="shortcut icon" href="<%=settings.faviconPad%>">
|
||||
<link rel="shortcut icon" href="../favicon.ico">
|
||||
|
||||
<% e.begin_block("styles"); %>
|
||||
<link href="../static/css/pad.css?v=<%=settings.randomVersionString%>" rel="stylesheet">
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
||||
<link rel="shortcut icon" href="<%=settings.faviconTimeslider%>">
|
||||
<link rel="shortcut icon" href="../../favicon.ico">
|
||||
<% e.begin_block("timesliderStyles"); %>
|
||||
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">
|
||||
<link rel="stylesheet" href="../../static/css/iframe_editor.css?v=<%=settings.randomVersionString%>">
|
||||
|
|
|
@ -55,5 +55,4 @@ describe(__filename, function () {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -109,22 +109,24 @@ describe(__filename, function () {
|
|||
.expect((res) => assert.equal(res.body.data.text, padText.toString()));
|
||||
});
|
||||
|
||||
it('gets read only pad Id and exports the html and text for this pad', async function () {
|
||||
for (const authn of [false, true]) {
|
||||
it(`can export from read-only pad ID, authn ${authn}`, async function () {
|
||||
this.timeout(250);
|
||||
const ro = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
|
||||
.expect(200)
|
||||
settings.requireAuthentication = authn;
|
||||
const get = (ep) => {
|
||||
let req = agent.get(ep);
|
||||
if (authn) req = req.auth('user', 'user-password');
|
||||
return req.expect(200);
|
||||
};
|
||||
const ro = await get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
|
||||
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID));
|
||||
const readOnlyId = JSON.parse(ro.text).data.readOnlyID;
|
||||
|
||||
await agent.get(`/p/${readOnlyId}/export/html`)
|
||||
.expect(200)
|
||||
await get(`/p/${readOnlyId}/export/html`)
|
||||
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
||||
|
||||
await agent.get(`/p/${readOnlyId}/export/txt`)
|
||||
.expect(200)
|
||||
await get(`/p/${readOnlyId}/export/txt`)
|
||||
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
describe('Import/Export tests requiring AbiWord/LibreOffice', function () {
|
||||
this.timeout(10000);
|
||||
|
|
BIN
src/tests/backend/specs/favicon-test-custom.png
Normal file
BIN
src/tests/backend/specs/favicon-test-custom.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 635 B |
BIN
src/tests/backend/specs/favicon-test-skin.png
Normal file
BIN
src/tests/backend/specs/favicon-test-skin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 419 B |
91
src/tests/backend/specs/favicon.js
Normal file
91
src/tests/backend/specs/favicon.js
Normal file
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const path = require('path');
|
||||
const settings = require('../../../node/utils/Settings');
|
||||
const superagent = require('superagent');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent;
|
||||
let backupSettings;
|
||||
let skinDir;
|
||||
let wantCustomIcon;
|
||||
let wantDefaultIcon;
|
||||
let wantSkinIcon;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
wantCustomIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-custom.png'));
|
||||
wantDefaultIcon = await fsp.readFile(path.join(settings.root, 'src', 'static', 'favicon.ico'));
|
||||
wantSkinIcon = await fsp.readFile(path.join(__dirname, 'favicon-test-skin.png'));
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
backupSettings = {...settings};
|
||||
skinDir = await fsp.mkdtemp(path.join(settings.root, 'src', 'static', 'skins', 'test-'));
|
||||
settings.skinName = path.basename(skinDir);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
delete settings.favicon;
|
||||
delete settings.skinName;
|
||||
Object.assign(settings, backupSettings);
|
||||
try {
|
||||
// TODO: The {recursive: true} option wasn't added to fsp.rmdir() until Node.js v12.10.0 so we
|
||||
// can't rely on it until support for Node.js v10 is dropped.
|
||||
await fsp.unlink(path.join(skinDir, 'favicon.ico'));
|
||||
await fsp.rmdir(skinDir, {recursive: true});
|
||||
} catch (err) { /* intentionally ignored */ }
|
||||
});
|
||||
|
||||
it('uses custom favicon if set (relative pathname)', async function () {
|
||||
settings.favicon =
|
||||
path.relative(settings.root, path.join(__dirname, 'favicon-test-custom.png'));
|
||||
assert(!path.isAbsolute(settings.favicon));
|
||||
const {body: gotIcon} = await agent.get('/favicon.ico')
|
||||
.accept('png').buffer(true).parse(superagent.parse.image)
|
||||
.expect(200);
|
||||
assert(gotIcon.equals(wantCustomIcon));
|
||||
});
|
||||
|
||||
it('uses custom favicon if set (absolute pathname)', async function () {
|
||||
settings.favicon = path.join(__dirname, 'favicon-test-custom.png');
|
||||
assert(path.isAbsolute(settings.favicon));
|
||||
const {body: gotIcon} = await agent.get('/favicon.ico')
|
||||
.accept('png').buffer(true).parse(superagent.parse.image)
|
||||
.expect(200);
|
||||
assert(gotIcon.equals(wantCustomIcon));
|
||||
});
|
||||
|
||||
it('falls back if custom favicon is missing', async function () {
|
||||
// The previous default for settings.favicon was 'favicon.ico', so many users will continue to
|
||||
// have that in their settings.json for a long time. There is unlikely to be a favicon at
|
||||
// path.resolve(settings.root, 'favicon.ico'), so this test ensures that 'favicon.ico' won't be
|
||||
// a problem for those users.
|
||||
settings.favicon = 'favicon.ico';
|
||||
const {body: gotIcon} = await agent.get('/favicon.ico')
|
||||
.accept('png').buffer(true).parse(superagent.parse.image)
|
||||
.expect(200);
|
||||
assert(gotIcon.equals(wantDefaultIcon));
|
||||
});
|
||||
|
||||
it('uses skin favicon if present', async function () {
|
||||
await fsp.writeFile(path.join(skinDir, 'favicon.ico'), wantSkinIcon);
|
||||
settings.favicon = null;
|
||||
const {body: gotIcon} = await agent.get('/favicon.ico')
|
||||
.accept('png').buffer(true).parse(superagent.parse.image)
|
||||
.expect(200);
|
||||
assert(gotIcon.equals(wantSkinIcon));
|
||||
});
|
||||
|
||||
it('falls back to default favicon', async function () {
|
||||
settings.favicon = null;
|
||||
const {body: gotIcon} = await agent.get('/favicon.ico')
|
||||
.accept('png').buffer(true).parse(superagent.parse.image)
|
||||
.expect(200);
|
||||
assert(gotIcon.equals(wantDefaultIcon));
|
||||
});
|
||||
});
|
31
src/tests/backend/specs/regression-db.js
Normal file
31
src/tests/backend/specs/regression-db.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
'use strict';
|
||||
|
||||
const AuthorManager = require('../../../node/db/AuthorManager');
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const db = require('../../../node/db/DB');
|
||||
|
||||
describe(__filename, function () {
|
||||
let setBackup;
|
||||
|
||||
before(async function () {
|
||||
await common.init();
|
||||
setBackup = db.set;
|
||||
|
||||
db.set = async (...args) => {
|
||||
// delay db.set
|
||||
await new Promise((resolve) => { setTimeout(() => resolve(), 500); });
|
||||
return await setBackup.call(db, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
db.set = setBackup;
|
||||
});
|
||||
|
||||
it('regression test for missing await in createAuthor (#5000)', async function () {
|
||||
this.timeout(700);
|
||||
const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes.
|
||||
assert(await AuthorManager.doesAuthorExist(authorID));
|
||||
});
|
||||
});
|
96
src/tests/backend/specs/sanitizePathname.js
Normal file
96
src/tests/backend/specs/sanitizePathname.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const path = require('path');
|
||||
const sanitizePathname = require('../../../node/utils/sanitizePathname');
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('absolute paths rejected', function () {
|
||||
const testCases = [
|
||||
['posix', '/'],
|
||||
['posix', '/foo'],
|
||||
['win32', '/'],
|
||||
['win32', '\\'],
|
||||
['win32', 'C:/foo'],
|
||||
['win32', 'C:\\foo'],
|
||||
['win32', 'c:/foo'],
|
||||
['win32', 'c:\\foo'],
|
||||
['win32', '/foo'],
|
||||
['win32', '\\foo'],
|
||||
];
|
||||
for (const [platform, p] of testCases) {
|
||||
it(`${platform} ${p}`, async function () {
|
||||
assert.throws(() => sanitizePathname(p, path[platform]), {message: /absolute path/});
|
||||
});
|
||||
}
|
||||
});
|
||||
describe('directory traversal rejected', function () {
|
||||
const testCases = [
|
||||
['posix', '..'],
|
||||
['posix', '../'],
|
||||
['posix', '../foo'],
|
||||
['posix', 'foo/../..'],
|
||||
['win32', '..'],
|
||||
['win32', '../'],
|
||||
['win32', '..\\'],
|
||||
['win32', '../foo'],
|
||||
['win32', '..\\foo'],
|
||||
['win32', 'foo/../..'],
|
||||
['win32', 'foo\\..\\..'],
|
||||
];
|
||||
for (const [platform, p] of testCases) {
|
||||
it(`${platform} ${p}`, async function () {
|
||||
assert.throws(() => sanitizePathname(p, path[platform]), {message: /travers/});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('accepted paths', function () {
|
||||
const testCases = [
|
||||
['posix', '', '.'],
|
||||
['posix', '.'],
|
||||
['posix', './'],
|
||||
['posix', 'foo'],
|
||||
['posix', 'foo/'],
|
||||
['posix', 'foo/bar/..', 'foo'],
|
||||
['posix', 'foo/bar/../', 'foo/'],
|
||||
['posix', './foo', 'foo'],
|
||||
['posix', 'foo/bar'],
|
||||
['posix', 'foo\\bar'],
|
||||
['posix', '\\foo'],
|
||||
['posix', '..\\foo'],
|
||||
['posix', 'foo/../bar', 'bar'],
|
||||
['posix', 'C:/foo'],
|
||||
['posix', 'C:\\foo'],
|
||||
['win32', '', '.'],
|
||||
['win32', '.'],
|
||||
['win32', './'],
|
||||
['win32', '.\\', './'],
|
||||
['win32', 'foo'],
|
||||
['win32', 'foo/'],
|
||||
['win32', 'foo\\', 'foo/'],
|
||||
['win32', 'foo/bar/..', 'foo'],
|
||||
['win32', 'foo\\bar\\..', 'foo'],
|
||||
['win32', 'foo/bar/../', 'foo/'],
|
||||
['win32', 'foo\\bar\\..\\', 'foo/'],
|
||||
['win32', './foo', 'foo'],
|
||||
['win32', '.\\foo', 'foo'],
|
||||
['win32', 'foo/bar'],
|
||||
['win32', 'foo\\bar', 'foo/bar'],
|
||||
['win32', 'foo/../bar', 'bar'],
|
||||
['win32', 'foo\\..\\bar', 'bar'],
|
||||
['win32', 'foo/..\\bar', 'bar'],
|
||||
['win32', 'foo\\../bar', 'bar'],
|
||||
];
|
||||
for (const [platform, p, tcWant] of testCases) {
|
||||
const want = tcWant == null ? p : tcWant;
|
||||
it(`${platform} ${p || '<empty string>'} -> ${want}`, async function () {
|
||||
assert.equal(sanitizePathname(p, path[platform]), want);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('default path API', async function () {
|
||||
assert.equal(sanitizePathname('foo'), 'foo');
|
||||
});
|
||||
});
|
61
src/tests/backend/specs/settings.js
Normal file
61
src/tests/backend/specs/settings.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly;
|
||||
const path = require('path');
|
||||
const process = require('process');
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('parseSettings', function () {
|
||||
let settings;
|
||||
const envVarSubstTestCases = [
|
||||
{name: 'true', val: 'true', var: 'SET_VAR_TRUE', want: true},
|
||||
{name: 'false', val: 'false', var: 'SET_VAR_FALSE', want: false},
|
||||
{name: 'null', val: 'null', var: 'SET_VAR_NULL', want: null},
|
||||
{name: 'undefined', val: 'undefined', var: 'SET_VAR_UNDEFINED', want: undefined},
|
||||
{name: 'number', val: '123', var: 'SET_VAR_NUMBER', want: 123},
|
||||
{name: 'string', val: 'foo', var: 'SET_VAR_STRING', want: 'foo'},
|
||||
{name: 'empty string', val: '', var: 'SET_VAR_EMPTY_STRING', want: ''},
|
||||
];
|
||||
|
||||
before(async function () {
|
||||
for (const tc of envVarSubstTestCases) process.env[tc.var] = tc.val;
|
||||
delete process.env.UNSET_VAR;
|
||||
settings = parseSettings(path.join(__dirname, 'settings.json'), true);
|
||||
assert(settings != null);
|
||||
});
|
||||
|
||||
describe('environment variable substitution', function () {
|
||||
describe('set', function () {
|
||||
for (const tc of envVarSubstTestCases) {
|
||||
it(tc.name, async function () {
|
||||
const obj = settings['environment variable substitution'].set;
|
||||
if (tc.name === 'undefined') {
|
||||
assert(!(tc.name in obj));
|
||||
} else {
|
||||
assert.equal(obj[tc.name], tc.want);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('unset', function () {
|
||||
it('no default', async function () {
|
||||
const obj = settings['environment variable substitution'].unset;
|
||||
assert.equal(obj['no default'], null);
|
||||
});
|
||||
|
||||
for (const tc of envVarSubstTestCases) {
|
||||
it(tc.name, async function () {
|
||||
const obj = settings['environment variable substitution'].unset;
|
||||
if (tc.name === 'undefined') {
|
||||
assert(!(tc.name in obj));
|
||||
} else {
|
||||
assert.equal(obj[tc.name], tc.want);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
39
src/tests/backend/specs/settings.json
Normal file
39
src/tests/backend/specs/settings.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
// line comment
|
||||
/*
|
||||
* block comment
|
||||
*/
|
||||
{
|
||||
"trailing commas": {
|
||||
"lists": {
|
||||
"multiple lines": [
|
||||
"",
|
||||
]
|
||||
},
|
||||
"objects": {
|
||||
"multiple lines": {
|
||||
"key": "",
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment variable substitution": {
|
||||
"set": {
|
||||
"true": "${SET_VAR_TRUE}",
|
||||
"false": "${SET_VAR_FALSE}",
|
||||
"null": "${SET_VAR_NULL}",
|
||||
"undefined": "${SET_VAR_UNDEFINED}",
|
||||
"number": "${SET_VAR_NUMBER}",
|
||||
"string": "${SET_VAR_STRING}",
|
||||
"empty string": "${SET_VAR_EMPTY_STRING}"
|
||||
},
|
||||
"unset": {
|
||||
"no default": "${UNSET_VAR}",
|
||||
"true": "${UNSET_VAR:true}",
|
||||
"false": "${UNSET_VAR:false}",
|
||||
"null": "${UNSET_VAR:null}",
|
||||
"undefined": "${UNSET_VAR:undefined}",
|
||||
"number": "${UNSET_VAR:123}",
|
||||
"string": "${UNSET_VAR:foo}",
|
||||
"empty string": "${UNSET_VAR:}"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ const common = require('../common');
|
|||
const io = require('socket.io-client');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
|
||||
const setCookieParser = require('set-cookie-parser');
|
||||
const settings = require('../../../node/utils/Settings');
|
||||
|
||||
|
@ -52,12 +53,16 @@ const connect = async (res) => {
|
|||
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
|
||||
|
||||
logger.debug('socket.io connecting...');
|
||||
let padId = null;
|
||||
if (res) {
|
||||
padId = res.req.path.split('/p/')[1];
|
||||
}
|
||||
const socket = io(`${common.baseUrl}/`, {
|
||||
forceNew: true, // Different tests will have different query parameters.
|
||||
path: '/socket.io',
|
||||
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
|
||||
// express_sid cookie must be passed as a query parameter.
|
||||
query: {cookie: reqCookieHdr},
|
||||
query: {cookie: reqCookieHdr, padId},
|
||||
});
|
||||
try {
|
||||
await getSocketEvent(socket, 'connect');
|
||||
|
@ -164,6 +169,33 @@ describe(__filename, function () {
|
|||
const clientVars = await handshake(socket, 'pad');
|
||||
assert.equal(clientVars.type, 'CLIENT_VARS');
|
||||
});
|
||||
|
||||
for (const authn of [false, true]) {
|
||||
const desc = authn ? 'authn user' : '!authn anonymous';
|
||||
it(`${desc} read-only /p/pad -> 200, ok`, async function () {
|
||||
this.timeout(400);
|
||||
const get = (ep) => {
|
||||
let res = agent.get(ep);
|
||||
if (authn) res = res.auth('user', 'user-password');
|
||||
return res.expect(200);
|
||||
};
|
||||
settings.requireAuthentication = authn;
|
||||
let res = await get('/p/pad');
|
||||
socket = await connect(res);
|
||||
let clientVars = await handshake(socket, 'pad');
|
||||
assert.equal(clientVars.type, 'CLIENT_VARS');
|
||||
assert.equal(clientVars.data.readonly, false);
|
||||
const readOnlyId = clientVars.data.readOnlyId;
|
||||
assert(readOnlyManager.isReadOnlyId(readOnlyId));
|
||||
socket.close();
|
||||
res = await get(`/p/${readOnlyId}`);
|
||||
socket = await connect(res);
|
||||
clientVars = await handshake(socket, readOnlyId);
|
||||
assert.equal(clientVars.type, 'CLIENT_VARS');
|
||||
assert.equal(clientVars.data.readonly, true);
|
||||
});
|
||||
}
|
||||
|
||||
it('authz user /p/pad -> 200, ok', async function () {
|
||||
this.timeout(400);
|
||||
settings.requireAuthentication = true;
|
||||
|
@ -199,6 +231,24 @@ describe(__filename, function () {
|
|||
const message = await handshake(socket, 'pad');
|
||||
assert.equal(message.accessStatus, 'deny');
|
||||
});
|
||||
|
||||
it('authn anonymous read-only /p/pad -> 401, error', async function () {
|
||||
this.timeout(400);
|
||||
settings.requireAuthentication = true;
|
||||
let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
||||
socket = await connect(res);
|
||||
const clientVars = await handshake(socket, 'pad');
|
||||
assert.equal(clientVars.type, 'CLIENT_VARS');
|
||||
const readOnlyId = clientVars.data.readOnlyId;
|
||||
assert(readOnlyManager.isReadOnlyId(readOnlyId));
|
||||
socket.close();
|
||||
res = await agent.get(`/p/${readOnlyId}`).expect(401);
|
||||
// Despite the 401, try to read the pad via a socket.io connection anyway.
|
||||
socket = await connect(res);
|
||||
const message = await handshake(socket, readOnlyId);
|
||||
assert.equal(message.accessStatus, 'deny');
|
||||
});
|
||||
|
||||
it('authn !cookie -> error', async function () {
|
||||
this.timeout(400);
|
||||
settings.requireAuthentication = true;
|
||||
|
|
|
@ -6,17 +6,16 @@ const helper = {};
|
|||
let $iframe;
|
||||
const jsLibraries = {};
|
||||
|
||||
helper.init = (cb) => {
|
||||
$.get('/static/js/vendors/jquery.js').done((code) => {
|
||||
helper.init = async () => {
|
||||
[
|
||||
jsLibraries.jquery,
|
||||
jsLibraries.sendkeys,
|
||||
] = await Promise.all([
|
||||
$.get('../../static/js/vendors/jquery.js'),
|
||||
$.get('lib/sendkeys.js'),
|
||||
]);
|
||||
// make sure we don't override existing jquery
|
||||
jsLibraries.jquery = `if(typeof $ === 'undefined') {\n${code}\n}`;
|
||||
|
||||
$.get('/tests/frontend/lib/sendkeys.js').done((code) => {
|
||||
jsLibraries.sendkeys = code;
|
||||
|
||||
cb();
|
||||
});
|
||||
});
|
||||
jsLibraries.jquery = `if (typeof $ === 'undefined') {\n${jsLibraries.jquery}\n}`;
|
||||
};
|
||||
|
||||
helper.randomString = (len) => {
|
||||
|
@ -51,26 +50,21 @@ const helper = {};
|
|||
};
|
||||
|
||||
helper.clearSessionCookies = () => {
|
||||
// Expire cookies, so author and language are changed after reloading the pad. See:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_4_reset_the_previous_cookie
|
||||
window.document.cookie = 'token=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
window.document.cookie = 'language=;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
window.Cookies.remove('token');
|
||||
window.Cookies.remove('language');
|
||||
};
|
||||
|
||||
// Can only happen when the iframe exists, so we're doing it separately from other cookies
|
||||
helper.clearPadPrefCookie = () => {
|
||||
helper.padChrome$.document.cookie = 'prefsHttp=;expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');
|
||||
padcookie.clear();
|
||||
};
|
||||
|
||||
// Overwrite all prefs in pad cookie. Assumes http, not https.
|
||||
//
|
||||
// `helper.padChrome$.document.cookie` (the iframe) and `window.document.cookie`
|
||||
// seem to have independent cookies, UNLESS we put path=/ here (which we don't).
|
||||
// I don't fully understand it, but this function seems to properly simulate
|
||||
// padCookie.setPref in the client code
|
||||
// Overwrite all prefs in pad cookie.
|
||||
helper.setPadPrefCookie = (prefs) => {
|
||||
helper.padChrome$.document.cookie =
|
||||
(`prefsHttp=${escape(JSON.stringify(prefs))};expires=Thu, 01 Jan 3000 00:00:00 GMT`);
|
||||
const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');
|
||||
padcookie.clear();
|
||||
for (const [key, value] of Object.entries(prefs)) padcookie.setPref(key, value);
|
||||
};
|
||||
|
||||
// Functionality for knowing what key event type is required for tests
|
||||
|
@ -89,19 +83,22 @@ const helper = {};
|
|||
}
|
||||
helper.evtType = evtType;
|
||||
|
||||
// @todo needs fixing asap
|
||||
// newPad occasionally timeouts, might be a problem with ready/onload code during page setup
|
||||
// This ensures that tests run regardless of this problem
|
||||
helper.retry = 0;
|
||||
// Deprecated; use helper.aNewPad() instead.
|
||||
helper.newPad = (opts, id) => {
|
||||
if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`;
|
||||
opts = Object.assign({id}, typeof opts === 'function' ? {cb: opts} : opts);
|
||||
const {cb = (err) => { if (err != null) throw err; }} = opts;
|
||||
delete opts.cb;
|
||||
helper.aNewPad(opts).then((id) => cb(null, id), (err) => cb(err || new Error(err)));
|
||||
return id;
|
||||
};
|
||||
|
||||
helper.newPad = (cb, padName) => {
|
||||
// build opts object
|
||||
let opts = {clearCookies: true};
|
||||
if (typeof cb === 'function') {
|
||||
opts.cb = cb;
|
||||
} else {
|
||||
opts = _.defaults(cb, opts);
|
||||
}
|
||||
helper.aNewPad = async (opts = {}) => {
|
||||
opts = Object.assign({
|
||||
_retry: 0,
|
||||
clearCookies: true,
|
||||
id: `FRONTEND_TEST_${helper.randomString(20)}`,
|
||||
}, opts);
|
||||
|
||||
// if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah.
|
||||
let encodedParams;
|
||||
|
@ -118,10 +115,7 @@ const helper = {};
|
|||
helper.clearSessionCookies();
|
||||
}
|
||||
|
||||
if (!padName) padName = `FRONTEND_TEST_${helper.randomString(20)}`;
|
||||
$iframe = $(`<iframe src='/p/${padName}${hash || ''}${encodedParams || ''}'></iframe>`);
|
||||
// needed for retry
|
||||
const origPadName = padName;
|
||||
$iframe = $(`<iframe src='/p/${opts.id}${hash || ''}${encodedParams || ''}'></iframe>`);
|
||||
|
||||
// clean up inner iframe references
|
||||
helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null;
|
||||
|
@ -130,16 +124,23 @@ const helper = {};
|
|||
$('#iframe-container iframe').remove();
|
||||
// set new iframe
|
||||
$('#iframe-container').append($iframe);
|
||||
$iframe.one('load', () => {
|
||||
await new Promise((resolve) => $iframe.one('load', resolve));
|
||||
helper.padChrome$ = getFrameJQuery($('#iframe-container iframe'));
|
||||
helper.padChrome$.padeditor =
|
||||
helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor;
|
||||
if (opts.clearCookies) {
|
||||
helper.clearPadPrefCookie();
|
||||
}
|
||||
if (opts.padPrefs) {
|
||||
helper.setPadPrefCookie(opts.padPrefs);
|
||||
}
|
||||
helper.waitFor(() => !$iframe.contents().find('#editorloadingbox')
|
||||
.is(':visible'), 10000).done(() => {
|
||||
try {
|
||||
await helper.waitForPromise(
|
||||
() => !$iframe.contents().find('#editorloadingbox').is(':visible'), 10000);
|
||||
} catch (err) {
|
||||
if (opts._retry++ >= 4) throw new Error('Pad never loaded');
|
||||
return await helper.aNewPad(opts);
|
||||
}
|
||||
helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]'));
|
||||
helper.padInner$ = getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]'));
|
||||
|
||||
|
@ -168,17 +169,8 @@ const helper = {};
|
|||
|
||||
// listen for server messages
|
||||
helper.spyOnSocketIO();
|
||||
opts.cb();
|
||||
}).fail(() => {
|
||||
if (helper.retry > 3) {
|
||||
throw new Error('Pad never loaded');
|
||||
}
|
||||
helper.retry++;
|
||||
helper.newPad(cb, origPadName);
|
||||
});
|
||||
});
|
||||
|
||||
return padName;
|
||||
return opts.id;
|
||||
};
|
||||
|
||||
helper.newAdmin = async (page) => {
|
||||
|
@ -269,6 +261,22 @@ const helper = {};
|
|||
selection.addRange(range);
|
||||
};
|
||||
|
||||
// Temporarily reduces minimum time between commits and calls the provided function with a single
|
||||
// argument: a function that immediately incorporates all pad edits (as opposed to waiting for the
|
||||
// idle timer to fire).
|
||||
helper.withFastCommit = async (fn) => {
|
||||
const incorp = () => helper.padChrome$.padeditor.ace.callWithAce(
|
||||
(ace) => ace.ace_inCallStackIfNecessary('helper.edit', () => ace.ace_fastIncorp()));
|
||||
const cc = helper.padChrome$.window.pad.collabClient;
|
||||
const {commitDelay} = cc;
|
||||
cc.commitDelay = 0;
|
||||
try {
|
||||
return await fn(incorp);
|
||||
} finally {
|
||||
cc.commitDelay = commitDelay;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => {
|
||||
const $textNodes = $targetLine.find('*').contents().filter(function () {
|
||||
return this.nodeType === Node.TEXT_NODE;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
helper.spyOnSocketIO = () => {
|
||||
helper.contentWindow().pad.socket.on('message', (msg) => {
|
||||
if (msg.type === 'COLLABROOM') {
|
||||
if (msg.type !== 'COLLABROOM') return;
|
||||
if (msg.data.type === 'ACCEPT_COMMIT') {
|
||||
helper.commits.push(msg);
|
||||
} else if (msg.data.type === 'USER_NEWINFO') {
|
||||
|
@ -14,7 +14,6 @@ helper.spyOnSocketIO = () => {
|
|||
} else if (msg.data.type === 'CHAT_MESSAGE') {
|
||||
helper.chatMessages.push(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -33,8 +32,11 @@ helper.spyOnSocketIO = () => {
|
|||
helper.edit = async (message, line) => {
|
||||
const editsNum = helper.commits.length;
|
||||
line = line ? line - 1 : 0;
|
||||
await helper.withFastCommit(async (incorp) => {
|
||||
helper.linesDiv()[line].sendkeys(message);
|
||||
return helper.waitForPromise(() => editsNum + 1 === helper.commits.length);
|
||||
incorp();
|
||||
await helper.waitForPromise(() => editsNum + 1 === helper.commits.length);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -45,11 +47,7 @@ helper.edit = async (message, line) => {
|
|||
*
|
||||
* @returns {Array.<HTMLElement>} array of divs
|
||||
*/
|
||||
helper.linesDiv = () => {
|
||||
return helper.padInner$('.ace-line').map(function () {
|
||||
return $(this);
|
||||
}).get();
|
||||
};
|
||||
helper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get();
|
||||
|
||||
/**
|
||||
* The pad text as an array of lines
|
||||
|
@ -81,10 +79,10 @@ helper.defaultText =
|
|||
* @param {string} message the chat message to be sent
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.sendChatMessage = (message) => {
|
||||
helper.sendChatMessage = async (message) => {
|
||||
const noOfChatMessages = helper.chatMessages.length;
|
||||
helper.padChrome$('#chatinput').sendkeys(message);
|
||||
return helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length);
|
||||
await helper.waitForPromise(() => noOfChatMessages + 1 === helper.chatMessages.length);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -92,11 +90,10 @@ helper.sendChatMessage = (message) => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.showSettings = () => {
|
||||
if (!helper.isSettingsShown()) {
|
||||
helper.showSettings = async () => {
|
||||
if (helper.isSettingsShown()) return;
|
||||
helper.settingsButton().click();
|
||||
return helper.waitForPromise(() => helper.isSettingsShown(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => helper.isSettingsShown(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -105,11 +102,10 @@ helper.showSettings = () => {
|
|||
* @returns {Promise}
|
||||
* @todo untested
|
||||
*/
|
||||
helper.hideSettings = () => {
|
||||
if (helper.isSettingsShown()) {
|
||||
helper.hideSettings = async () => {
|
||||
if (!helper.isSettingsShown()) return;
|
||||
helper.settingsButton().click();
|
||||
return helper.waitForPromise(() => !helper.isSettingsShown(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => !helper.isSettingsShown(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -118,12 +114,11 @@ helper.hideSettings = () => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.enableStickyChatviaSettings = () => {
|
||||
helper.enableStickyChatviaSettings = async () => {
|
||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||
if (helper.isSettingsShown() && !stickyChat.is(':checked')) {
|
||||
if (!helper.isSettingsShown() || stickyChat.is(':checked')) return;
|
||||
stickyChat.click();
|
||||
return helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -132,12 +127,11 @@ helper.enableStickyChatviaSettings = () => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.disableStickyChatviaSettings = () => {
|
||||
helper.disableStickyChatviaSettings = async () => {
|
||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||
if (helper.isSettingsShown() && stickyChat.is(':checked')) {
|
||||
if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return;
|
||||
stickyChat.click();
|
||||
return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -146,12 +140,11 @@ helper.disableStickyChatviaSettings = () => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.enableStickyChatviaIcon = () => {
|
||||
helper.enableStickyChatviaIcon = async () => {
|
||||
const stickyChat = helper.padChrome$('#titlesticky');
|
||||
if (helper.isChatboxShown() && !helper.isChatboxSticky()) {
|
||||
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||
stickyChat.click();
|
||||
return helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -160,11 +153,10 @@ helper.enableStickyChatviaIcon = () => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.disableStickyChatviaIcon = () => {
|
||||
if (helper.isChatboxShown() && helper.isChatboxSticky()) {
|
||||
helper.disableStickyChatviaIcon = async () => {
|
||||
if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return;
|
||||
helper.titlecross().click();
|
||||
return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -179,12 +171,12 @@ helper.disableStickyChatviaIcon = () => {
|
|||
* @todo for some reason this does only work the first time, you cannot
|
||||
* goto rev 0 and then via the same method to rev 5. Use buttons instead
|
||||
*/
|
||||
helper.gotoTimeslider = (revision) => {
|
||||
helper.gotoTimeslider = async (revision) => {
|
||||
revision = Number.isInteger(revision) ? `#${revision}` : '';
|
||||
const iframe = $('#iframe-container iframe');
|
||||
iframe.attr('src', `${iframe.attr('src')}/timeslider${revision}`);
|
||||
|
||||
return helper.waitForPromise(() => helper.timesliderTimerTime() &&
|
||||
await helper.waitForPromise(() => helper.timesliderTimerTime() &&
|
||||
!Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000);
|
||||
};
|
||||
|
||||
|
@ -227,7 +219,10 @@ helper.clearPad = async () => {
|
|||
await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed);
|
||||
const e = new helper.padInner$.Event(helper.evtType);
|
||||
e.keyCode = 8; // delete key
|
||||
await helper.withFastCommit(async (incorp) => {
|
||||
helper.padInner$('#innerdocbody').trigger(e);
|
||||
incorp();
|
||||
await helper.waitForPromise(helper.padIsEmpty);
|
||||
await helper.waitForPromise(() => helper.commits.length > commitsBefore);
|
||||
});
|
||||
};
|
||||
|
|
121
src/tests/frontend/helper/multipleUsers.js
Normal file
121
src/tests/frontend/helper/multipleUsers.js
Normal file
|
@ -0,0 +1,121 @@
|
|||
'use strict';
|
||||
|
||||
helper.multipleUsers = {
|
||||
_user0: null,
|
||||
_user1: null,
|
||||
|
||||
// open the same pad on different frames (allows concurrent editions to pad)
|
||||
async init() {
|
||||
this._user0 = {
|
||||
$frame: $('#iframe-container iframe'),
|
||||
token: getToken(),
|
||||
// we'll switch between pads, need to store current values of helper.pad*
|
||||
// to be able to restore those values later
|
||||
padChrome$: helper.padChrome$,
|
||||
padOuter$: helper.padOuter$,
|
||||
padInner$: helper.padInner$,
|
||||
};
|
||||
this._user1 = {};
|
||||
// Force generation of a new token.
|
||||
clearToken();
|
||||
// need to perform as the other user, otherwise we'll get the userdup error message
|
||||
await this.performAsOtherUser(this._createUser1Frame.bind(this));
|
||||
},
|
||||
|
||||
async performAsOtherUser(action) {
|
||||
startActingLike(this._user1);
|
||||
await action();
|
||||
startActingLike(this._user0);
|
||||
},
|
||||
|
||||
close() {
|
||||
this._user0.$frame.attr('style', ''); // make the default ocopy the full height
|
||||
this._user1.$frame.remove();
|
||||
},
|
||||
|
||||
async _loadJQueryForUser1Frame() {
|
||||
const code = await $.get('/static/js/jquery.js');
|
||||
|
||||
// make sure we don't override existing jquery
|
||||
const jQueryCode = `if(typeof $ === "undefined") {\n${code}\n}`;
|
||||
const sendkeysCode = await $.get('/tests/frontend/lib/sendkeys.js');
|
||||
const codesToLoad = [jQueryCode, sendkeysCode];
|
||||
|
||||
this._user1.padChrome$ = getFrameJQuery(codesToLoad, this._user1.$frame);
|
||||
this._user1.padOuter$ =
|
||||
getFrameJQuery(codesToLoad, this._user1.padChrome$('iframe[name="ace_outer"]'));
|
||||
this._user1.padInner$ =
|
||||
getFrameJQuery(codesToLoad, this._user1.padOuter$('iframe[name="ace_inner"]'));
|
||||
|
||||
// update helper vars now that they are available
|
||||
helper.padChrome$ = this._user1.padChrome$;
|
||||
helper.padOuter$ = this._user1.padOuter$;
|
||||
helper.padInner$ = this._user1.padInner$;
|
||||
},
|
||||
|
||||
async _createUser1Frame() {
|
||||
// create the iframe
|
||||
const padUrl = this._user0.$frame.attr('src');
|
||||
this._user1.$frame = $('<iframe>').attr('id', 'user1_pad').attr('src', padUrl);
|
||||
|
||||
// place one iframe (visually) below the other
|
||||
this._user0.$frame.attr('style', 'height: 50%');
|
||||
this._user1.$frame.attr('style', 'height: 50%; top: 50%');
|
||||
this._user1.$frame.insertAfter(this._user0.$frame);
|
||||
|
||||
// wait for user1 pad to load
|
||||
await new Promise((resolve) => this._user1.$frame.one('load', resolve));
|
||||
|
||||
const $editorLoadingMessage = this._user1.$frame.contents().find('#editorloadingbox');
|
||||
const $errorMessageModal = this._user0.$frame.contents().find('#connectivity .userdup');
|
||||
|
||||
await helper.waitForPromise(() => {
|
||||
const loaded = !$editorLoadingMessage.is(':visible');
|
||||
// make sure we don't get the userdup by mistake
|
||||
const didNotDetectUserDup = !$errorMessageModal.is(':visible');
|
||||
return loaded && didNotDetectUserDup;
|
||||
}, 50000);
|
||||
|
||||
// need to get values for this._user1.pad* vars
|
||||
await this._loadJQueryForUser1Frame();
|
||||
|
||||
this._user1.token = getToken();
|
||||
if (this._user0.token === this._user1.token) {
|
||||
throw new Error('expected different token for user1');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// adapted form helper.js on Etherpad code
|
||||
const getFrameJQuery = (codesToLoad, $iframe) => {
|
||||
const win = $iframe[0].contentWindow;
|
||||
const doc = win.document;
|
||||
|
||||
for (let i = 0; i < codesToLoad.length; i++) {
|
||||
win.eval(codesToLoad[i]);
|
||||
}
|
||||
|
||||
win.$.window = win;
|
||||
win.$.document = doc;
|
||||
|
||||
return win.$;
|
||||
};
|
||||
|
||||
const getCookies =
|
||||
() => helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils').Cookies;
|
||||
|
||||
const setToken = (token) => getCookies().set('token', token);
|
||||
|
||||
const getToken = () => getCookies().get('token');
|
||||
|
||||
const startActingLike = (user) => {
|
||||
// update helper references, so other methods will act as if the main frame
|
||||
// was the one we're using from now on
|
||||
helper.padChrome$ = user.padChrome$;
|
||||
helper.padOuter$ = user.padOuter$;
|
||||
helper.padInner$ = user.padInner$;
|
||||
|
||||
if (helper.padChrome$) setToken(user.token);
|
||||
};
|
||||
|
||||
const clearToken = () => getCookies().remove('token');
|
|
@ -13,12 +13,11 @@ helper.contentWindow = () => $('#iframe-container iframe')[0].contentWindow;
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.showChat = () => {
|
||||
helper.showChat = async () => {
|
||||
const chaticon = helper.chatIcon();
|
||||
if (chaticon.hasClass('visible')) {
|
||||
if (!chaticon.hasClass('visible')) return;
|
||||
chaticon.click();
|
||||
return helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -26,11 +25,10 @@ helper.showChat = () => {
|
|||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
helper.hideChat = () => {
|
||||
if (helper.isChatboxShown() && !helper.isChatboxSticky()) {
|
||||
helper.hideChat = async () => {
|
||||
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||
helper.titlecross().click();
|
||||
return helper.waitForPromise(() => !helper.isChatboxShown(), 2000);
|
||||
}
|
||||
await helper.waitForPromise(() => !helper.isChatboxShown(), 2000);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -132,9 +130,8 @@ helper.isSettingsShown = () => helper.padChrome$('#settings').hasClass('popup-sh
|
|||
* @returns {HTMLElement} timer
|
||||
*/
|
||||
helper.timesliderTimer = () => {
|
||||
if (typeof helper.contentWindow().$ === 'function') {
|
||||
if (typeof helper.contentWindow().$ !== 'function') return;
|
||||
return helper.contentWindow().$('#timer');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -143,9 +140,8 @@ helper.timesliderTimer = () => {
|
|||
* @returns {HTMLElement} timer
|
||||
*/
|
||||
helper.timesliderTimerTime = () => {
|
||||
if (helper.timesliderTimer()) {
|
||||
if (!helper.timesliderTimer()) return;
|
||||
return helper.timesliderTimer().text();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,16 +1,23 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Frontend tests</title>
|
||||
<meta charset="utf-8">
|
||||
|
||||
<link rel="stylesheet" href="runner.css" />
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="console"></div>
|
||||
<div id="split-view">
|
||||
<div id="mocha"></div>
|
||||
<div id="separator"></div>
|
||||
<div id="iframe-container"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/vendors/jquery.js"></script>
|
||||
<script src="/static/js/vendors/browser.js"></script>
|
||||
<script src="../../static/js/require-kernel.js"></script>
|
||||
<script src="../../static/js/vendors/jquery.js"></script>
|
||||
<script src="../../static/js/vendors/browser.js"></script>
|
||||
<script src="../../static/plugins/js-cookie/src/js.cookie.js"></script>
|
||||
<script src="lib/underscore.js"></script>
|
||||
|
||||
<script src="lib/mocha.js"></script>
|
||||
|
@ -20,7 +27,7 @@
|
|||
<script src="helper.js"></script>
|
||||
<script src="helper/methods.js"></script>
|
||||
<script src="helper/ui.js"></script>
|
||||
|
||||
<script src="specs_list.js"></script>
|
||||
<script src="helper/multipleUsers.js"></script>
|
||||
<script src="runner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -6,8 +6,6 @@ body {
|
|||
padding: 0px;
|
||||
margin: 0px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
@ -15,37 +13,39 @@ body {
|
|||
display: none;
|
||||
}
|
||||
|
||||
#iframe-container {
|
||||
width: 80%;
|
||||
min-width: 820px;
|
||||
#split-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 20% 10px 1fr;
|
||||
}
|
||||
|
||||
#separator {
|
||||
grid-column: 2;
|
||||
grid-row: 1/-1;
|
||||
cursor: col-resize;
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
#iframe-container {
|
||||
height: 100%;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
#iframe-container iframe {
|
||||
border: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 820px;
|
||||
}
|
||||
|
||||
#mocha {
|
||||
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
border-right: 2px solid #999;
|
||||
flex: 1 auto;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
width:20%;
|
||||
min-height: 100%; /* https://css-tricks.com/preventing-a-grid-blowout/ */
|
||||
font-size:80%;
|
||||
|
||||
}
|
||||
|
||||
#mocha #report {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#mocha li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#mocha ul {
|
||||
|
@ -57,7 +57,7 @@ body {
|
|||
}
|
||||
|
||||
#mocha h1 {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px; /* margin-top breaks autoscrolling */
|
||||
font-size: 1em;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ body {
|
|||
}
|
||||
|
||||
#mocha .suite .suite h1 {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
|
@ -170,26 +170,33 @@ body {
|
|||
-webkit-box-shadow: 0 1px 3px #eee;
|
||||
}
|
||||
|
||||
#report ul {
|
||||
#mocha-report {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#mocha-report ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#report.pass .test.fail {
|
||||
#mocha-report.pass .test.fail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#report.fail .test.pass {
|
||||
#mocha-report.fail .test.pass {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#error {
|
||||
#mocha-error {
|
||||
color: #c00;
|
||||
font-size: 1.5 em;
|
||||
font-weight: 100;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#stats {
|
||||
#mocha-stats {
|
||||
flex: 0 0 auto;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
|
@ -197,30 +204,26 @@ body {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
#mocha-stats {
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
#mocha-stats .progress {
|
||||
float: right;
|
||||
padding-top: 0;
|
||||
margin-right:5px;
|
||||
}
|
||||
|
||||
#stats em {
|
||||
#mocha-stats em {
|
||||
color: black;
|
||||
}
|
||||
|
||||
#stats a {
|
||||
#mocha-stats a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#stats a:hover {
|
||||
#mocha-stats a:hover {
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
#stats li {
|
||||
#mocha-stats li {
|
||||
display: inline-block;
|
||||
margin: 0 5px;
|
||||
list-style: none;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
/* global specs_list */
|
||||
|
||||
$(() => {
|
||||
// $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is an
|
||||
// async function for some bizarre reason, so the async function is wrapped in a non-async function.
|
||||
$(() => (async () => {
|
||||
const stringifyException = (exception) => {
|
||||
let err = exception.stack || exception.toString();
|
||||
|
||||
|
@ -29,13 +29,49 @@ $(() => {
|
|||
|
||||
if (!runner) return;
|
||||
|
||||
// AUTO-SCROLLING:
|
||||
|
||||
// Mocha can start multiple suites before the first 'suite' event is emitted. This can break the
|
||||
// logic used to determine if the div is already scrolled to the bottom. If this is false,
|
||||
// auto-scrolling unconditionally scrolls to the bottom no matter how far up the div is
|
||||
// currently scrolled. If true, auto-scrolling only happens if the div is scrolled close to the
|
||||
// bottom.
|
||||
let manuallyScrolled = false;
|
||||
// The 'scroll' event is fired for manual scrolling as well as JavaScript-initiated scrolling.
|
||||
// This is incremented while auto-scrolling and decremented when done auto-scrolling. This is
|
||||
// used to ensure that auto-scrolling never sets manuallyScrolled to true.
|
||||
let autoScrolling = 0;
|
||||
|
||||
// Auto-scroll the #mocha-report div to show the newly added test entry if it was previously
|
||||
// scrolled to the bottom.
|
||||
const autoscroll = (newElement) => {
|
||||
const mr = $('#mocha-report')[0];
|
||||
const scroll = !manuallyScrolled || (() => {
|
||||
const offsetTopAbs = newElement.getBoundingClientRect().top;
|
||||
const mrOffsetTopAbs = mr.getBoundingClientRect().top - mr.scrollTop;
|
||||
const offsetTop = offsetTopAbs - mrOffsetTopAbs;
|
||||
// Add some margin to cover rounding error and to make it easier to engage the auto-scroll.
|
||||
return offsetTop <= mr.clientHeight + mr.scrollTop + 5;
|
||||
})();
|
||||
if (!scroll) return;
|
||||
++autoScrolling;
|
||||
mr.scrollTop = mr.scrollHeight;
|
||||
manuallyScrolled = false;
|
||||
};
|
||||
|
||||
$('#mocha-report').on('scroll', () => {
|
||||
if (!autoScrolling) manuallyScrolled = true;
|
||||
else --autoScrolling;
|
||||
});
|
||||
|
||||
runner.on('start', () => {
|
||||
stats.start = new Date();
|
||||
});
|
||||
|
||||
runner.on('suite', (suite) => {
|
||||
suite.root || stats.suites++;
|
||||
if (suite.root) return;
|
||||
autoscroll($('#mocha-report .suite').last()[0]);
|
||||
stats.suites++;
|
||||
append(suite.title);
|
||||
level++;
|
||||
});
|
||||
|
@ -49,16 +85,11 @@ $(() => {
|
|||
}
|
||||
});
|
||||
|
||||
// Scroll down test display after each test
|
||||
const mochaEl = $('#mocha')[0];
|
||||
runner.on('test', () => {
|
||||
mochaEl.scrollTop = mochaEl.scrollHeight;
|
||||
});
|
||||
|
||||
// max time a test is allowed to run
|
||||
// TODO this should be lowered once timeslider_revision.js is faster
|
||||
let killTimeout;
|
||||
runner.on('test end', () => {
|
||||
autoscroll($('#mocha-report .test').last()[0]);
|
||||
stats.tests++;
|
||||
});
|
||||
|
||||
|
@ -102,27 +133,9 @@ $(() => {
|
|||
|
||||
const $console = $('#console');
|
||||
const append = (text) => {
|
||||
const oldText = $console.text();
|
||||
|
||||
let space = '';
|
||||
for (let i = 0; i < level * 2; i++) {
|
||||
space += ' ';
|
||||
}
|
||||
|
||||
let splitedText = '';
|
||||
_(text.split('\n')).each((line) => {
|
||||
while (line.length > 0) {
|
||||
const split = line.substr(0, 100);
|
||||
line = line.substr(100);
|
||||
if (splitedText.length > 0) splitedText += '\n';
|
||||
splitedText += split;
|
||||
}
|
||||
});
|
||||
|
||||
// indent all lines with the given amount of space
|
||||
const newText = _(splitedText.split('\n')).map((line) => space + line).join('\\n');
|
||||
|
||||
$console.text(`${oldText + newText}\\n`);
|
||||
// Indent each line.
|
||||
const lines = text.split('\n').map((line) => ' '.repeat(level * 2) + line);
|
||||
$console.append(document.createTextNode(`${lines.join('\n')}\n`));
|
||||
};
|
||||
|
||||
const total = runner.total;
|
||||
|
@ -156,29 +169,156 @@ $(() => {
|
|||
|
||||
const getURLParameter = (name) => (new URLSearchParams(location.search)).get(name);
|
||||
|
||||
// get the list of specs and filter it if requested
|
||||
const specs = specs_list.slice();
|
||||
const absUrl = (url) => new URL(url, window.location.href).href;
|
||||
require.setRootURI(absUrl('../../javascripts/src'));
|
||||
require.setLibraryURI(absUrl('../../javascripts/lib'));
|
||||
require.setGlobalKeyPath('require');
|
||||
|
||||
// inject spec scripts into the dom
|
||||
const $body = $('body');
|
||||
$.each(specs, (i, spec) => {
|
||||
// if the spec isn't a plugin spec which means the spec file might be in a different subfolder
|
||||
if (!spec.startsWith('/')) {
|
||||
$body.append(`<script src="specs/${spec}"></script>`);
|
||||
} else {
|
||||
$body.append(`<script src="${spec}"></script>`);
|
||||
}
|
||||
const Split = require('split-grid/dist/split-grid.min');
|
||||
new Split({
|
||||
columnGutters: [{
|
||||
track: 1,
|
||||
element: document.getElementById('separator'),
|
||||
}],
|
||||
});
|
||||
|
||||
// initialize the test helper
|
||||
helper.init(() => {
|
||||
// configure and start the test framework
|
||||
// Speed up tests by loading test definitions in parallel. Approach: Define a new global object
|
||||
// that has a define() method, which is a wrapper around window.require.define(). The wrapper
|
||||
// mutates the module definition function to temporarily replace Mocha's functions with
|
||||
// placeholders. The placeholders make it possible to defer the actual Mocha function calls until
|
||||
// after the modules are all loaded in parallel. require.setGlobalKeyPath() is used to coax
|
||||
// require-kernel into using the wrapper define() method instead of require.define().
|
||||
|
||||
// Per-module log of attempted Mocha function calls. Key is module path, value is an array of
|
||||
// [functionName, argsArray] arrays.
|
||||
const mochaCalls = new Map();
|
||||
const mochaFns = [
|
||||
'after',
|
||||
'afterEach',
|
||||
'before',
|
||||
'beforeEach',
|
||||
'context',
|
||||
'describe',
|
||||
'it',
|
||||
'run',
|
||||
'specify',
|
||||
'xcontext', // Undocumented as of Mocha 7.1.2.
|
||||
'xdescribe', // Undocumented as of Mocha 7.1.2.
|
||||
'xit', // Undocumented as of Mocha 7.1.2.
|
||||
'xspecify', // Undocumented as of Mocha 7.1.2.
|
||||
];
|
||||
window.testRunnerRequire = {
|
||||
define(...args) {
|
||||
if (args.length === 2) args = [{[args[0]]: args[1]}];
|
||||
if (args.length !== 1) throw new Error('unexpected args passed to testRunnerRequire.define');
|
||||
const [origDefs] = args;
|
||||
const defs = {};
|
||||
for (const [path, origDef] of Object.entries(origDefs)) {
|
||||
defs[path] = function (require, exports, module) {
|
||||
const calls = [];
|
||||
mochaCalls.set(module.id.replace(/\.js$/, ''), calls);
|
||||
// Backup Mocha functions. Note that because modules can require other modules, these
|
||||
// backups might be placeholders, not the actual Mocha functions.
|
||||
const backups = {};
|
||||
for (const fn of mochaFns) {
|
||||
if (typeof window[fn] !== 'function') continue;
|
||||
// Note: Test specs can require other modules, so window[fn] might be a placeholder
|
||||
// function, not the actual Mocha function.
|
||||
backups[fn] = window[fn];
|
||||
window[fn] = (...args) => calls.push([fn, args]);
|
||||
}
|
||||
try {
|
||||
return origDef.call(this, require, exports, module);
|
||||
} finally {
|
||||
Object.assign(window, backups);
|
||||
}
|
||||
};
|
||||
}
|
||||
return require.define(defs);
|
||||
},
|
||||
};
|
||||
require.setGlobalKeyPath('testRunnerRequire');
|
||||
// Increase fetch parallelism to speed up test spec loading. (Note: The browser might limit to a
|
||||
// lower value -- this is just an upper limit.)
|
||||
require.setRequestMaximum(20);
|
||||
|
||||
const $log = $('<div>');
|
||||
const appendToLog = (msg) => {
|
||||
if (typeof msg === 'string') msg = document.createTextNode(msg);
|
||||
// Add some margin to cover rounding error and to make it easier to engage the auto-scroll.
|
||||
const scrolledToBottom = $log[0].scrollHeight <= $log[0].scrollTop + $log[0].clientHeight + 5;
|
||||
const $msg = $('<div>').css('white-space', 'nowrap').append(msg).appendTo($log);
|
||||
if (scrolledToBottom) $log[0].scrollTop = $log[0].scrollHeight;
|
||||
return $msg;
|
||||
};
|
||||
const $bar = $('<progress>');
|
||||
let barLastUpdate = Date.now();
|
||||
const incrementBar = async (amount = 1) => {
|
||||
$bar.attr('value', Number.parseInt($bar.attr('value')) + 1);
|
||||
// Give the browser an opportunity to draw the progress bar's new length. `await
|
||||
// Promise.resolve()` isn't enough, so a timeout is used. Sleeping every increment (even 0ms)
|
||||
// unnecessarily slows down spec loading so the sleep is occasional.
|
||||
if (Date.now() - barLastUpdate > 100) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
barLastUpdate = Date.now();
|
||||
}
|
||||
};
|
||||
const $progressArea = $('<div>')
|
||||
.css({'display': 'flex', 'flex-direction': 'column', 'height': '100%'})
|
||||
.append($('<div>').css({flex: '1 0 0'}))
|
||||
.append($('<div>')
|
||||
.css({'flex': '0 0 auto', 'font-weight': 'bold'})
|
||||
.text('Loading frontend test specs...'))
|
||||
.append($log.css({flex: '0 1 auto', overflow: 'auto'}))
|
||||
.append($bar.css({flex: '0 0 auto', width: '100%'}))
|
||||
.appendTo('#mocha');
|
||||
const specs = await $.getJSON('frontendTestSpecs.json');
|
||||
if (specs.length > 0) {
|
||||
$bar.attr({value: 0, max: specs.length * 2});
|
||||
await incrementBar(0);
|
||||
}
|
||||
const makeDesc = (spec) => `${spec
|
||||
.replace(/^ep_etherpad-lite\/tests\/frontend\/specs\//, '<core> ')
|
||||
.replace(/^([^/ ]*)\/static\/tests\/frontend\/specs\//, '<$1> ')}.js`;
|
||||
await Promise.all(specs.map(async (spec) => {
|
||||
const $msg = appendToLog(`Fetching ${makeDesc(spec)}...`);
|
||||
try {
|
||||
await new Promise((resolve, reject) => require(spec, (module) => {
|
||||
if (module == null) return reject(new Error(`failed to load module ${spec}`));
|
||||
resolve();
|
||||
}));
|
||||
} catch (err) {
|
||||
$msg.append($('<b>').css('color', 'red').text(' FAILED'));
|
||||
appendToLog($('<pre>').text(`${err.stack || err}`));
|
||||
throw err;
|
||||
}
|
||||
$msg.append(' done');
|
||||
await incrementBar();
|
||||
}));
|
||||
require.setGlobalKeyPath('require');
|
||||
delete window.testRunnerRequire;
|
||||
for (const spec of specs) {
|
||||
const desc = makeDesc(spec);
|
||||
const $msg = appendToLog(`Executing ${desc}...`);
|
||||
try {
|
||||
describe(desc, function () {
|
||||
for (const [fn, args] of mochaCalls.get(spec)) window[fn](...args);
|
||||
});
|
||||
} catch (err) {
|
||||
$msg.append($('<b>').css('color', 'red').text(' FAILED'));
|
||||
appendToLog($('<pre>').text(`${err.stack || err}`));
|
||||
throw err;
|
||||
}
|
||||
$msg.append(' done');
|
||||
await incrementBar();
|
||||
}
|
||||
$progressArea.remove();
|
||||
|
||||
await helper.init();
|
||||
const grep = getURLParameter('grep');
|
||||
if (grep != null) {
|
||||
mocha.grep(grep);
|
||||
}
|
||||
|
||||
const runner = mocha.run();
|
||||
customRunner(runner);
|
||||
});
|
||||
});
|
||||
})());
|
||||
|
|
|
@ -4,13 +4,11 @@ describe('All the alphabet works n stuff', function () {
|
|||
const expectedString = 'abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
// create a new pad before each test run
|
||||
beforeEach(function (cb) {
|
||||
helper.newPad(cb);
|
||||
this.timeout(60000);
|
||||
beforeEach(async function () {
|
||||
await helper.aNewPad();
|
||||
});
|
||||
|
||||
it('when you enter any char it appears right', function (done) {
|
||||
this.timeout(250);
|
||||
const inner$ = helper.padInner$;
|
||||
|
||||
// get the first text element out of the inner iframe
|
||||
|
|
|
@ -6,8 +6,9 @@ describe('author of pad edition', function () {
|
|||
const LINE_WITH_UNORDERED_LIST = 2;
|
||||
|
||||
// author 1 creates a new pad with some content (regular lines and lists)
|
||||
before(function (done) {
|
||||
const padId = helper.newPad(() => {
|
||||
before(async function () {
|
||||
const padId = await helper.aNewPad();
|
||||
|
||||
// make sure pad has at least 3 lines
|
||||
const $firstLine = helper.padInner$('div').first();
|
||||
const threeLines = ['regular line', 'line with ordered list', 'line with unordered list']
|
||||
|
@ -15,10 +16,10 @@ describe('author of pad edition', function () {
|
|||
$firstLine.html(threeLines);
|
||||
|
||||
// wait for lines to be processed by Etherpad
|
||||
helper.waitFor(() => {
|
||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
||||
return $lineWithUnorderedList.text() === 'line with unordered list';
|
||||
}).done(() => {
|
||||
await helper.waitForPromise(() => (
|
||||
getLine(LINE_WITH_UNORDERED_LIST).text() === 'line with unordered list' &&
|
||||
helper.commits.length === 1));
|
||||
|
||||
// create the unordered list
|
||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
||||
$lineWithUnorderedList.sendkeys('{selectall}');
|
||||
|
@ -26,10 +27,10 @@ describe('author of pad edition', function () {
|
|||
const $insertUnorderedListButton = helper.padChrome$('.buttonicon-insertunorderedlist');
|
||||
$insertUnorderedListButton.click();
|
||||
|
||||
helper.waitFor(() => {
|
||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
||||
return $lineWithUnorderedList.find('ul li').length === 1;
|
||||
}).done(() => {
|
||||
await helper.waitForPromise(() => (
|
||||
getLine(LINE_WITH_UNORDERED_LIST).find('ul li').length === 1 &&
|
||||
helper.commits.length === 2));
|
||||
|
||||
// create the ordered list
|
||||
const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);
|
||||
$lineWithOrderedList.sendkeys('{selectall}');
|
||||
|
@ -37,40 +38,29 @@ describe('author of pad edition', function () {
|
|||
const $insertOrderedListButton = helper.padChrome$('.buttonicon-insertorderedlist');
|
||||
$insertOrderedListButton.click();
|
||||
|
||||
helper.waitFor(() => {
|
||||
const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);
|
||||
return $lineWithOrderedList.find('ol li').length === 1;
|
||||
}).done(() => {
|
||||
// Reload pad, to make changes as a second user. Need a timeout here to make sure
|
||||
// all changes were saved before reloading
|
||||
setTimeout(() => {
|
||||
// Expire cookie, so author is changed after reloading the pad.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#Example_4_Reset_the_previous_cookie
|
||||
helper.padChrome$.document.cookie =
|
||||
'token=foo;expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
await helper.waitForPromise(() => (
|
||||
getLine(LINE_WITH_ORDERED_LIST).find('ol li').length === 1 &&
|
||||
helper.commits.length === 3));
|
||||
|
||||
helper.newPad(done, padId);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
this.timeout(60000);
|
||||
// Expire cookie, so author is changed after reloading the pad.
|
||||
const {Cookies} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_utils');
|
||||
Cookies.remove('token');
|
||||
|
||||
// Reload pad, to make changes as a second user.
|
||||
await helper.aNewPad({id: padId});
|
||||
});
|
||||
|
||||
// author 2 makes some changes on the pad
|
||||
it('marks only the new content as changes of the second user on a regular line', function (done) {
|
||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(REGULAR_LINE, 'x', done);
|
||||
it('regular line', async function () {
|
||||
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(REGULAR_LINE, 'x');
|
||||
});
|
||||
|
||||
it('marks only the new content as changes of the second user on a ' +
|
||||
'line with ordered list', function (done) {
|
||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_ORDERED_LIST, 'y', done);
|
||||
it('line with ordered list', async function () {
|
||||
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_ORDERED_LIST, 'y');
|
||||
});
|
||||
|
||||
it('marks only the new content as changes of the second user on ' +
|
||||
'a line with unordered list', function (done) {
|
||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_UNORDERED_LIST, 'z', done);
|
||||
it('line with unordered list', async function () {
|
||||
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_UNORDERED_LIST, 'z');
|
||||
});
|
||||
|
||||
/* ********************** Helper functions ************************ */
|
||||
|
@ -78,7 +68,7 @@ describe('author of pad edition', function () {
|
|||
|
||||
const getAuthorFromClassList = (classes) => classes.find((cls) => cls.startsWith('author'));
|
||||
|
||||
const changeLineAndCheckOnlyThatChangeIsFromThisAuthor = (lineNumber, textChange, done) => {
|
||||
const changeLineAndCheckOnlyThatChangeIsFromThisAuthor = async (lineNumber, textChange) => {
|
||||
// get original author class
|
||||
const classes = getLine(lineNumber).find('span').first().attr('class').split(' ');
|
||||
const originalAuthor = getAuthorFromClassList(classes);
|
||||
|
@ -90,18 +80,16 @@ describe('author of pad edition', function () {
|
|||
|
||||
// wait for change to be processed by Etherpad
|
||||
let otherAuthorsOfLine;
|
||||
helper.waitFor(() => {
|
||||
await helper.waitForPromise(() => {
|
||||
const authorsOfLine = getLine(lineNumber).find('span').map(function () {
|
||||
return getAuthorFromClassList($(this).attr('class').split(' '));
|
||||
}).get();
|
||||
otherAuthorsOfLine = authorsOfLine.filter((author) => author !== originalAuthor);
|
||||
const lineHasChangeOfThisAuthor = otherAuthorsOfLine.length > 0;
|
||||
return lineHasChangeOfThisAuthor;
|
||||
}).done(() => {
|
||||
});
|
||||
const thisAuthor = otherAuthorsOfLine[0];
|
||||
const $changeOfThisAuthor = getLine(lineNumber).find(`span.${thisAuthor}`);
|
||||
expect($changeOfThisAuthor.text()).to.be(textChange);
|
||||
done();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
|
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