mirror of
https://github.com/ether/etherpad-lite.git
synced 2025-05-04 14:19:13 -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
|
- Black hole bug
|
||||||
- Special case Bug
|
- Special case Bug
|
||||||
- Upstream bug
|
- Upstream bug
|
||||||
|
- Feature Request
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: wontfix
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# 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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [10, 12, 14, 15]
|
node: [12, 14, 16]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
@ -50,7 +50,7 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [10, 12, 14, 15]
|
node: [12, 14, 16]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- 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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [10, 12, 14, 15]
|
node: [12, 14, 16]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Generate Sauce Labs strings
|
- 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
|
# any branch is useful for testing before a PR is submitted
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
@ -16,10 +16,10 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node: [10, 12, 14, 15]
|
node: [12, 14, 16]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout master repository
|
- name: Check out latest release
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
ref: master
|
ref: master
|
||||||
|
@ -60,10 +60,18 @@ jobs:
|
||||||
- name: Run the backend tests
|
- name: Run the backend tests
|
||||||
run: cd src && npm test
|
run: cd src && npm test
|
||||||
|
|
||||||
- name: Git fetch
|
# Because actions/checkout@v2 is called with "ref: master" and without
|
||||||
run: git fetch
|
# "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}"
|
run: git checkout "${GITHUB_SHA}"
|
||||||
|
|
||||||
- name: Install all dependencies and symlink for ep_etherpad-lite
|
- 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
|
# 1.8.13
|
||||||
|
|
||||||
### Notable fixes
|
### Notable fixes
|
||||||
|
|
||||||
* Fixed a bug in the safeRun.sh script (#4935)
|
* 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)
|
* Fixed issue with non-opening device keyboard on smartphones (#4929)
|
||||||
* Add version string to iframe_editor.css to prevent stale cache entry (#4964)
|
* 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
|
ARG EP_DIR=/opt/etherpad-lite
|
||||||
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
|
||||||
|
|
||||||
# install abiword for DOC/PDF/ODT export
|
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
|
||||||
RUN [ -z "${INSTALL_ABIWORD}" ] || (apt update && apt -y install abiword && apt clean && rm -rf /var/lib/apt/lists/*)
|
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
||||||
|
RUN export DEBIAN_FRONTEND=noninteractive; \
|
||||||
# install libreoffice for DOC/PDF/ODT export
|
mkdir -p /usr/share/man/man1 && \
|
||||||
# the mkdir is needed for configuration of openjdk-11-jre-headless, see https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
|
apt-get -qq update && \
|
||||||
RUN [ -z "${INSTALL_SOFFICE}" ] || (apt update && mkdir -p /usr/share/man/man1 && apt -y install libreoffice && apt clean && rm -rf /var/lib/apt/lists/*)
|
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
|
USER etherpad
|
||||||
|
|
||||||
|
@ -68,11 +75,18 @@ WORKDIR "${EP_DIR}"
|
||||||
|
|
||||||
COPY --chown=etherpad:etherpad ./ ./
|
COPY --chown=etherpad:etherpad ./ ./
|
||||||
|
|
||||||
# install node dependencies for Etherpad
|
# Plugins must be installed before installing Etherpad's dependencies, otherwise
|
||||||
RUN src/bin/installDeps.sh && \
|
# npm will try to hoist common dependencies by removing them from
|
||||||
rm -rf ~/.npm/_cacache
|
# 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
|
||||||
RUN [ -z "${ETHERPAD_PLUGINS}" ] || npm install ${ETHERPAD_PLUGINS}
|
# 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 the configuration file.
|
||||||
COPY --chown=etherpad:etherpad ./settings.json.docker "${EP_DIR}"/settings.json
|
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
|
# Installation
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- `nodejs` >= **10.17.0**.
|
- [Node.js](https://nodejs.org/) >= **12.13.0**.
|
||||||
|
|
||||||
## GNU/Linux and other UNIX-like systems
|
## GNU/Linux and other UNIX-like systems
|
||||||
|
|
||||||
|
@ -46,7 +46,8 @@ src/bin/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual install
|
### 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):**
|
**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
|
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
|
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.
|
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
|
## collectContentPre
|
||||||
|
|
||||||
|
|
|
@ -156,11 +156,13 @@ Called from: src/node/db/SecurityManager.js
|
||||||
|
|
||||||
Things in context:
|
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
|
2. token - the token of the author
|
||||||
3. sessionCookie - the session the use has
|
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
|
## padCreate
|
||||||
Called from: src/node/db/Pad.js
|
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
|
configuration. (Example: Add a tracking ID for an external analytics tool that
|
||||||
is used client-side.)
|
is used client-side.)
|
||||||
|
|
||||||
The clientVars function must return a Promise that resolves to an object (or
|
You can manipulate `clientVars` in two different ways:
|
||||||
null/undefined) whose properties will be merged into `context.clientVars`.
|
* Return an object. The object will be merged into `clientVars` via
|
||||||
Returning `callback(value)` will return a Promise that is resolved to `value`.
|
`Object.assign()`, so any keys that already exist in `clientVars` will be
|
||||||
|
overwritten by the values in the returned object.
|
||||||
You can modify `context.clientVars` to change the values sent to the client, but
|
* Modify `context.clientVars`. Beware: Other plugins might also be reading or
|
||||||
beware: async functions from other clientVars plugins might also be reading or
|
manipulating the same `context.clientVars` object. To avoid race conditions,
|
||||||
manipulating the same `context.clientVars` object. For this reason it is
|
you are encouraged to return an object rather than modify
|
||||||
recommended you return an object rather than modify `context.clientVars`.
|
`context.clientVars`.
|
||||||
|
|
||||||
If needed, you can access the user's account information (if authenticated) via
|
If needed, you can access the user's account information (if authenticated) via
|
||||||
`context.socket.client.request.session.user`.
|
`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
|
## getLineHTMLForExport
|
||||||
Called from: src/node/utils/ExportHtml.js
|
Called from: src/node/utils/ExportHtml.js
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
body{
|
body {
|
||||||
border-top: solid #44b492 5pt;
|
border-top: solid #44b492 5pt;
|
||||||
line-height:150%;
|
line-height:150%;
|
||||||
font-family: 'Quicksand',sans-serif;
|
font-family: 'Quicksand',sans-serif;
|
||||||
|
@ -8,28 +8,43 @@ body{
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a{
|
a {
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1{
|
h1 {
|
||||||
color: #44b492;
|
color: #44b492;
|
||||||
line-height:100%;
|
line-height:100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover{
|
a:hover {
|
||||||
color: #44b492;
|
color: #44b492;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre{
|
pre {
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
padding:20px;
|
padding:20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code{
|
code {
|
||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
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_USER` | a database user with sufficient permissions to create tables | |
|
||||||
| `DB_PASS` | the password for the database username | |
|
| `DB_PASS` | the password for the database username | |
|
||||||
| `DB_CHARSET` | the character set for the tables (only required for MySQL) | |
|
| `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).
|
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>",
|
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
|
||||||
"contributors": [],
|
"contributors": [],
|
||||||
"dependencies": {"MODULE": "0.3.20"},
|
"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.
|
* 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:
|
* EXAMPLE:
|
||||||
* "port": "${PORT:9001}"
|
* "port": "${PORT:9001}"
|
||||||
* "minify": "${MINIFY}"
|
* "minify": "${MINIFY}"
|
||||||
|
@ -80,10 +105,12 @@
|
||||||
"title": "${TITLE:Etherpad}",
|
"title": "${TITLE:Etherpad}",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* favicon default name
|
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
||||||
* alternatively, set up a fully specified Url to your own favicon
|
* 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.
|
* Skin name.
|
||||||
|
@ -180,12 +207,12 @@
|
||||||
|
|
||||||
"dbType": "${DB_TYPE:dirty}",
|
"dbType": "${DB_TYPE:dirty}",
|
||||||
"dbSettings": {
|
"dbSettings": {
|
||||||
"host": "${DB_HOST}",
|
"host": "${DB_HOST:undefined}",
|
||||||
"port": "${DB_PORT}",
|
"port": "${DB_PORT:undefined}",
|
||||||
"database": "${DB_NAME}",
|
"database": "${DB_NAME:undefined}",
|
||||||
"user": "${DB_USER}",
|
"user": "${DB_USER:undefined}",
|
||||||
"password": "${DB_PASS}",
|
"password": "${DB_PASS:undefined}",
|
||||||
"charset": "${DB_CHARSET}",
|
"charset": "${DB_CHARSET:undefined}",
|
||||||
"filename": "${DB_FILENAME:var/dirty.db}"
|
"filename": "${DB_FILENAME:var/dirty.db}"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -283,7 +310,7 @@
|
||||||
* it to null disables Abiword and will only allow plain text and HTML
|
* it to null disables Abiword and will only allow plain text and HTML
|
||||||
* import/exports.
|
* import/exports.
|
||||||
*/
|
*/
|
||||||
"abiword": "${ABIWORD}",
|
"abiword": "${ABIWORD:null}",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This is the absolute path to the soffice executable.
|
* This is the absolute path to the soffice executable.
|
||||||
|
@ -291,7 +318,7 @@
|
||||||
* LibreOffice can be used in lieu of Abiword to export pads.
|
* LibreOffice can be used in lieu of Abiword to export pads.
|
||||||
* Setting it to null disables LibreOffice exporting.
|
* Setting it to null disables LibreOffice exporting.
|
||||||
*/
|
*/
|
||||||
"soffice": "${SOFFICE}",
|
"soffice": "${SOFFICE:null}",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Path to the Tidy executable.
|
* Path to the Tidy executable.
|
||||||
|
@ -299,7 +326,7 @@
|
||||||
* Tidy is used to improve the quality of exported pads.
|
* Tidy is used to improve the quality of exported pads.
|
||||||
* Setting it to null disables Tidy.
|
* Setting it to null disables Tidy.
|
||||||
*/
|
*/
|
||||||
"tidyHtml": "${TIDY_HTML}",
|
"tidyHtml": "${TIDY_HTML:null}",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Allow import of file types other than the supported ones:
|
* Allow import of file types other than the supported ones:
|
||||||
|
@ -429,13 +456,13 @@
|
||||||
"admin": {
|
"admin": {
|
||||||
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
|
// 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
|
// 2) please note that if password is null, the user will not be created
|
||||||
"password": "${ADMIN_PASSWORD}",
|
"password": "${ADMIN_PASSWORD:null}",
|
||||||
"is_admin": true
|
"is_admin": true
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
// 1) "password" can be replaced with "hash" if you install ep_hash_auth
|
// 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
|
// 2) please note that if password is null, the user will not be created
|
||||||
"password": "${USER_PASSWORD}",
|
"password": "${USER_PASSWORD:null}",
|
||||||
"is_admin": false
|
"is_admin": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -463,6 +490,11 @@
|
||||||
*/
|
*/
|
||||||
"loadTest": "${LOAD_TEST:false}",
|
"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
|
* Disable indentation on new line when previous line ends with some special
|
||||||
* chars (':', '[', '(', '{')
|
* chars (':', '[', '(', '{')
|
||||||
|
|
|
@ -15,6 +15,31 @@
|
||||||
*
|
*
|
||||||
* This is useful, for example, when running in a Docker container.
|
* 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:
|
* EXAMPLE:
|
||||||
* "port": "${PORT:9001}"
|
* "port": "${PORT:9001}"
|
||||||
* "minify": "${MINIFY}"
|
* "minify": "${MINIFY}"
|
||||||
|
@ -71,10 +96,12 @@
|
||||||
"title": "Etherpad",
|
"title": "Etherpad",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* favicon default name
|
* Pathname of the favicon you want to use. If null, the skin's favicon is
|
||||||
* alternatively, set up a fully specified Url to your own favicon
|
* 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.
|
* Skin name.
|
||||||
|
@ -468,6 +495,11 @@
|
||||||
*/
|
*/
|
||||||
"loadTest": false,
|
"loadTest": false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable dump of objects preventing a clean exit
|
||||||
|
*/
|
||||||
|
"dumpOnUncleanExit": false,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Disable indentation on new line when previous line ends with some special
|
* Disable indentation on new line when previous line ends with some special
|
||||||
* chars (':', '[', '(', '{')
|
* chars (':', '[', '(', '{')
|
||||||
|
|
|
@ -36,4 +36,4 @@ src/bin/installDeps.sh "$@" || exit 1
|
||||||
#Move to the node folder and start
|
#Move to the node folder and start
|
||||||
echo "Starting Etherpad..."
|
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
|
# Use 0.0.0.0 to allow external connections to the debugger
|
||||||
# (ex: running Etherpad on a docker container). Use default port # (9229)
|
# (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) => {
|
lexed.forEach((tok) => {
|
||||||
if (tok.type !== 'heading') return;
|
if (tok.type !== 'heading') return;
|
||||||
if (tok.depth - depth > 1) {
|
if (tok.depth - depth > 1) {
|
||||||
return cb(new Error(`Inappropriate heading level\n${
|
return cb(new Error(`Inappropriate heading level\n${JSON.stringify(tok)}`));
|
||||||
JSON.stringify(tok)}`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
depth = tok.depth;
|
depth = tok.depth;
|
||||||
const id = getId(`${filename}_${tok.text.trim()}`);
|
|
||||||
toc.push(`${new Array((depth - 1) * 2 + 1).join(' ')
|
const slugger = new marked.Slugger();
|
||||||
}* <a href="#${id}">${
|
const id = slugger.slug(`${filename}_${tok.text.trim()}`);
|
||||||
tok.text}</a>`);
|
|
||||||
|
toc.push(`${new Array((depth - 1) * 2 + 1).join(' ')}* <a href="#${id}">${tok.text}</a>`);
|
||||||
|
|
||||||
tok.text += `<span><a class="mark" href="#${id}" ` +
|
tok.text += `<span><a class="mark" href="#${id}" ` +
|
||||||
`id="${id}">#</a></span>`;
|
`id="${id}">#</a></span>`;
|
||||||
});
|
});
|
||||||
|
@ -162,17 +163,3 @@ const buildToc = (lexed, filename, cb) => {
|
||||||
toc = marked.parse(toc.join('\n'));
|
toc = marked.parse(toc.join('\n'));
|
||||||
cb(null, toc);
|
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",
|
"description": "Internal tool for generating Node.js API docs",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.17.0"
|
"node": ">=12.13.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "^2.0.0"
|
"marked": "^2.0.0"
|
||||||
|
|
|
@ -10,6 +10,8 @@
|
||||||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||||
process.on('unhandledRejection', (err) => { throw err; });
|
process.on('unhandledRejection', (err) => { throw err; });
|
||||||
|
|
||||||
|
const util = require('util');
|
||||||
|
|
||||||
if (process.argv.length !== 3) throw new Error('Use: node extractPadData.js $PADID');
|
if (process.argv.length !== 3) throw new Error('Use: node extractPadData.js $PADID');
|
||||||
|
|
||||||
// get the padID
|
// get the padID
|
||||||
|
|
|
@ -19,4 +19,4 @@ cd "${MY_DIR}/../.." || exit 1
|
||||||
echo "Running directly, without checking/installing dependencies"
|
echo "Running directly, without checking/installing dependencies"
|
||||||
|
|
||||||
# run Etherpad main class
|
# 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
|
# minimum required node version
|
||||||
REQUIRED_NODE_MAJOR=10
|
REQUIRED_NODE_MAJOR=12
|
||||||
REQUIRED_NODE_MINOR=13
|
REQUIRED_NODE_MINOR=13
|
||||||
|
|
||||||
# minimum required npm version
|
# 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() {
|
require_minimal_version() {
|
||||||
PROGRAM_LABEL="$1"
|
PROGRAM_LABEL="$1"
|
||||||
VERSION="$2"
|
VERSION="$2"
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||||
process.on('unhandledRejection', (err) => { throw err; });
|
process.on('unhandledRejection', (err) => { throw err; });
|
||||||
|
|
||||||
|
const util = require('util');
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const log = (str) => {
|
const log = (str) => {
|
||||||
|
@ -46,6 +48,7 @@ const unescape = (val) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const log4js = require('log4js');
|
const log4js = require('log4js');
|
||||||
|
const readline = require('readline');
|
||||||
const settings = require('../node/utils/Settings');
|
const settings = require('../node/utils/Settings');
|
||||||
const ueberDB = require('ueberdb2');
|
const ueberDB = require('ueberdb2');
|
||||||
|
|
||||||
|
@ -69,14 +72,12 @@ const unescape = (val) => {
|
||||||
await util.promisify(db.init.bind(db))();
|
await util.promisify(db.init.bind(db))();
|
||||||
log('done');
|
log('done');
|
||||||
|
|
||||||
log('open output file...');
|
log(`Opening ${sqlFile}...`);
|
||||||
const lines = fs.readFileSync(sqlFile, 'utf8').split('\n');
|
const stream = fs.createReadStream(sqlFile, {encoding: 'utf8'});
|
||||||
|
|
||||||
const count = lines.length;
|
log(`Reading ${sqlFile}...`);
|
||||||
let keyNo = 0;
|
let keyNo = 0;
|
||||||
|
for await (const l of readline.createInterface({input: stream, crlfDelay: Infinity})) {
|
||||||
process.stdout.write(`Start importing ${count} keys...\n`);
|
|
||||||
lines.forEach((l) => {
|
|
||||||
if (l.substr(0, 27) === 'REPLACE INTO store VALUES (') {
|
if (l.substr(0, 27) === 'REPLACE INTO store VALUES (') {
|
||||||
const pos = l.indexOf("', '");
|
const pos = l.indexOf("', '");
|
||||||
const key = l.substr(28, pos - 28);
|
const key = l.substr(28, pos - 28);
|
||||||
|
@ -86,11 +87,9 @@ const unescape = (val) => {
|
||||||
console.log(`unval: ${unescape(value)}`);
|
console.log(`unval: ${unescape(value)}`);
|
||||||
db.set(key, unescape(value), null);
|
db.set(key, unescape(value), null);
|
||||||
keyNo++;
|
keyNo++;
|
||||||
if (keyNo % 1000 === 0) {
|
if (keyNo % 1000 === 0) log(` ${keyNo}`);
|
||||||
process.stdout.write(` ${keyNo}/${count}\n`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
process.stdout.write('done. waiting for db to finish transaction. ' +
|
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||||
'depended on dbms this may take some time..\n');
|
'depended on dbms this may take some time..\n');
|
||||||
|
|
|
@ -220,14 +220,15 @@ fs.readdir(pluginPath, (err, rootFiles) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDeps(parsedPackageJSON, 'devDependencies', {
|
updateDeps(parsedPackageJSON, 'devDependencies', {
|
||||||
'eslint': '^7.20.0',
|
'eslint': '^7.28.0',
|
||||||
'eslint-config-etherpad': '^1.0.25',
|
'eslint-config-etherpad': '^2.0.0',
|
||||||
|
'eslint-plugin-cypress': '^2.11.3',
|
||||||
'eslint-plugin-eslint-comments': '^3.2.0',
|
'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-node': '^11.1.0',
|
||||||
'eslint-plugin-prefer-arrow': '^1.2.3',
|
'eslint-plugin-prefer-arrow': '^1.2.3',
|
||||||
'eslint-plugin-promise': '^4.3.1',
|
'eslint-plugin-promise': '^5.1.0',
|
||||||
'eslint-plugin-you-dont-need-lodash-underscore': '^6.11.0',
|
'eslint-plugin-you-dont-need-lodash-underscore': '^6.12.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
updateDeps(parsedPackageJSON, 'peerDependencies', {
|
updateDeps(parsedPackageJSON, 'peerDependencies', {
|
||||||
|
@ -263,7 +264,7 @@ fs.readdir(pluginPath, (err, rootFiles) => {
|
||||||
console.warn('No engines or node engine in package.json');
|
console.warn('No engines or node engine in package.json');
|
||||||
if (autoFix) {
|
if (autoFix) {
|
||||||
const engines = {
|
const engines = {
|
||||||
node: '^10.17.0 || >=11.14.0',
|
node: '>=12.13.0',
|
||||||
};
|
};
|
||||||
parsedPackageJSON.engines = engines;
|
parsedPackageJSON.engines = engines;
|
||||||
writePackageJson(parsedPackageJSON);
|
writePackageJson(parsedPackageJSON);
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
const superagent = require('superagent');
|
const superagent = require('superagent');
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
|
|
||||||
(async() => {
|
(async () => {
|
||||||
const res = await superagent.get('https://static.etherpad.org/plugins.full.json');
|
const res = await superagent.get('https://static.etherpad.org/plugins.full.json');
|
||||||
const plugins = JSON.parse(res.text);
|
const plugins = JSON.parse(res.text);
|
||||||
for (const plugin of Object.keys(plugins)) {
|
for (const plugin of Object.keys(plugins)) {
|
||||||
|
@ -13,8 +13,8 @@ const currentTime = new Date();
|
||||||
const date = new Date(plugins[plugin].time);
|
const date = new Date(plugins[plugin].time);
|
||||||
const diffTime = Math.abs(currentTime - date);
|
const diffTime = Math.abs(currentTime - date);
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
if (diffDays > (365*2)) {
|
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
|
# Move to the node folder and start
|
||||||
log "Starting Etherpad..."
|
log "Starting Etherpad..."
|
||||||
|
|
||||||
exec node $(compute_node_args) src/node/server.js "$@"
|
exec node src/node/server.js "$@"
|
||||||
|
|
|
@ -1,20 +1,11 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
#Move to the folder where ep-lite is installed
|
mydir=$(cd "${0%/*}" && pwd -P) || exit 1
|
||||||
cd $(dirname $0)
|
cd "${mydir}"/../..
|
||||||
|
OUTDATED=$(npm outdated --depth=0 | awk '{print $1}' | grep '^ep_') || {
|
||||||
#Was this script started in the bin folder? if yes move out
|
echo "All plugins are up-to-date"
|
||||||
if [ -d "../bin" ]; then
|
exit 0
|
||||||
cd "../"
|
}
|
||||||
fi
|
set -- ${OUTDATED}
|
||||||
|
echo "Updating plugins: $*"
|
||||||
# npm outdated --depth=0 | grep -v "^Package" | awk '{print $1}' | xargs npm install $1 --save-dev
|
exec npm install --no-save "$@"
|
||||||
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
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
"authors": [
|
"authors": [
|
||||||
"Alami",
|
"Alami",
|
||||||
"Ali1",
|
"Ali1",
|
||||||
|
"ArticleEditor404",
|
||||||
"Haytham morsy",
|
"Haytham morsy",
|
||||||
"Meno25",
|
"Meno25",
|
||||||
"Mido",
|
"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.description": "الوصف",
|
||||||
|
"admin_plugins.installed_uninstall.value": "فك التنصيب",
|
||||||
|
"admin_plugins.last-update": "آخر تحديث",
|
||||||
"admin_plugins.name": "الاسم",
|
"admin_plugins.name": "الاسم",
|
||||||
"admin_plugins.version": "الإصدار",
|
"admin_plugins.version": "الإصدار",
|
||||||
|
"admin_plugins_info.version_latest": "أحدث نسخة متاحة",
|
||||||
"admin_plugins_info.version_number": "رقم الإصدار",
|
"admin_plugins_info.version_number": "رقم الإصدار",
|
||||||
"admin_settings": "إعدادات",
|
"admin_settings": "إعدادات",
|
||||||
|
"admin_settings.current": "التكوين الحالي",
|
||||||
"index.newPad": "باد جديد",
|
"index.newPad": "باد جديد",
|
||||||
"index.createOpenPad": "أو صنع/فتح باد بوضع اسمه:",
|
"index.createOpenPad": "أو صنع/فتح باد بوضع اسمه:",
|
||||||
"index.openPad": "افتح باد موجودة بالاسم:",
|
"index.openPad": "افتح باد موجودة بالاسم:",
|
||||||
|
@ -90,6 +101,8 @@
|
||||||
"pad.modals.corruptPad.cause": "قد يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة.",
|
"pad.modals.corruptPad.cause": "قد يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة.",
|
||||||
"pad.modals.deleted": "محذوف.",
|
"pad.modals.deleted": "محذوف.",
|
||||||
"pad.modals.deleted.explanation": "تمت إزالة هذا الباد.",
|
"pad.modals.deleted.explanation": "تمت إزالة هذا الباد.",
|
||||||
|
"pad.modals.rateLimited.explanation": "لقد أرسلت عددًا كبيرًا جدًا من الرسائل إلى هذه اللوحة ، لذا فقد قطع اتصالك.",
|
||||||
|
"pad.modals.rejected.explanation": "رفض الخادم رسالة أرسلها متصفحك.",
|
||||||
"pad.modals.disconnected": "لم تعد متصلا.",
|
"pad.modals.disconnected": "لم تعد متصلا.",
|
||||||
"pad.modals.disconnected.explanation": "تم فقدان الاتصال بالخادم",
|
"pad.modals.disconnected.explanation": "تم فقدان الاتصال بالخادم",
|
||||||
"pad.modals.disconnected.cause": "قد يكون الخادم غير متوفر. يرجى إعلام مسؤول الخدمة إذا كان هذا لا يزال يحدث.",
|
"pad.modals.disconnected.cause": "قد يكون الخادم غير متوفر. يرجى إعلام مسؤول الخدمة إذا كان هذا لا يزال يحدث.",
|
||||||
|
|
|
@ -12,11 +12,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin_plugins.available_fetching": "আনা হচ্ছে...",
|
"admin_plugins.available_fetching": "আনা হচ্ছে...",
|
||||||
"admin_plugins.available_install.value": "ইন্সটল করুন",
|
"admin_plugins.available_install.value": "ইনস্টল করুন",
|
||||||
"admin_plugins.description": "বিবরণ",
|
"admin_plugins.description": "বিবরণ",
|
||||||
"admin_plugins.installed": "ইন্সটল হওয়া প্লাগিনসমূহ",
|
"admin_plugins.installed": "ইন্সটল হওয়া প্লাগিনসমূহ",
|
||||||
"admin_plugins.installed_fetching": "ইন্সটলকৃত প্লাগিন আনা হচ্ছে",
|
"admin_plugins.installed_fetching": "ইন্সটলকৃত প্লাগিন আনা হচ্ছে",
|
||||||
"admin_plugins.installed_uninstall.value": "আনইন্সটল করুন",
|
"admin_plugins.installed_uninstall.value": "আনইনস্টল করুন",
|
||||||
"admin_plugins.last-update": "সর্বশেষ হালনাগাদ",
|
"admin_plugins.last-update": "সর্বশেষ হালনাগাদ",
|
||||||
"admin_plugins.name": "নাম",
|
"admin_plugins.name": "নাম",
|
||||||
"admin_plugins.version": "সংস্করণ",
|
"admin_plugins.version": "সংস্করণ",
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
"pad.permissionDenied": "দুঃখিত, এ প্যাড-টি দেখার অধিকার আপনার নেই",
|
"pad.permissionDenied": "দুঃখিত, এ প্যাড-টি দেখার অধিকার আপনার নেই",
|
||||||
"pad.settings.padSettings": "প্যাডের স্থাপন",
|
"pad.settings.padSettings": "প্যাডের স্থাপন",
|
||||||
"pad.settings.myView": "আমার দৃশ্য",
|
"pad.settings.myView": "আমার দৃশ্য",
|
||||||
"pad.settings.stickychat": "চ্যাট সক্রীনে প্রদর্শন করা হবে",
|
"pad.settings.stickychat": "সর্বদা পর্দায় চ্যাট দেখান",
|
||||||
"pad.settings.chatandusers": "চ্যাট এবং ব্যবহারকারী দেখান",
|
"pad.settings.chatandusers": "চ্যাট এবং ব্যবহারকারী দেখান",
|
||||||
"pad.settings.colorcheck": "লেখকদের নিজস্ব নির্বাচিত রং",
|
"pad.settings.colorcheck": "লেখকদের নিজস্ব নির্বাচিত রং",
|
||||||
"pad.settings.linenocheck": "লাইন নম্বর",
|
"pad.settings.linenocheck": "লাইন নম্বর",
|
||||||
|
@ -61,19 +61,19 @@
|
||||||
"pad.settings.fontType.normal": "সাধারণ",
|
"pad.settings.fontType.normal": "সাধারণ",
|
||||||
"pad.settings.language": "ভাষা:",
|
"pad.settings.language": "ভাষা:",
|
||||||
"pad.settings.about": "পরিচিতি",
|
"pad.settings.about": "পরিচিতি",
|
||||||
"pad.settings.poweredBy": "$1 দ্বারা চালিত",
|
"pad.settings.poweredBy": "এটি দ্বারা চালিত:",
|
||||||
"pad.importExport.import_export": "আমদানি/রপ্তানি",
|
"pad.importExport.import_export": "আমদানি/রপ্তানি",
|
||||||
"pad.importExport.import": "কোন টেক্সট ফাইল বা নথি আপলোড করুন",
|
"pad.importExport.import": "কোন টেক্সট ফাইল বা নথি আপলোড করুন",
|
||||||
"pad.importExport.importSuccessful": "সফল!",
|
"pad.importExport.importSuccessful": "সফল!",
|
||||||
"pad.importExport.export": "এই প্যাডটি রপ্তানি করুন:",
|
"pad.importExport.export": "এইরূপে এই প্যাডটি রপ্তানি করুন:",
|
||||||
"pad.importExport.exportetherpad": "ইথারপ্যাড",
|
"pad.importExport.exportetherpad": "ইথারপ্যাড",
|
||||||
"pad.importExport.exporthtml": "এইচটিএমএল",
|
"pad.importExport.exporthtml": "এইচটিএমএল",
|
||||||
"pad.importExport.exportplain": "সাধারণ লেখা",
|
"pad.importExport.exportplain": "সাধারণ লেখা",
|
||||||
"pad.importExport.exportword": "মাইক্রোসফট ওয়ার্ড",
|
"pad.importExport.exportword": "মাইক্রোসফট ওয়ার্ড",
|
||||||
"pad.importExport.exportpdf": "পিডিএফ",
|
"pad.importExport.exportpdf": "পিডিএফ",
|
||||||
"pad.importExport.exportopen": "ওডিএফ (ওপেন ডকুমেন্ট ফরম্যাট)",
|
"pad.importExport.exportopen": "ওডিএফ (ওপেন ডকুমেন্ট ফরম্যাট)",
|
||||||
"pad.modals.connected": "যোগাযোগ সফল",
|
"pad.modals.connected": "সংযোগস্থাপন করা হয়েছে।",
|
||||||
"pad.modals.reconnecting": "আপনার প্যাডের সাথে সংযোগস্থাপন করা হচ্ছে..",
|
"pad.modals.reconnecting": "আপনার প্যাডের সাথে সংযোগস্থাপন করা হচ্ছে…",
|
||||||
"pad.modals.forcereconnect": "পুনরায় সংযোগস্থাপনের চেষ্টা",
|
"pad.modals.forcereconnect": "পুনরায় সংযোগস্থাপনের চেষ্টা",
|
||||||
"pad.modals.userdup": "অন্য উইন্ডো-তে খোলা হয়েছে",
|
"pad.modals.userdup": "অন্য উইন্ডো-তে খোলা হয়েছে",
|
||||||
"pad.modals.unauth": "আপনার অধিকার নেই",
|
"pad.modals.unauth": "আপনার অধিকার নেই",
|
||||||
|
|
|
@ -5,14 +5,49 @@
|
||||||
"Dalba",
|
"Dalba",
|
||||||
"Ebraminio",
|
"Ebraminio",
|
||||||
"FarsiNevis",
|
"FarsiNevis",
|
||||||
|
"Jeeputer",
|
||||||
"Omid.koli",
|
"Omid.koli",
|
||||||
"Reza1615",
|
"Reza1615",
|
||||||
"ZxxZxxZ",
|
"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.newPad": "دفترچه یادداشت تازه",
|
||||||
"index.createOpenPad": "یا ایجاد/بازکردن یک دفترچه یادداشت با نام:",
|
"index.createOpenPad": "یا ایجاد/بازکردن یک دفترچه یادداشت با نام:",
|
||||||
|
"index.openPad": "باز کردن یک پد موجود با نام:",
|
||||||
"pad.toolbar.bold.title": "پررنگ (Ctrl-B)",
|
"pad.toolbar.bold.title": "پررنگ (Ctrl-B)",
|
||||||
"pad.toolbar.italic.title": "کج (Ctrl-I)",
|
"pad.toolbar.italic.title": "کج (Ctrl-I)",
|
||||||
"pad.toolbar.underline.title": "زیرخط (Ctrl-U)",
|
"pad.toolbar.underline.title": "زیرخط (Ctrl-U)",
|
||||||
|
@ -20,7 +55,7 @@
|
||||||
"pad.toolbar.ol.title": "فهرست مرتب شده (Ctrl+Shift+N)",
|
"pad.toolbar.ol.title": "فهرست مرتب شده (Ctrl+Shift+N)",
|
||||||
"pad.toolbar.ul.title": "فهرست مرتب نشده (Ctrl+Shift+L)",
|
"pad.toolbar.ul.title": "فهرست مرتب نشده (Ctrl+Shift+L)",
|
||||||
"pad.toolbar.indent.title": "تورفتگی (TAB)",
|
"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.undo.title": "باطلکردن (Ctrl-Z)",
|
||||||
"pad.toolbar.redo.title": "از نو (Ctrl-Y)",
|
"pad.toolbar.redo.title": "از نو (Ctrl-Y)",
|
||||||
"pad.toolbar.clearAuthorship.title": "پاککردن رنگهای نویسندگی (Ctrl+Shift+C)",
|
"pad.toolbar.clearAuthorship.title": "پاککردن رنگهای نویسندگی (Ctrl+Shift+C)",
|
||||||
|
@ -33,7 +68,7 @@
|
||||||
"pad.colorpicker.save": "ذخیره",
|
"pad.colorpicker.save": "ذخیره",
|
||||||
"pad.colorpicker.cancel": "لغو",
|
"pad.colorpicker.cancel": "لغو",
|
||||||
"pad.loading": "در حال بارگذاری...",
|
"pad.loading": "در حال بارگذاری...",
|
||||||
"pad.noCookie": "کوکی یافت نشد. لطفاً اجازهٔ اجرای کوکی در مروگرتان را بدهید!",
|
"pad.noCookie": "کلوچک یافت نشد. لطفاً اجارهٔ استفاده از کلوچک را در مرورگر خود تأیید کنید! نشست و تنظیمات شما در میان بازدیدها ذخیره نخواهد شد. این میتواند به این دلیل باشد که اترپد در برخی مرورگرها در یک iFrame قرار میگیرد. لطفاً مطمئن شوید که اترپد در زیردامنه/دامنهٔ یکسان با iFrame والد قرار دارد",
|
||||||
"pad.permissionDenied": "شما اجازهی دسترسی به این دفترچه یادداشت را ندارید",
|
"pad.permissionDenied": "شما اجازهی دسترسی به این دفترچه یادداشت را ندارید",
|
||||||
"pad.settings.padSettings": "تنظیمات دفترچه یادداشت",
|
"pad.settings.padSettings": "تنظیمات دفترچه یادداشت",
|
||||||
"pad.settings.myView": "نمای من",
|
"pad.settings.myView": "نمای من",
|
||||||
|
@ -45,6 +80,8 @@
|
||||||
"pad.settings.fontType": "نوع قلم:",
|
"pad.settings.fontType": "نوع قلم:",
|
||||||
"pad.settings.fontType.normal": "ساده",
|
"pad.settings.fontType.normal": "ساده",
|
||||||
"pad.settings.language": "زبان:",
|
"pad.settings.language": "زبان:",
|
||||||
|
"pad.settings.about": "درباره",
|
||||||
|
"pad.settings.poweredBy": "قدرستگرفته از",
|
||||||
"pad.importExport.import_export": "درونریزی/برونریزی",
|
"pad.importExport.import_export": "درونریزی/برونریزی",
|
||||||
"pad.importExport.import": "بارگذاری پروندهی متنی یا سند",
|
"pad.importExport.import": "بارگذاری پروندهی متنی یا سند",
|
||||||
"pad.importExport.importSuccessful": "موفقیت آمیز بود!",
|
"pad.importExport.importSuccessful": "موفقیت آمیز بود!",
|
||||||
|
@ -55,9 +92,9 @@
|
||||||
"pad.importExport.exportword": "Microsoft Word",
|
"pad.importExport.exportword": "Microsoft Word",
|
||||||
"pad.importExport.exportpdf": "PDF",
|
"pad.importExport.exportpdf": "PDF",
|
||||||
"pad.importExport.exportopen": "ODF (قالب سند باز)",
|
"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.connected": "متصل شد.",
|
||||||
"pad.modals.reconnecting": "در حال اتصال دوباره به دفترچه یادداشت شما..",
|
"pad.modals.reconnecting": "در حال اتصال دوباره به پد شما...",
|
||||||
"pad.modals.forcereconnect": "واداشتن به اتصال دوباره",
|
"pad.modals.forcereconnect": "واداشتن به اتصال دوباره",
|
||||||
"pad.modals.reconnecttimer": "تلاش برای اتصال مجدد",
|
"pad.modals.reconnecttimer": "تلاش برای اتصال مجدد",
|
||||||
"pad.modals.cancel": "لغو",
|
"pad.modals.cancel": "لغو",
|
||||||
|
@ -79,6 +116,7 @@
|
||||||
"pad.modals.corruptPad.cause": "این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.",
|
"pad.modals.corruptPad.cause": "این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.",
|
||||||
"pad.modals.deleted": "پاک شد.",
|
"pad.modals.deleted": "پاک شد.",
|
||||||
"pad.modals.deleted.explanation": "این دفترچه یادداشت پاک شدهاست.",
|
"pad.modals.deleted.explanation": "این دفترچه یادداشت پاک شدهاست.",
|
||||||
|
"pad.modals.rateLimited": "نرخ محدود شدهاست.",
|
||||||
"pad.modals.disconnected": "اتصال شما قطع شدهاست.",
|
"pad.modals.disconnected": "اتصال شما قطع شدهاست.",
|
||||||
"pad.modals.disconnected.explanation": "اتصال به سرور قطع شدهاست.",
|
"pad.modals.disconnected.explanation": "اتصال به سرور قطع شدهاست.",
|
||||||
"pad.modals.disconnected.cause": "ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.",
|
"pad.modals.disconnected.cause": "ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.",
|
||||||
|
|
|
@ -118,7 +118,7 @@
|
||||||
"pad.modals.disconnected.explanation": "Perdeuse a conexión co servidor",
|
"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.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": "Compartir este documento",
|
||||||
"pad.share.readonly": "Lectura só",
|
"pad.share.readonly": "Só lectura",
|
||||||
"pad.share.link": "Ligazón",
|
"pad.share.link": "Ligazón",
|
||||||
"pad.share.emebdcode": "Incorporar o URL",
|
"pad.share.emebdcode": "Incorporar o URL",
|
||||||
"pad.chat": "Chat",
|
"pad.chat": "Chat",
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
"@metadata": {
|
"@metadata": {
|
||||||
"authors": [
|
"authors": [
|
||||||
"Bhatakati aatma",
|
"Bhatakati aatma",
|
||||||
|
"Dsvyas",
|
||||||
"Harsh4101991",
|
"Harsh4101991",
|
||||||
"KartikMistry"
|
"KartikMistry"
|
||||||
]
|
]
|
||||||
|
@ -10,7 +11,7 @@
|
||||||
"pad.toolbar.bold.title": "બોલ્ડ",
|
"pad.toolbar.bold.title": "બોલ્ડ",
|
||||||
"pad.toolbar.settings.title": "ગોઠવણીઓ",
|
"pad.toolbar.settings.title": "ગોઠવણીઓ",
|
||||||
"pad.colorpicker.save": "સાચવો",
|
"pad.colorpicker.save": "સાચવો",
|
||||||
"pad.colorpicker.cancel": "રદ્દ કરો",
|
"pad.colorpicker.cancel": "રદ કરો",
|
||||||
"pad.loading": "લાવે છે...",
|
"pad.loading": "લાવે છે...",
|
||||||
"pad.noCookie": "કુકી મળી નહી. આપના બ્રાઉઝર સેટિંગમાં જઇ કુકી સક્રિય કરો!",
|
"pad.noCookie": "કુકી મળી નહી. આપના બ્રાઉઝર સેટિંગમાં જઇ કુકી સક્રિય કરો!",
|
||||||
"pad.permissionDenied": "આ પેડના ઉપયોગની આપને પરવાનગી નથી",
|
"pad.permissionDenied": "આ પેડના ઉપયોગની આપને પરવાનગી નથી",
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
"pad.toolbar.bold.title": "Grasse (Ctrl-B)",
|
"pad.toolbar.bold.title": "Grasse (Ctrl-B)",
|
||||||
"pad.toolbar.italic.title": "Italic (Ctrl-I)",
|
"pad.toolbar.italic.title": "Italic (Ctrl-I)",
|
||||||
"pad.toolbar.underline.title": "Sublinear (Ctrl-U)",
|
"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.ol.title": "Lista ordinate (Ctrl+Shift+N)",
|
||||||
"pad.toolbar.ul.title": "Lista non ordinate (Ctrl+Shift+L)",
|
"pad.toolbar.ul.title": "Lista non ordinate (Ctrl+Shift+L)",
|
||||||
"pad.toolbar.indent.title": "Indentar (TAB)",
|
"pad.toolbar.indent.title": "Indentar (TAB)",
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
"pad.colorpicker.save": "Salveguardar",
|
"pad.colorpicker.save": "Salveguardar",
|
||||||
"pad.colorpicker.cancel": "Cancellar",
|
"pad.colorpicker.cancel": "Cancellar",
|
||||||
"pad.loading": "Cargamento…",
|
"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.permissionDenied": "Tu non ha le permission de acceder a iste pad",
|
||||||
"pad.settings.padSettings": "Configuration del pad",
|
"pad.settings.padSettings": "Configuration del pad",
|
||||||
"pad.settings.myView": "Mi vista",
|
"pad.settings.myView": "Mi vista",
|
||||||
|
@ -50,7 +50,7 @@
|
||||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
"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.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.connected": "Connectite.",
|
||||||
"pad.modals.reconnecting": "Reconnecte a tu pad…",
|
"pad.modals.reconnecting": "Reconnexion a tu pad…",
|
||||||
"pad.modals.forcereconnect": "Fortiar reconnexion",
|
"pad.modals.forcereconnect": "Fortiar reconnexion",
|
||||||
"pad.modals.reconnecttimer": "Tentativa de reconnexion in",
|
"pad.modals.reconnecttimer": "Tentativa de reconnexion in",
|
||||||
"pad.modals.cancel": "Cancellar",
|
"pad.modals.cancel": "Cancellar",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@metadata": {
|
"@metadata": {
|
||||||
"authors": [
|
"authors": [
|
||||||
|
"Ajeje Brazorf",
|
||||||
"Beta16",
|
"Beta16",
|
||||||
"Gianfranco",
|
"Gianfranco",
|
||||||
"Jack",
|
"Jack",
|
||||||
|
@ -10,6 +11,12 @@
|
||||||
"Vituzzu"
|
"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.newPad": "Nuovo pad",
|
||||||
"index.createOpenPad": "o crea/apre un pad con il nome:",
|
"index.createOpenPad": "o crea/apre un pad con il nome:",
|
||||||
"index.openPad": "apri un Pad esistente col nome:",
|
"index.openPad": "apri un Pad esistente col nome:",
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"pad.colorpicker.save": "Späicheren",
|
"pad.colorpicker.save": "Späicheren",
|
||||||
"pad.colorpicker.cancel": "Ofbriechen",
|
"pad.colorpicker.cancel": "Ofbriechen",
|
||||||
"pad.loading": "Lueden...",
|
"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.permissionDenied": "Dir hutt net déi néideg Rechter fir dëse Pad opzemaachen",
|
||||||
"pad.settings.myView": "Méng Usiicht",
|
"pad.settings.myView": "Méng Usiicht",
|
||||||
"pad.settings.linenocheck": "Zeilennummeren",
|
"pad.settings.linenocheck": "Zeilennummeren",
|
||||||
|
|
|
@ -5,6 +5,33 @@
|
||||||
"Quentí"
|
"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.newPad": "Pad novèl",
|
||||||
"index.createOpenPad": "o crear/dobrir un Pad intitulat :",
|
"index.createOpenPad": "o crear/dobrir un Pad intitulat :",
|
||||||
"pad.toolbar.bold.title": "Gras (Ctrl-B)",
|
"pad.toolbar.bold.title": "Gras (Ctrl-B)",
|
||||||
|
@ -39,6 +66,8 @@
|
||||||
"pad.settings.fontType": "Tipe de poliça :",
|
"pad.settings.fontType": "Tipe de poliça :",
|
||||||
"pad.settings.fontType.normal": "Normal",
|
"pad.settings.fontType.normal": "Normal",
|
||||||
"pad.settings.language": "Lenga :",
|
"pad.settings.language": "Lenga :",
|
||||||
|
"pad.settings.about": "A prepaus",
|
||||||
|
"pad.settings.poweredBy": "Propulsat per",
|
||||||
"pad.importExport.import_export": "Importar/Exportar",
|
"pad.importExport.import_export": "Importar/Exportar",
|
||||||
"pad.importExport.import": "Cargar un tèxte o un document",
|
"pad.importExport.import": "Cargar un tèxte o un document",
|
||||||
"pad.importExport.importSuccessful": "Capitat !",
|
"pad.importExport.importSuccessful": "Capitat !",
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"MuratTheTurkish",
|
"MuratTheTurkish",
|
||||||
"Ti4goc",
|
"Ti4goc",
|
||||||
"Tuliouel",
|
"Tuliouel",
|
||||||
|
"Unamane",
|
||||||
"Waldir",
|
"Waldir",
|
||||||
"Waldyrious"
|
"Waldyrious"
|
||||||
]
|
]
|
||||||
|
@ -29,7 +30,7 @@
|
||||||
"admin_plugins.installed_fetching": "A obter plugins instalados...",
|
"admin_plugins.installed_fetching": "A obter plugins instalados...",
|
||||||
"admin_plugins.installed_nothing": "Não instalas-te nenhum plugin ainda.",
|
"admin_plugins.installed_nothing": "Não instalas-te nenhum plugin ainda.",
|
||||||
"admin_plugins.installed_uninstall.value": "Desinstalar",
|
"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.name": "Nome",
|
||||||
"admin_plugins.page-title": "Gestor de plugins - Etherpad",
|
"admin_plugins.page-title": "Gestor de plugins - Etherpad",
|
||||||
"admin_plugins.version": "Versão",
|
"admin_plugins.version": "Versão",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@metadata": {
|
"@metadata": {
|
||||||
"authors": [
|
"authors": [
|
||||||
|
"BryanDavis",
|
||||||
"Liuxinyu970226",
|
"Liuxinyu970226",
|
||||||
"Mklehr",
|
"Mklehr",
|
||||||
"Nemo bis",
|
"Nemo bis",
|
||||||
|
@ -10,6 +11,12 @@
|
||||||
"Tim.krieger"
|
"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}}",
|
"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.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.",
|
"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": "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.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.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_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.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}}",
|
"pad.importExport.importSuccessful": "Used as success message to indicate that the pad has been imported successfully.\n{{Identical|Successful}}",
|
||||||
|
|
|
@ -5,47 +5,84 @@
|
||||||
"Lexected",
|
"Lexected",
|
||||||
"Mark",
|
"Mark",
|
||||||
"Rudko",
|
"Rudko",
|
||||||
"Teslaton"
|
"Teslaton",
|
||||||
|
"Yardom78"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"index.newPad": "Nový Pad",
|
"admin.page-title": "Ovládací panel správu - Etherpad",
|
||||||
"index.createOpenPad": "alebo vytvoriť/otvoriť Pad s názvom:",
|
"admin_plugins": "Správca doplnkov",
|
||||||
"pad.toolbar.bold.title": "Tučné (Ctrl-B)",
|
"admin_plugins.available": "Dostupné doplnky",
|
||||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl-I)",
|
"admin_plugins.available_not-found": "Doplnky neboli nájdené.",
|
||||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl-U)",
|
"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.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.ul.title": "Nezoradený zoznam (Ctrl+Shift+L)",
|
||||||
"pad.toolbar.indent.title": "Zväčšiť odsadenie (TAB)",
|
"pad.toolbar.indent.title": "Zväčšiť okraj (TAB)",
|
||||||
"pad.toolbar.unindent.title": "Zmenšiť odsadenie (Shift+TAB)",
|
"pad.toolbar.unindent.title": "Zmenšiť okraj (Shift+TAB)",
|
||||||
"pad.toolbar.undo.title": "Späť (Ctrl-Z)",
|
"pad.toolbar.undo.title": "Späť (Ctrl-Z)",
|
||||||
"pad.toolbar.redo.title": "Znova (Ctrl-Y)",
|
"pad.toolbar.redo.title": "Opakovať (Ctrl-Y)",
|
||||||
"pad.toolbar.clearAuthorship.title": "Odstrániť farby autorstva (Ctrl+Shift+C)",
|
"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.import_export.title": "Import/export z/do rôznych formátov súborov",
|
||||||
"pad.toolbar.timeslider.title": "Časová os",
|
"pad.toolbar.timeslider.title": "Časová os",
|
||||||
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
||||||
"pad.toolbar.settings.title": "Nastavenia",
|
"pad.toolbar.settings.title": "Nastavenia",
|
||||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento Pad",
|
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
|
||||||
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto Padu",
|
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto poznámkového bloku",
|
||||||
"pad.colorpicker.save": "Uložiť",
|
"pad.colorpicker.save": "Uložiť",
|
||||||
"pad.colorpicker.cancel": "Zrušiť",
|
"pad.colorpicker.cancel": "Zrušiť",
|
||||||
"pad.loading": "Načítava sa...",
|
"pad.loading": "Načítava sa...",
|
||||||
"pad.noCookie": "Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači.",
|
"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 Padu",
|
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku",
|
||||||
"pad.settings.padSettings": "Nastavenia Padu",
|
"pad.settings.padSettings": "Nastavenia poznámkového bloku",
|
||||||
"pad.settings.myView": "Vlastný pohľad",
|
"pad.settings.myView": "Vlastný pohľad",
|
||||||
"pad.settings.stickychat": "Chat stále na obrazovke",
|
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
|
||||||
"pad.settings.chatandusers": "Zobraziť chat a užívateľov",
|
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
|
||||||
"pad.settings.colorcheck": "Farby autorstva",
|
"pad.settings.colorcheck": "Farby autorov",
|
||||||
"pad.settings.linenocheck": "Čísla riadkov",
|
"pad.settings.linenocheck": "Čísla riadkov",
|
||||||
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
||||||
"pad.settings.fontType": "Typ písma:",
|
"pad.settings.fontType": "Typ písma:",
|
||||||
"pad.settings.fontType.normal": "Normálne",
|
"pad.settings.fontType.normal": "Normálne",
|
||||||
"pad.settings.language": "Jazyk:",
|
"pad.settings.language": "Jazyk:",
|
||||||
|
"pad.settings.about": "O Etherpade",
|
||||||
|
"pad.settings.poweredBy": "Poháňané cez",
|
||||||
"pad.importExport.import_export": "Import/Export",
|
"pad.importExport.import_export": "Import/Export",
|
||||||
"pad.importExport.import": "Nahrať ľubovoľný textový súbor alebo dokument",
|
"pad.importExport.import": "Nahrať ľubovoľný textový súbor alebo dokument",
|
||||||
"pad.importExport.importSuccessful": "Import úspešný!",
|
"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.exportetherpad": "Etherpad",
|
||||||
"pad.importExport.exporthtml": "HTML",
|
"pad.importExport.exporthtml": "HTML",
|
||||||
"pad.importExport.exportplain": "Čistý text",
|
"pad.importExport.exportplain": "Čistý text",
|
||||||
|
@ -54,12 +91,12 @@
|
||||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
"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.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.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.forcereconnect": "Vynútiť znovupripojenie",
|
||||||
"pad.modals.reconnecttimer": "Skúšam sa pripojiť",
|
"pad.modals.reconnecttimer": "Skúšam sa pripojiť",
|
||||||
"pad.modals.cancel": "Zrušiť",
|
"pad.modals.cancel": "Zrušiť",
|
||||||
"pad.modals.userdup": "Otvorené v inom okne",
|
"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.userdup.advice": "Pre použitie tohoto okna se musíte znovu pripojiť.",
|
||||||
"pad.modals.unauth": "Nie ste autorizovaný",
|
"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.",
|
"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.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.explanation": "Server neodpovedá.",
|
||||||
"pad.modals.slowcommit.cause": "Príčinou môže byť problém so sieťovým pripojením.",
|
"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.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.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": "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": "Boli ste odpojení.",
|
||||||
"pad.modals.disconnected.explanation": "Spojenie so serverom sa prerušilo",
|
"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.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.readonly": "Len na čítanie",
|
||||||
"pad.share.link": "Odkaz",
|
"pad.share.link": "Odkaz",
|
||||||
"pad.share.emebdcode": "Vložiť URL",
|
"pad.share.emebdcode": "Vložiť URL",
|
||||||
"pad.chat": "Chat",
|
"pad.chat": "Rozhovor",
|
||||||
"pad.chat.title": "Otvoriť chat tohoto Padu.",
|
"pad.chat.title": "Otvoriť rozhovor tohoto poznámkového bloku.",
|
||||||
"pad.chat.loadmessages": "Načítať ďalšie správy",
|
"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.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.authors": "Autori:",
|
||||||
"timeslider.toolbar.authorsList": "Bez autorov",
|
"timeslider.toolbar.authorsList": "Bez autorov",
|
||||||
"timeslider.toolbar.exportlink.title": "Export",
|
"timeslider.toolbar.exportlink.title": "Export",
|
||||||
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
|
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
|
||||||
"timeslider.version": "Verzia {{version}}",
|
"timeslider.version": "Verzia {{version}}",
|
||||||
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
|
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
|
||||||
"timeslider.playPause": "Pustiť / Pozastaviť obsah padu",
|
"timeslider.playPause": "Pustiť / Pozastaviť obsah poznámkového bloku",
|
||||||
"timeslider.backRevision": "Ísť v tomto pade a revíziu späť",
|
"timeslider.backRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu späť",
|
||||||
"timeslider.forwardRevision": "Ísť v tomto pade o revíziu vpred",
|
"timeslider.forwardRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu vpred",
|
||||||
"timeslider.dateformat": "{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}",
|
"timeslider.dateformat": "{{day}}. {{month}} {{year}} {{hours}}:{{minutes}}:{{seconds}}",
|
||||||
"timeslider.month.january": "januára",
|
"timeslider.month.january": "januára",
|
||||||
"timeslider.month.february": "februára",
|
"timeslider.month.february": "februára",
|
||||||
|
@ -110,19 +154,20 @@
|
||||||
"timeslider.month.october": "októbra",
|
"timeslider.month.october": "októbra",
|
||||||
"timeslider.month.november": "novembra",
|
"timeslider.month.november": "novembra",
|
||||||
"timeslider.month.december": "decembra",
|
"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.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.savedrevs.timeslider": "Návštevou časovej osi môžete zobraziť uložené revízie",
|
||||||
"pad.userlist.entername": "Zadajte svoje meno",
|
"pad.userlist.entername": "Zadajte svoje meno",
|
||||||
"pad.userlist.unnamed": "nemenovaný",
|
"pad.userlist.unnamed": "nemenovaný",
|
||||||
"pad.editbar.clearcolors": "Skutočne odstrániť autorské farby z celého dokumentu?",
|
"pad.editbar.clearcolors": "Odstrániť farby autorov z celého dokumentu? Táto akcia sa nedá vrátiť",
|
||||||
"pad.impexp.importbutton": "Importovať",
|
"pad.impexp.importbutton": "Importovať teraz",
|
||||||
"pad.impexp.importing": "Prebieha import...",
|
"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.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.uploadFailed": "Nahrávanie zlyhalo, skúste to prosím znovu",
|
||||||
"pad.impexp.importfailed": "Import zlyhal",
|
"pad.impexp.importfailed": "Import zlyhal",
|
||||||
"pad.impexp.copypaste": "Vložte prosím kópiu cez schránku",
|
"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": "Nepooblaščen dostop",
|
||||||
"pad.modals.unauth.explanation": "Med ogledovanjem strani so se dovoljenja za ogled spremenila. Poskusite se znova povezati.",
|
"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.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": "Strežnik je nedosegljiv.",
|
||||||
"pad.modals.initsocketfail.explanation": "Povezava s strežnikom za usklajevanje ni mogoča.",
|
"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.",
|
"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_restart.value": "Rinise Etherpad-in",
|
||||||
"admin_settings.current_save.value": "Ruaji Rregullimet",
|
"admin_settings.current_save.value": "Ruaji Rregullimet",
|
||||||
"admin_settings.page-title": "Rregullime - Etherpad",
|
"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.createOpenPad": "ose krijoni/hapni një Bllok me emrin:",
|
||||||
"index.openPad": "hapni një Bllok ekzistues me emrin:",
|
"index.openPad": "hapni një Bllok ekzistues me emrin:",
|
||||||
"pad.toolbar.bold.title": "Të trasha (Ctrl-B)",
|
"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.clearAuthorship.title": "Hiqu Ngjyra Autorësish (Ctrl+Shift+C)",
|
||||||
"pad.toolbar.import_export.title": "Importoni/Eksportoni nga/në formate të tjera kartelash",
|
"pad.toolbar.import_export.title": "Importoni/Eksportoni nga/në formate të tjera kartelash",
|
||||||
"pad.toolbar.timeslider.title": "Rrjedha kohore",
|
"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.settings.title": "Rregullime",
|
||||||
"pad.toolbar.embed.title": "Ndajeni me të tjerët dhe Trupëzojeni këtë bllok",
|
"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",
|
"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.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.permissionDenied": "S’keni leje të hyni në këtë bllok",
|
||||||
"pad.settings.padSettings": "Rregullime Blloku",
|
"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.stickychat": "Fjalosje përherë në ekran",
|
||||||
"pad.settings.chatandusers": "Shfaq Fjalosje dhe Përdorues",
|
"pad.settings.chatandusers": "Shfaq Fjalosje dhe Përdorues",
|
||||||
"pad.settings.colorcheck": "Ngjyra autorësish",
|
"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.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.connected": "I lidhur.",
|
||||||
"pad.modals.reconnecting": "Po rilidheni te blloku juaj…",
|
"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.reconnecttimer": "Provë për rilidhje pas",
|
||||||
"pad.modals.cancel": "Anuloje",
|
"pad.modals.cancel": "Anuloje",
|
||||||
"pad.modals.userdup": "Hapur në një tjetër dritare",
|
"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.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": "I paautorizuar",
|
||||||
"pad.modals.unauth.explanation": "Lejet tuaja ndryshuan teksa shihnit këtë dritare. Provoni të rilidheni.",
|
"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.",
|
"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": "Shërbyesi është i pakapshëm.",
|
||||||
"pad.modals.initsocketfail.explanation": "S’u lidh dot te shërbyesi i njëkohësimit.",
|
"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.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.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.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.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.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.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ë.",
|
"pad.modals.deleted": "I fshirë.",
|
||||||
|
@ -131,7 +131,7 @@
|
||||||
"timeslider.pageTitle": "Rrjedhë kohore e {{appTitle}}",
|
"timeslider.pageTitle": "Rrjedhë kohore e {{appTitle}}",
|
||||||
"timeslider.toolbar.returnbutton": "Rikthehuni te blloku",
|
"timeslider.toolbar.returnbutton": "Rikthehuni te blloku",
|
||||||
"timeslider.toolbar.authors": "Autorë:",
|
"timeslider.toolbar.authors": "Autorë:",
|
||||||
"timeslider.toolbar.authorsList": "S’ka autorë",
|
"timeslider.toolbar.authorsList": "S’ka Autorë",
|
||||||
"timeslider.toolbar.exportlink.title": "Eksportoje",
|
"timeslider.toolbar.exportlink.title": "Eksportoje",
|
||||||
"timeslider.exportCurrent": "Eksportojeni versionin e tanishëm si:",
|
"timeslider.exportCurrent": "Eksportojeni versionin e tanishëm si:",
|
||||||
"timeslider.version": "Versioni {{version}}",
|
"timeslider.version": "Versioni {{version}}",
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
"timeslider.month.march": "Marso",
|
"timeslider.month.march": "Marso",
|
||||||
"timeslider.month.april": "Apriłe",
|
"timeslider.month.april": "Apriłe",
|
||||||
"timeslider.month.may": "Majo",
|
"timeslider.month.may": "Majo",
|
||||||
"timeslider.month.june": "Xugno",
|
"timeslider.month.june": "Zugno",
|
||||||
"timeslider.month.july": "Lujo",
|
"timeslider.month.july": "Lujo",
|
||||||
"timeslider.month.august": "Agosto",
|
"timeslider.month.august": "Agosto",
|
||||||
"timeslider.month.september": "Setenbre",
|
"timeslider.month.september": "Setenbre",
|
||||||
|
|
|
@ -831,7 +831,7 @@ exports.getStats = async () => {
|
||||||
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
|
const isInt = (value) => (parseFloat(value) === parseInt(value, 10)) && !isNaN(value);
|
||||||
|
|
||||||
// gets a pad safe
|
// gets a pad safe
|
||||||
async function getPadSafe(padID, shouldExist, text) {
|
const getPadSafe = async (padID, shouldExist, text) => {
|
||||||
// check if padID is a string
|
// check if padID is a string
|
||||||
if (typeof padID !== 'string') {
|
if (typeof padID !== 'string') {
|
||||||
throw new CustomError('padID is not a string', 'apierror');
|
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
|
// pad exists, let's get it
|
||||||
return padManager.getPad(padID, text);
|
return padManager.getPad(padID, text);
|
||||||
}
|
};
|
||||||
|
|
||||||
// checks if a rev is a legal number
|
// checks if a rev is a legal number
|
||||||
// pre-condition is that `rev` is not undefined
|
// 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} mapperkey The database key name for this mapper
|
||||||
* @param {String} mapper The mapper
|
* @param {String} mapper The mapper
|
||||||
*/
|
*/
|
||||||
async function mapAuthorWithDBKey(mapperkey, mapper) {
|
const mapAuthorWithDBKey = async (mapperkey, mapper) => {
|
||||||
// try to map to an author
|
// try to map to an author
|
||||||
const author = await db.get(`${mapperkey}:${mapper}`);
|
const author = await db.get(`${mapperkey}:${mapper}`);
|
||||||
|
|
||||||
|
@ -156,13 +156,13 @@ async function mapAuthorWithDBKey(mapperkey, mapper) {
|
||||||
|
|
||||||
// return the author
|
// return the author
|
||||||
return {authorID: author};
|
return {authorID: author};
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal function that creates the database entry for an author
|
* Internal function that creates the database entry for an author
|
||||||
* @param {String} name The name of the author
|
* @param {String} name The name of the author
|
||||||
*/
|
*/
|
||||||
exports.createAuthor = (name) => {
|
exports.createAuthor = async (name) => {
|
||||||
// create the new author name
|
// create the new author name
|
||||||
const author = `a.${randomString(16)}`;
|
const author = `a.${randomString(16)}`;
|
||||||
|
|
||||||
|
@ -174,8 +174,7 @@ exports.createAuthor = (name) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// set the global author db entry
|
// set the global author db entry
|
||||||
// NB: no await, since we're not waiting for the DB set to finish
|
await db.set(`globalAuthor:${author}`, authorObj);
|
||||||
db.set(`globalAuthor:${author}`, authorObj);
|
|
||||||
|
|
||||||
return {authorID: author};
|
return {authorID: author};
|
||||||
};
|
};
|
||||||
|
@ -184,34 +183,35 @@ exports.createAuthor = (name) => {
|
||||||
* Returns the Author Obj of the author
|
* Returns the Author Obj of the author
|
||||||
* @param {String} author The id 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
|
* Returns the color Id of the author
|
||||||
* @param {String} author The 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
|
* Sets the color Id of the author
|
||||||
* @param {String} author The id of the author
|
* @param {String} author The id of the author
|
||||||
* @param {String} colorId The color 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);
|
`globalAuthor:${author}`, ['colorId'], colorId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the name of the author
|
* Returns the name of the author
|
||||||
* @param {String} author The id 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
|
* Sets the name of the author
|
||||||
* @param {String} author The id of the author
|
* @param {String} author The id of the author
|
||||||
* @param {String} name The name 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
|
* 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
|
author.padIDs[padID] = 1; // anything, because value is not used
|
||||||
|
|
||||||
// save the new element back
|
// 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
|
// set database settings
|
||||||
const db =
|
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
|
* 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(/\t/g, ' ')
|
||||||
.replace(/\xa0/g, ' ');
|
.replace(/\xa0/g, ' ');
|
||||||
|
|
||||||
const Pad = function Pad(id) {
|
const Pad = function (id) {
|
||||||
this.atext = Changeset.makeAText('\n');
|
this.atext = Changeset.makeAText('\n');
|
||||||
this.pool = new AttributePool();
|
this.pool = new AttributePool();
|
||||||
this.head = -1;
|
this.head = -1;
|
||||||
|
@ -44,32 +44,29 @@ const Pad = function Pad(id) {
|
||||||
|
|
||||||
exports.Pad = Pad;
|
exports.Pad = Pad;
|
||||||
|
|
||||||
Pad.prototype.apool = function apool() {
|
Pad.prototype.apool = function () {
|
||||||
return this.pool;
|
return this.pool;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getHeadRevisionNumber = function getHeadRevisionNumber() {
|
Pad.prototype.getHeadRevisionNumber = function () {
|
||||||
return this.head;
|
return this.head;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getSavedRevisionsNumber = function getSavedRevisionsNumber() {
|
Pad.prototype.getSavedRevisionsNumber = function () {
|
||||||
return this.savedRevisions.length;
|
return this.savedRevisions.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getSavedRevisionsList = function getSavedRevisionsList() {
|
Pad.prototype.getSavedRevisionsList = function () {
|
||||||
const savedRev = [];
|
const savedRev = this.savedRevisions.map((rev) => rev.revNum);
|
||||||
for (const rev in this.savedRevisions) {
|
|
||||||
savedRev.push(this.savedRevisions[rev].revNum);
|
|
||||||
}
|
|
||||||
savedRev.sort((a, b) => a - b);
|
savedRev.sort((a, b) => a - b);
|
||||||
return savedRev;
|
return savedRev;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getPublicStatus = function getPublicStatus() {
|
Pad.prototype.getPublicStatus = function () {
|
||||||
return this.publicStatus;
|
return this.publicStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.appendRevision = async function appendRevision(aChangeset, author) {
|
Pad.prototype.appendRevision = async function (aChangeset, author) {
|
||||||
if (!author) {
|
if (!author) {
|
||||||
author = '';
|
author = '';
|
||||||
}
|
}
|
||||||
|
@ -115,7 +112,7 @@ Pad.prototype.appendRevision = async function appendRevision(aChangeset, author)
|
||||||
};
|
};
|
||||||
|
|
||||||
// save all attributes to the database
|
// save all attributes to the database
|
||||||
Pad.prototype.saveToDatabase = async function saveToDatabase() {
|
Pad.prototype.saveToDatabase = async function () {
|
||||||
const dbObject = {};
|
const dbObject = {};
|
||||||
|
|
||||||
for (const attr in this) {
|
for (const attr in this) {
|
||||||
|
@ -133,24 +130,24 @@ Pad.prototype.saveToDatabase = async function saveToDatabase() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// get time of last edit (changeset application)
|
// get time of last edit (changeset application)
|
||||||
Pad.prototype.getLastEdit = function getLastEdit() {
|
Pad.prototype.getLastEdit = function () {
|
||||||
const revNum = this.getHeadRevisionNumber();
|
const revNum = this.getHeadRevisionNumber();
|
||||||
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
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']);
|
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']);
|
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']);
|
return db.getSub(`pad:${this.id}:revs:${revNum}`, ['meta', 'timestamp']);
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getAllAuthors = function getAllAuthors() {
|
Pad.prototype.getAllAuthors = function () {
|
||||||
const authors = [];
|
const authors = [];
|
||||||
|
|
||||||
for (const key in this.pool.numToAttrib) {
|
for (const key in this.pool.numToAttrib) {
|
||||||
|
@ -162,7 +159,7 @@ Pad.prototype.getAllAuthors = function getAllAuthors() {
|
||||||
return authors;
|
return authors;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText(targetRev) {
|
Pad.prototype.getInternalRevisionAText = async function (targetRev) {
|
||||||
const keyRev = this.getKeyRevisionNumber(targetRev);
|
const keyRev = this.getKeyRevisionNumber(targetRev);
|
||||||
|
|
||||||
// find out which changesets are needed
|
// find out which changesets are needed
|
||||||
|
@ -197,11 +194,11 @@ Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText
|
||||||
return atext;
|
return atext;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getRevision = function getRevisionChangeset(revNum) {
|
Pad.prototype.getRevision = function (revNum) {
|
||||||
return db.get(`pad:${this.id}:revs:${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 authors = this.getAllAuthors();
|
||||||
const returnTable = {};
|
const returnTable = {};
|
||||||
const colorPalette = authorManager.getColorPalette();
|
const colorPalette = authorManager.getColorPalette();
|
||||||
|
@ -215,7 +212,7 @@ Pad.prototype.getAllAuthorColors = async function getAllAuthorColors() {
|
||||||
return returnTable;
|
return returnTable;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, endRev) {
|
Pad.prototype.getValidRevisionRange = function (startRev, endRev) {
|
||||||
startRev = parseInt(startRev, 10);
|
startRev = parseInt(startRev, 10);
|
||||||
const head = this.getHeadRevisionNumber();
|
const head = this.getHeadRevisionNumber();
|
||||||
endRev = endRev ? parseInt(endRev, 10) : head;
|
endRev = endRev ? parseInt(endRev, 10) : head;
|
||||||
|
@ -236,15 +233,15 @@ Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, e
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getKeyRevisionNumber = function getKeyRevisionNumber(revNum) {
|
Pad.prototype.getKeyRevisionNumber = function (revNum) {
|
||||||
return Math.floor(revNum / 100) * 100;
|
return Math.floor(revNum / 100) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.text = function text() {
|
Pad.prototype.text = function () {
|
||||||
return this.atext.text;
|
return this.atext.text;
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.setText = async function setText(newText) {
|
Pad.prototype.setText = async function (newText) {
|
||||||
// clean the new text
|
// clean the new text
|
||||||
newText = exports.cleanText(newText);
|
newText = exports.cleanText(newText);
|
||||||
|
|
||||||
|
@ -264,7 +261,7 @@ Pad.prototype.setText = async function setText(newText) {
|
||||||
await this.appendRevision(changeset);
|
await this.appendRevision(changeset);
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.appendText = async function appendText(newText) {
|
Pad.prototype.appendText = async function (newText) {
|
||||||
// clean the new text
|
// clean the new text
|
||||||
newText = exports.cleanText(newText);
|
newText = exports.cleanText(newText);
|
||||||
|
|
||||||
|
@ -277,7 +274,7 @@ Pad.prototype.appendText = async function appendText(newText) {
|
||||||
await this.appendRevision(changeset);
|
await this.appendRevision(changeset);
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.appendChatMessage = async function appendChatMessage(text, userId, time) {
|
Pad.prototype.appendChatMessage = async function (text, userId, time) {
|
||||||
this.chatHead++;
|
this.chatHead++;
|
||||||
// save the chat entry in the database
|
// save the chat entry in the database
|
||||||
await Promise.all([
|
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
|
// get the chat entry
|
||||||
const entry = await db.get(`pad:${this.id}:chat:${entryNum}`);
|
const entry = await db.get(`pad:${this.id}:chat:${entryNum}`);
|
||||||
|
|
||||||
|
@ -298,7 +295,7 @@ Pad.prototype.getChatMessage = async function getChatMessage(entryNum) {
|
||||||
return entry;
|
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
|
// collect the numbers of chat entries and in which order we need them
|
||||||
const neededEntries = [];
|
const neededEntries = [];
|
||||||
for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) {
|
for (let order = 0, entryNum = start; entryNum <= end; ++order, ++entryNum) {
|
||||||
|
@ -326,7 +323,7 @@ Pad.prototype.getChatMessages = async function getChatMessages(start, end) {
|
||||||
return cleanedEntries;
|
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
|
// replace text with default text if text isn't set
|
||||||
if (text == null) {
|
if (text == null) {
|
||||||
text = settings.defaultPadText;
|
text = settings.defaultPadText;
|
||||||
|
@ -355,8 +352,7 @@ Pad.prototype.init = async function init(text) {
|
||||||
hooks.callAll('padLoad', {pad: this});
|
hooks.callAll('padLoad', {pad: this});
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.copy = async function copy(destinationID, force) {
|
Pad.prototype.copy = async function (destinationID, force) {
|
||||||
let destGroupID;
|
|
||||||
const sourceID = this.id;
|
const sourceID = this.id;
|
||||||
|
|
||||||
// Kick everyone from this pad.
|
// Kick everyone from this pad.
|
||||||
|
@ -367,16 +363,11 @@ Pad.prototype.copy = async function copy(destinationID, force) {
|
||||||
// flush the source pad:
|
// flush the source pad:
|
||||||
await this.saveToDatabase();
|
await this.saveToDatabase();
|
||||||
|
|
||||||
|
// if it's a group pad, let's make sure the group exists.
|
||||||
|
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||||
|
|
||||||
try {
|
// if force is true and already exists a Pad with the same id, remove that Pad
|
||||||
// if it's a group pad, let's make sure the group exists.
|
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
||||||
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
|
// copy the 'pad' entry
|
||||||
const pad = await db.get(`pad:${sourceID}`);
|
const pad = await db.get(`pad:${sourceID}`);
|
||||||
|
@ -423,7 +414,7 @@ Pad.prototype.copy = async function copy(destinationID, force) {
|
||||||
return {padID: destinationID};
|
return {padID: destinationID};
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAndReturnIt(destinationID) {
|
Pad.prototype.checkIfGroupExistAndReturnIt = async function (destinationID) {
|
||||||
let destGroupID = false;
|
let destGroupID = false;
|
||||||
|
|
||||||
if (destinationID.indexOf('$') >= 0) {
|
if (destinationID.indexOf('$') >= 0) {
|
||||||
|
@ -438,7 +429,7 @@ Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAnd
|
||||||
return destGroupID;
|
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.
|
// if the pad exists, we should abort, unless forced.
|
||||||
const exists = await padManager.doesPadExist(destinationID);
|
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
|
// add the new sourcePad to all authors who contributed to the old one
|
||||||
this.getAllAuthors().forEach((authorID) => {
|
this.getAllAuthors().forEach((authorID) => {
|
||||||
authorManager.addPad(authorID, destinationID);
|
authorManager.addPad(authorID, destinationID);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.copyPadWithoutHistory = async function copyPadWithoutHistory(destinationID, force) {
|
Pad.prototype.copyPadWithoutHistory = async function (destinationID, force) {
|
||||||
let destGroupID;
|
|
||||||
const sourceID = this.id;
|
const sourceID = this.id;
|
||||||
|
|
||||||
// flush the source pad
|
// flush the source pad
|
||||||
this.saveToDatabase();
|
this.saveToDatabase();
|
||||||
|
|
||||||
try {
|
// if it's a group pad, let's make sure the group exists.
|
||||||
// if it's a group pad, let's make sure the group exists.
|
const destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
||||||
destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID);
|
|
||||||
|
|
||||||
// if force is true and already exists a Pad with the same id, remove that Pad
|
// if force is true and already exists a Pad with the same id, remove that Pad
|
||||||
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force);
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePad = await padManager.getPad(sourceID);
|
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 padID = this.id;
|
||||||
const p = [];
|
const p = [];
|
||||||
|
|
||||||
|
@ -579,12 +565,12 @@ Pad.prototype.remove = async function remove() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// set in db
|
// set in db
|
||||||
Pad.prototype.setPublicStatus = async function setPublicStatus(publicStatus) {
|
Pad.prototype.setPublicStatus = async function (publicStatus) {
|
||||||
this.publicStatus = publicStatus;
|
this.publicStatus = publicStatus;
|
||||||
await this.saveToDatabase();
|
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
|
// if this revision is already saved, return silently
|
||||||
for (const i in this.savedRevisions) {
|
for (const i in this.savedRevisions) {
|
||||||
if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {
|
if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) {
|
||||||
|
@ -605,6 +591,6 @@ Pad.prototype.addSavedRevision = async function addSavedRevision(revNum, savedBy
|
||||||
await this.saveToDatabase();
|
await this.saveToDatabase();
|
||||||
};
|
};
|
||||||
|
|
||||||
Pad.prototype.getSavedRevisions = function getSavedRevisions() {
|
Pad.prototype.getSavedRevisions = function () {
|
||||||
return this.savedRevisions;
|
return this.savedRevisions;
|
||||||
};
|
};
|
||||||
|
|
|
@ -28,7 +28,7 @@ const randomString = require('../utils/randomstring');
|
||||||
* checks if the id pattern matches a read-only pad id
|
* checks if the id pattern matches a read-only pad id
|
||||||
* @param {String} the pad's 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
|
* 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
|
* @param {String} padIdOrReadonlyPadId read only id or real pad id
|
||||||
*/
|
*/
|
||||||
exports.getIds = async (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
|
// Might be null, if this is an unknown read-only id
|
||||||
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id);
|
const readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id);
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
const authorManager = require('./AuthorManager');
|
const authorManager = require('./AuthorManager');
|
||||||
const hooks = require('../../static/js/pluginfw/hooks.js');
|
const hooks = require('../../static/js/pluginfw/hooks.js');
|
||||||
const padManager = require('./PadManager');
|
const padManager = require('./PadManager');
|
||||||
|
const readOnlyManager = require('./ReadOnlyManager');
|
||||||
const sessionManager = require('./SessionManager');
|
const sessionManager = require('./SessionManager');
|
||||||
const settings = require('../utils/Settings');
|
const settings = require('../utils/Settings');
|
||||||
const webaccess = require('../hooks/express/webaccess');
|
const webaccess = require('../hooks/express/webaccess');
|
||||||
|
@ -56,6 +57,15 @@ exports.checkAccess = async (padID, sessionCookie, token, userSettings) => {
|
||||||
|
|
||||||
let canCreate = !settings.editOnly;
|
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.
|
// Authentication and authorization checks.
|
||||||
if (settings.loadTest) {
|
if (settings.loadTest) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
|
@ -250,7 +250,7 @@ const listSessionsWithDBKey = async (dbkey) => {
|
||||||
const sessions = sessionObject ? sessionObject.sessionIDs : null;
|
const sessions = sessionObject ? sessionObject.sessionIDs : null;
|
||||||
|
|
||||||
// iterate through the sessions and get the sessioninfos
|
// iterate through the sessions and get the sessioninfos
|
||||||
for (const sessionID in sessions) {
|
for (const sessionID of Object.keys(sessions || {})) {
|
||||||
try {
|
try {
|
||||||
const sessionInfo = await exports.getSessionInfo(sessionID);
|
const sessionInfo = await exports.getSessionInfo(sessionID);
|
||||||
sessions[sessionID] = sessionInfo;
|
sessions[sessionID] = sessionInfo;
|
||||||
|
|
|
@ -42,7 +42,7 @@ const runTests = () => {
|
||||||
|
|
||||||
const literal = (v) => {
|
const literal = (v) => {
|
||||||
if ((typeof v) === 'string') {
|
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); }
|
} else { return JSON.stringify(v); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -50,20 +50,35 @@ exports.socketio = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A associative array that saves information about a session
|
* Contains information about socket.io connections:
|
||||||
* key = sessionId
|
* - key: Socket.io socket ID.
|
||||||
* values = padId, readonlyPadId, readonly, author, rev
|
* - value: Object that is initially empty immediately after connect. Once the client's
|
||||||
* padId = the real padId of the pad
|
* CLIENT_READY message is processed, it has the following properties:
|
||||||
* readonlyPadId = The readonly pad id of the pad
|
* - auth: Object with the following properties copied from the client's CLIENT_READY message:
|
||||||
* readonly = Wether the client has only read access (true) or read/write access (false)
|
* - padID: Pad ID requested by the user. Unlike the padId property described below, this
|
||||||
* rev = That last revision that was send to this client
|
* may be a read-only pad ID.
|
||||||
* author = the author ID used for this session
|
* - 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 = {};
|
const sessioninfos = {};
|
||||||
exports.sessioninfos = sessioninfos;
|
exports.sessioninfos = sessioninfos;
|
||||||
|
|
||||||
// Measure total amount of users
|
|
||||||
stats.gauge('totalUsers', () => Object.keys(socketio.sockets.sockets).length);
|
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()
|
* A changeset queue per pad that is processed by handleUserChanges()
|
||||||
|
@ -94,18 +109,6 @@ exports.handleConnect = (socket) => {
|
||||||
|
|
||||||
// Initialize sessioninfos for this new session
|
// Initialize sessioninfos for this new session
|
||||||
sessioninfos[socket.id] = {};
|
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;
|
const auth = thisSession.auth;
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
console.error('Auth was never applied to a session. If you are using the ' +
|
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
|
||||||
'stress-test tool then restart Etherpad and the Stress test tool.');
|
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;
|
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 {session: {user} = {}} = socket.client.request;
|
||||||
const {accessStatus, authorID} =
|
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') {
|
if (accessStatus !== 'grant') {
|
||||||
// Access denied. Send the reason to the user.
|
// Access denied. Send the reason to the user.
|
||||||
socket.json.send({accessStatus});
|
socket.json.send({accessStatus});
|
||||||
|
@ -717,13 +715,13 @@ exports.updatePadClients = async (pad) => {
|
||||||
// but benefit of reusing cached revision object is HUGE
|
// but benefit of reusing cached revision object is HUGE
|
||||||
const revCache = {};
|
const revCache = {};
|
||||||
|
|
||||||
// go through all sessions on this pad
|
await Promise.all(roomSockets.map(async (socket) => {
|
||||||
for (const socket of roomSockets) {
|
const sessioninfo = sessioninfos[socket.id];
|
||||||
const sid = socket.id;
|
// The user might have disconnected since _getRoomSockets() was called.
|
||||||
|
if (sessioninfo == null) return;
|
||||||
|
|
||||||
// send them all new changesets
|
while (sessioninfo.rev < pad.getHeadRevisionNumber()) {
|
||||||
while (sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()) {
|
const r = sessioninfo.rev + 1;
|
||||||
const r = sessioninfos[sid].rev + 1;
|
|
||||||
let revision = revCache[r];
|
let revision = revCache[r];
|
||||||
if (!revision) {
|
if (!revision) {
|
||||||
revision = await pad.getRevision(r);
|
revision = await pad.getRevision(r);
|
||||||
|
@ -734,33 +732,34 @@ exports.updatePadClients = async (pad) => {
|
||||||
const revChangeset = revision.changeset;
|
const revChangeset = revision.changeset;
|
||||||
const currentTime = revision.meta.timestamp;
|
const currentTime = revision.meta.timestamp;
|
||||||
|
|
||||||
// next if session has not been deleted
|
let msg;
|
||||||
if (sessioninfos[sid] == null) {
|
if (author === sessioninfo.author) {
|
||||||
continue;
|
msg = {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev: r}};
|
||||||
}
|
|
||||||
|
|
||||||
if (author === sessioninfos[sid].author) {
|
|
||||||
socket.json.send({type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev: r}});
|
|
||||||
} else {
|
} else {
|
||||||
const forWire = Changeset.prepareForWire(revChangeset, pad.pool);
|
const forWire = Changeset.prepareForWire(revChangeset, pad.pool);
|
||||||
const wireMsg = {type: 'COLLABROOM',
|
msg = {
|
||||||
data: {type: 'NEW_CHANGES',
|
type: 'COLLABROOM',
|
||||||
|
data: {
|
||||||
|
type: 'NEW_CHANGES',
|
||||||
newRev: r,
|
newRev: r,
|
||||||
changeset: forWire.translated,
|
changeset: forWire.translated,
|
||||||
apool: forWire.pool,
|
apool: forWire.pool,
|
||||||
author,
|
author,
|
||||||
currentTime,
|
currentTime,
|
||||||
timeDelta: currentTime - sessioninfos[sid].time}};
|
timeDelta: currentTime - sessioninfo.time,
|
||||||
|
},
|
||||||
socket.json.send(wireMsg);
|
};
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
if (sessioninfos[sid]) {
|
socket.json.send(msg);
|
||||||
sessioninfos[sid].time = currentTime;
|
} catch (err) {
|
||||||
sessioninfos[sid].rev = r;
|
messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
sessioninfo.time = currentTime;
|
||||||
|
sessioninfo.rev = r;
|
||||||
}
|
}
|
||||||
}
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1122,72 +1121,61 @@ const handleClientReady = async (socket, message, authorID) => {
|
||||||
|
|
||||||
// Save the current revision in sessioninfos, should be the same as in clientVars
|
// Save the current revision in sessioninfos, should be the same as in clientVars
|
||||||
sessionInfo.rev = pad.getHeadRevisionNumber();
|
sessionInfo.rev = pad.getHeadRevisionNumber();
|
||||||
|
}
|
||||||
|
|
||||||
// prepare the notification for the other users on the pad, that this user joined
|
// Notify other users about this new user.
|
||||||
const messageToTheOtherUsers = {
|
socket.broadcast.to(padIds.padId).json.send({
|
||||||
|
type: 'COLLABROOM',
|
||||||
|
data: {
|
||||||
|
type: 'USER_NEWINFO',
|
||||||
|
userInfo: {
|
||||||
|
colorId: authorColorId,
|
||||||
|
name: authorName,
|
||||||
|
userId: authorID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify this new user about other users.
|
||||||
|
await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {
|
||||||
|
if (roomSocket.id === socket.id) return;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// get the authorname & colorId
|
||||||
|
const authorId = sessionInfo.author;
|
||||||
|
// The authorId of this other user might be unknown if the other user just connected and has
|
||||||
|
// not yet sent a CLIENT_READY message.
|
||||||
|
if (authorId == null) return;
|
||||||
|
|
||||||
|
// reuse previously created cache of author's data
|
||||||
|
const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId);
|
||||||
|
if (authorInfo == null) {
|
||||||
|
messageLogger.error(
|
||||||
|
`Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` +
|
||||||
|
'the global author database. This should never happen because the author ID is ' +
|
||||||
|
'generated by the same code that adds the author to the database.');
|
||||||
|
// Don't bother telling the new user about this mystery author.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = {
|
||||||
type: 'COLLABROOM',
|
type: 'COLLABROOM',
|
||||||
data: {
|
data: {
|
||||||
type: 'USER_NEWINFO',
|
type: 'USER_NEWINFO',
|
||||||
userInfo: {
|
userInfo: {
|
||||||
colorId: authorColorId,
|
colorId: authorInfo.colorId,
|
||||||
userId: authorID,
|
name: authorInfo.name,
|
||||||
|
userId: authorId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the authorname of this new User, if avaiable
|
socket.json.send(msg);
|
||||||
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)
|
|
||||||
await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {
|
|
||||||
// Jump over, if this session is the connection session
|
|
||||||
if (roomSocket.id === socket.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since sessioninfos might change while being enumerated, check if the
|
|
||||||
// sessionID is still assigned to a valid session
|
|
||||||
const sessionInfo = sessioninfos[roomSocket.id];
|
|
||||||
if (sessionInfo == null) return;
|
|
||||||
|
|
||||||
// get the authorname & colorId
|
|
||||||
const authorId = sessionInfo.author;
|
|
||||||
// The authorId of this other user might be unknown if the other user just connected and has
|
|
||||||
// not yet sent a CLIENT_READY message.
|
|
||||||
if (authorId == null) return;
|
|
||||||
|
|
||||||
// reuse previously created cache of author's data
|
|
||||||
const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId);
|
|
||||||
if (authorInfo == null) {
|
|
||||||
messageLogger.error(
|
|
||||||
`Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` +
|
|
||||||
'the global author database. This should never happen because the author ID is ' +
|
|
||||||
'generated by the same code that adds the author to the database.');
|
|
||||||
// Don't bother telling the new user about this mystery author.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the new User a Notification about this other user
|
|
||||||
const msg = {
|
|
||||||
type: 'COLLABROOM',
|
|
||||||
data: {
|
|
||||||
type: 'USER_NEWINFO',
|
|
||||||
userInfo: {
|
|
||||||
colorId: authorInfo.colorId,
|
|
||||||
name: authorInfo.name,
|
|
||||||
userId: authorId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.json.send(msg);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const eejs = require('../../eejs');
|
const eejs = require('../../eejs');
|
||||||
|
const fs = require('fs');
|
||||||
|
const fsp = fs.promises;
|
||||||
const toolbar = require('../../utils/toolbar');
|
const toolbar = require('../../utils/toolbar');
|
||||||
const hooks = require('../../../static/js/pluginfw/hooks');
|
const hooks = require('../../../static/js/pluginfw/hooks');
|
||||||
const settings = require('../../utils/Settings');
|
const settings = require('../../utils/Settings');
|
||||||
|
const util = require('util');
|
||||||
const webaccess = require('./webaccess');
|
const webaccess = require('./webaccess');
|
||||||
|
|
||||||
exports.expressCreateServer = (hookName, args, cb) => {
|
exports.expressCreateServer = (hookName, args, cb) => {
|
||||||
|
@ -46,14 +49,15 @@ exports.expressCreateServer = (hookName, args, cb) => {
|
||||||
// serve pad.html under /p
|
// serve pad.html under /p
|
||||||
args.app.get('/p/:pad', (req, res, next) => {
|
args.app.get('/p/:pad', (req, res, next) => {
|
||||||
// The below might break for pads being rewritten
|
// The below might break for pads being rewritten
|
||||||
const isReadOnly =
|
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
|
||||||
req.url.indexOf('/p/r.') === 0 || !webaccess.userCanModify(req.params.pad, req);
|
|
||||||
|
|
||||||
hooks.callAll('padInitToolbar', {
|
hooks.callAll('padInitToolbar', {
|
||||||
toolbar,
|
toolbar,
|
||||||
isReadOnly,
|
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', {
|
res.send(eejs.require('ep_etherpad-lite/templates/pad.html', {
|
||||||
req,
|
req,
|
||||||
toolbar,
|
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, next) => {
|
||||||
args.app.get(/\/favicon.ico$/, (req, res) => {
|
(async () => {
|
||||||
let filePath = path.join(
|
const fns = [
|
||||||
settings.root,
|
...(settings.favicon ? [path.resolve(settings.root, settings.favicon)] : []),
|
||||||
'src',
|
path.join(settings.root, 'src', 'static', 'skins', settings.skinName, 'favicon.ico'),
|
||||||
'static',
|
path.join(settings.root, 'src', 'static', 'favicon.ico'),
|
||||||
'skins',
|
];
|
||||||
settings.skinName,
|
for (const fn of fns) {
|
||||||
'favicon.ico'
|
try {
|
||||||
);
|
await fsp.access(fn, fs.constants.R_OK);
|
||||||
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
|
} catch (err) {
|
||||||
res.sendFile(filePath, (err) => {
|
continue;
|
||||||
// there is no custom favicon, send the default favicon
|
}
|
||||||
if (err) {
|
res.setHeader('Cache-Control', `public, max-age=${settings.maxAge}`);
|
||||||
filePath = path.join(settings.root, 'src', 'static', 'favicon.ico');
|
await util.promisify(res.sendFile.bind(res))(fn);
|
||||||
res.sendFile(filePath);
|
return;
|
||||||
}
|
}
|
||||||
});
|
next();
|
||||||
|
})().catch((err) => next(err || new Error(err)));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cb();
|
return cb();
|
||||||
|
|
|
@ -1,98 +1,82 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fsp = require('fs').promises;
|
||||||
const util = require('util');
|
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||||
|
const sanitizePathname = require('../../utils/sanitizePathname');
|
||||||
const settings = require('../../utils/Settings');
|
const settings = require('../../utils/Settings');
|
||||||
|
|
||||||
exports.expressCreateServer = (hookName, args, cb) => {
|
// Returns all *.js files under specDir (recursively) as relative paths to specDir, using '/'
|
||||||
args.app.get('/tests/frontend/specs_list.js', async (req, res) => {
|
// instead of path.sep to separate pathname components.
|
||||||
const [coreTests, pluginTests] = await Promise.all([
|
const findSpecs = async (specDir) => {
|
||||||
exports.getCoreTests(),
|
let dirents;
|
||||||
exports.getPluginTests(),
|
try {
|
||||||
]);
|
dirents = await fsp.readdir(specDir, {withFileTypes: true});
|
||||||
|
} catch (err) {
|
||||||
// merge the two sets of results
|
if (['ENOENT', 'ENOTDIR'].includes(err.code)) return [];
|
||||||
let files = [].concat(coreTests, pluginTests).sort();
|
throw err;
|
||||||
|
}
|
||||||
// Keep only *.js files
|
const specs = [];
|
||||||
files = files.filter((f) => f.endsWith('.js'));
|
await Promise.all(dirents.map(async (dirent) => {
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
// remove admin tests if the setting to enable them isn't in settings.json
|
const subdirSpecs = await findSpecs(path.join(specDir, dirent.name));
|
||||||
if (!settings.enableAdminUITests) {
|
specs.push(...subdirSpecs.map((spec) => `${dirent.name}/${spec}`));
|
||||||
files = files.filter((file) => file.indexOf('admin') !== 0);
|
return;
|
||||||
}
|
}
|
||||||
|
if (!dirent.name.endsWith('.js')) return;
|
||||||
|
specs.push(dirent.name);
|
||||||
|
}));
|
||||||
|
return specs;
|
||||||
|
};
|
||||||
|
|
||||||
console.debug('Sent browser the following test specs:', files);
|
exports.expressCreateServer = (hookName, args, cb) => {
|
||||||
res.setHeader('content-type', 'application/javascript');
|
args.app.get('/tests/frontend/frontendTestSpecs.json', (req, res, next) => {
|
||||||
res.end(`var specs_list = ${JSON.stringify(files)};\n`);
|
(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 rootTestFolder = path.join(settings.root, 'src/tests/frontend/');
|
||||||
|
|
||||||
const url2FilePath = (url) => {
|
args.app.get('/tests/frontend/index.html', (req, res) => {
|
||||||
let subPath = url.substr('/tests/frontend'.length);
|
res.redirect(['./', ...req.url.split('?').slice(1)].join('?'));
|
||||||
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/*', (req, res) => {
|
// The regexp /[\d\D]{0,}/ is equivalent to the regexp /.*/. The Express route path used here
|
||||||
const filePath = url2FilePath(req.url);
|
// uses the more verbose /[\d\D]{0,}/ pattern instead of /.*/ because path-to-regexp v0.1.7 (the
|
||||||
res.sendFile(filePath);
|
// 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) => {
|
args.app.get('/tests/frontend', (req, res) => {
|
||||||
res.redirect('/tests/frontend/index.html');
|
res.redirect(['./frontend/', ...req.url.split('?').slice(1)].join('?'));
|
||||||
});
|
});
|
||||||
|
|
||||||
return cb();
|
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',
|
'robots.txt',
|
||||||
'static/.*',
|
'static/.*',
|
||||||
'stats/?',
|
'stats/?',
|
||||||
'tests/frontend(?:/.*)?'
|
'tests/frontend(?:/.*)?',
|
||||||
].join('|')})$`);
|
].join('|')})$`);
|
||||||
|
|
||||||
exports.normalizeAuthzLevel = (level) => {
|
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
|
// 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).
|
// authentication is checked and once after (if settings.requireAuthorization is true).
|
||||||
const authorize = async () => {
|
const authorize = async () => {
|
||||||
const grant = (level) => {
|
const grant = async (level) => {
|
||||||
level = exports.normalizeAuthzLevel(level);
|
level = exports.normalizeAuthzLevel(level);
|
||||||
if (!level) return false;
|
if (!level) return false;
|
||||||
const user = req.session.user;
|
const user = req.session.user;
|
||||||
if (user == null) return true; // This will happen if authentication is not required.
|
if (user == null) return true; // This will happen if authentication is not required.
|
||||||
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
|
const encodedPadId = (req.path.match(/^\/p\/([^/]*)/) || [])[1];
|
||||||
if (encodedPadId == null) return true;
|
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
|
// 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.
|
// settings so that SecurityManager can approve or deny specific actions.
|
||||||
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
if (user.padAuthorizations == null) user.padAuthorizations = {};
|
||||||
|
@ -85,13 +90,13 @@ const checkAccess = async (req, res, next) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const isAuthenticated = req.session && req.session.user;
|
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;
|
const requireAuthn = requireAdmin || settings.requireAuthentication;
|
||||||
if (!requireAuthn) return grant('create');
|
if (!requireAuthn) return await grant('create');
|
||||||
if (!isAuthenticated) return grant(false);
|
if (!isAuthenticated) return await grant(false);
|
||||||
if (requireAdmin && !req.session.user.is_admin) return grant(false);
|
if (requireAdmin && !req.session.user.is_admin) return await grant(false);
|
||||||
if (!settings.requireAuthorization) return grant('create');
|
if (!settings.requireAuthorization) return await grant('create');
|
||||||
return grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
|
return await grant(await aCallFirst0('authorize', {req, res, next, resource: req.path}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
// ///////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -3,21 +3,16 @@ const securityManager = require('./db/SecurityManager');
|
||||||
|
|
||||||
// checks for padAccess
|
// checks for padAccess
|
||||||
module.exports = async (req, res) => {
|
module.exports = async (req, res) => {
|
||||||
try {
|
const {session: {user} = {}} = req;
|
||||||
const {session: {user} = {}} = req;
|
const accessObj = await securityManager.checkAccess(
|
||||||
const accessObj = await securityManager.checkAccess(
|
req.params.pad, req.cookies.sessionID, req.cookies.token, user);
|
||||||
req.params.pad, req.cookies.sessionID, req.cookies.token, user);
|
|
||||||
|
|
||||||
if (accessObj.accessStatus === 'grant') {
|
if (accessObj.accessStatus === 'grant') {
|
||||||
// there is access, continue
|
// there is access, continue
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// no access
|
// no access
|
||||||
res.status(403).send("403 - Can't touch this");
|
res.status(403).send("403 - Can't touch this");
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// @TODO - send internal server error here?
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,17 +27,22 @@
|
||||||
const log4js = require('log4js');
|
const log4js = require('log4js');
|
||||||
log4js.replaceConsole();
|
log4js.replaceConsole();
|
||||||
|
|
||||||
// wtfnode should be loaded after log4js.replaceConsole() so that it uses log4js for logging, and it
|
const settings = require('./utils/Settings');
|
||||||
// should be above everything else so that it can hook in before resources are used.
|
|
||||||
const wtfnode = require('wtfnode');
|
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
|
* early check for version compatibility before calling
|
||||||
* any modules that require newer versions of NodeJS
|
* any modules that require newer versions of NodeJS
|
||||||
*/
|
*/
|
||||||
const NodeVersion = require('./utils/NodeVersion');
|
const NodeVersion = require('./utils/NodeVersion');
|
||||||
NodeVersion.enforceMinNodeVersion('10.17.0');
|
NodeVersion.enforceMinNodeVersion('12.13.0');
|
||||||
NodeVersion.checkDeprecationStatus('10.17.0', '1.8.8');
|
NodeVersion.checkDeprecationStatus('12.13.0', '1.8.14');
|
||||||
|
|
||||||
const UpdateCheck = require('./utils/UpdateCheck');
|
const UpdateCheck = require('./utils/UpdateCheck');
|
||||||
const db = require('./db/DB');
|
const db = require('./db/DB');
|
||||||
|
@ -45,7 +50,6 @@ const express = require('./hooks/express');
|
||||||
const hooks = require('../static/js/pluginfw/hooks');
|
const hooks = require('../static/js/pluginfw/hooks');
|
||||||
const pluginDefs = require('../static/js/pluginfw/plugin_defs');
|
const pluginDefs = require('../static/js/pluginfw/plugin_defs');
|
||||||
const plugins = require('../static/js/pluginfw/plugins');
|
const plugins = require('../static/js/pluginfw/plugins');
|
||||||
const settings = require('./utils/Settings');
|
|
||||||
const stats = require('./stats');
|
const stats = require('./stats');
|
||||||
|
|
||||||
const logger = log4js.getLogger('server');
|
const logger = log4js.getLogger('server');
|
||||||
|
@ -248,16 +252,25 @@ exports.exit = async (err = null) => {
|
||||||
exitGate = new Gate();
|
exitGate = new Gate();
|
||||||
state = State.EXITING;
|
state = State.EXITING;
|
||||||
exitGate.resolve();
|
exitGate.resolve();
|
||||||
|
|
||||||
// Node.js should exit on its own without further action. Add a timeout to force Node.js to exit
|
// 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
|
// just in case something failed to get cleaned up during the shutdown hook. unref() is called
|
||||||
// the timeout so that the timeout itself does not prevent Node.js from exiting.
|
// on the timeout so that the timeout itself does not prevent Node.js from exiting.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
logger.error('Something that should have been cleaned up during the shutdown hook (such as ' +
|
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');
|
'a timer, worker thread, or open connection) is preventing Node.js from exiting');
|
||||||
wtfnode.dump();
|
|
||||||
|
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...');
|
logger.error('Forcing an unclean exit...');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 5000).unref();
|
}, 5000).unref();
|
||||||
|
|
||||||
logger.info('Waiting for Node.js to exit...');
|
logger.info('Waiting for Node.js to exit...');
|
||||||
state = State.WAITING_FOR_EXIT;
|
state = State.WAITING_FOR_EXIT;
|
||||||
/* eslint-enable no-process-exit */
|
/* eslint-enable no-process-exit */
|
||||||
|
|
|
@ -18,16 +18,21 @@
|
||||||
|
|
||||||
const db = require('../db/DB');
|
const db = require('../db/DB');
|
||||||
const hooks = require('../../static/js/pluginfw/hooks');
|
const hooks = require('../../static/js/pluginfw/hooks');
|
||||||
|
const log4js = require('log4js');
|
||||||
const supportedElems = require('../../static/js/contentcollector').supportedElems;
|
const supportedElems = require('../../static/js/contentcollector').supportedElems;
|
||||||
|
|
||||||
|
const logger = log4js.getLogger('ImportEtherpad');
|
||||||
|
|
||||||
exports.setPadRaw = (padId, r) => {
|
exports.setPadRaw = (padId, r) => {
|
||||||
const records = JSON.parse(r);
|
const records = JSON.parse(r);
|
||||||
|
|
||||||
// get supported block Elements from plugins, we will use this later.
|
// get supported block Elements from plugins, we will use this later.
|
||||||
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
||||||
supportedElems.push(element);
|
supportedElems.add(element);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unsupportedElements = new Set();
|
||||||
|
|
||||||
Object.keys(records).forEach(async (key) => {
|
Object.keys(records).forEach(async (key) => {
|
||||||
let value = records[key];
|
let value = records[key];
|
||||||
|
|
||||||
|
@ -64,10 +69,7 @@ exports.setPadRaw = (padId, r) => {
|
||||||
if (value.pool) {
|
if (value.pool) {
|
||||||
for (const attrib of Object.keys(value.pool.numToAttrib)) {
|
for (const attrib of Object.keys(value.pool.numToAttrib)) {
|
||||||
const attribName = value.pool.numToAttrib[attrib][0];
|
const attribName = value.pool.numToAttrib[attrib][0];
|
||||||
if (supportedElems.indexOf(attribName) === -1) {
|
if (!supportedElems.has(attribName)) unsupportedElements.add(attribName);
|
||||||
console.warn('Plugin missing: ' +
|
|
||||||
`You might want to install a plugin to support this node name: ${attribName}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const oldPadId = key.split(':');
|
const oldPadId = key.split(':');
|
||||||
|
@ -92,4 +94,9 @@ exports.setPadRaw = (padId, r) => {
|
||||||
// Write the value to the server
|
// Write the value to the server
|
||||||
await db.set(newKey, value);
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const assert = require('assert').strict;
|
|
||||||
const settings = require('./Settings');
|
const settings = require('./Settings');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
@ -30,6 +29,7 @@ const RequireKernel = require('etherpad-require-kernel');
|
||||||
const mime = require('mime-types');
|
const mime = require('mime-types');
|
||||||
const Threads = require('threads');
|
const Threads = require('threads');
|
||||||
const log4js = require('log4js');
|
const log4js = require('log4js');
|
||||||
|
const sanitizePathname = require('./sanitizePathname');
|
||||||
|
|
||||||
const logger = log4js.getLogger('Minify');
|
const logger = log4js.getLogger('Minify');
|
||||||
|
|
||||||
|
@ -41,6 +41,7 @@ const LIBRARY_WHITELIST = [
|
||||||
'async',
|
'async',
|
||||||
'js-cookie',
|
'js-cookie',
|
||||||
'security',
|
'security',
|
||||||
|
'split-grid',
|
||||||
'tinycon',
|
'tinycon',
|
||||||
'underscore',
|
'underscore',
|
||||||
'unorm',
|
'unorm',
|
||||||
|
@ -48,7 +49,7 @@ const LIBRARY_WHITELIST = [
|
||||||
|
|
||||||
// What follows is a terrible hack to avoid loop-back within the server.
|
// 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.
|
// 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);
|
const parsedUrl = new URL(url);
|
||||||
let status = 500;
|
let status = 500;
|
||||||
const content = [];
|
const content = [];
|
||||||
|
@ -58,34 +59,46 @@ const requestURI = async (url, method, headers) => await new Promise((resolve, r
|
||||||
params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\/static\//, '')},
|
params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\/static\//, '')},
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
const mockResponse = {
|
let mockResponse;
|
||||||
writeHead: (_status, _headers) => {
|
const p = new Promise((resolve) => {
|
||||||
status = _status;
|
mockResponse = {
|
||||||
for (const header in _headers) {
|
writeHead: (_status, _headers) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(_headers, header)) {
|
status = _status;
|
||||||
headers[header] = _headers[header];
|
for (const header in _headers) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(_headers, header)) {
|
||||||
|
headers[header] = _headers[header];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
setHeader: (header, value) => {
|
||||||
setHeader: (header, value) => {
|
headers[header.toLowerCase()] = value.toString();
|
||||||
headers[header.toLowerCase()] = value.toString();
|
},
|
||||||
},
|
header: (header, value) => {
|
||||||
header: (header, value) => {
|
headers[header.toLowerCase()] = value.toString();
|
||||||
headers[header.toLowerCase()] = value.toString();
|
},
|
||||||
},
|
write: (_content) => {
|
||||||
write: (_content) => {
|
_content && content.push(_content);
|
||||||
_content && content.push(_content);
|
},
|
||||||
},
|
end: (_content) => {
|
||||||
end: (_content) => {
|
_content && content.push(_content);
|
||||||
_content && content.push(_content);
|
resolve([status, headers, content.join('')]);
|
||||||
resolve([status, headers, content.join('')]);
|
},
|
||||||
},
|
};
|
||||||
};
|
});
|
||||||
minify(mockRequest, mockResponse).catch(reject);
|
await minify(mockRequest, mockResponse);
|
||||||
});
|
return await p;
|
||||||
|
};
|
||||||
|
|
||||||
const requestURIs = (locations, method, headers, callback) => {
|
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 statuss = responses.map((x) => x[0]);
|
||||||
const headerss = responses.map((x) => x[1]);
|
const headerss = responses.map((x) => x[1]);
|
||||||
const contentss = responses.map((x) => x[2]);
|
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 = {
|
const compatPaths = {
|
||||||
'js/browser.js': 'js/vendors/browser.js',
|
'js/browser.js': 'js/vendors/browser.js',
|
||||||
'js/farbtastic.js': 'js/vendors/farbtastic.js',
|
'js/farbtastic.js': 'js/vendors/farbtastic.js',
|
||||||
|
@ -183,6 +166,8 @@ const minify = async (req, res) => {
|
||||||
filename = path.join('../node_modules/', library, libraryPath);
|
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);
|
const contentType = mime.lookup(filename);
|
||||||
|
|
||||||
|
@ -243,7 +228,7 @@ const statFile = async (filename, dirStatLimit) => {
|
||||||
try {
|
try {
|
||||||
stats = await fs.stat(path.resolve(ROOT_DIR, filename));
|
stats = await fs.stat(path.resolve(ROOT_DIR, filename));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'ENOENT') {
|
if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
|
||||||
// Stat the directory instead.
|
// Stat the directory instead.
|
||||||
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
|
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
|
||||||
return [date, false];
|
return [date, false];
|
||||||
|
|
|
@ -50,11 +50,12 @@ console.log('All relative paths will be interpreted relative to the identified '
|
||||||
exports.title = 'Etherpad';
|
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.favicon = null;
|
||||||
exports.faviconPad = `../${exports.favicon}`;
|
|
||||||
exports.faviconTimeslider = `../../${exports.favicon}`;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Skin name.
|
* Skin name.
|
||||||
|
@ -140,7 +141,7 @@ exports.padOptions = {
|
||||||
alwaysShowChat: false,
|
alwaysShowChat: false,
|
||||||
chatAndUsers: false,
|
chatAndUsers: false,
|
||||||
lang: 'en-gb',
|
lang: 'en-gb',
|
||||||
},
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether certain shortcut keys are enabled for a user in the pad
|
* Whether certain shortcut keys are enabled for a user in the pad
|
||||||
|
@ -168,7 +169,7 @@ exports.padShortcutEnabled = {
|
||||||
ctrlHome: true,
|
ctrlHome: true,
|
||||||
pageUp: true,
|
pageUp: true,
|
||||||
pageDown: true,
|
pageDown: true,
|
||||||
},
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The toolbar buttons and order.
|
* The toolbar buttons and order.
|
||||||
|
@ -250,6 +251,11 @@ exports.automaticReconnectionTimeout = 0;
|
||||||
*/
|
*/
|
||||||
exports.loadTest = false;
|
exports.loadTest = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable dump of objects preventing a clean exit
|
||||||
|
*/
|
||||||
|
exports.dumpOnUncleanExit = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable indentation on new lines
|
* Enable indentation on new lines
|
||||||
*/
|
*/
|
||||||
|
@ -460,7 +466,7 @@ exports.getEpVersion = () => require('../../package.json').version;
|
||||||
* both "settings.json" and "credentials.json".
|
* both "settings.json" and "credentials.json".
|
||||||
*/
|
*/
|
||||||
const storeSettings = (settingsObj) => {
|
const storeSettings = (settingsObj) => {
|
||||||
for (const i in settingsObj) {
|
for (const i of Object.keys(settingsObj || {})) {
|
||||||
// test if the setting starts with a lowercase character
|
// test if the setting starts with a lowercase character
|
||||||
if (i.charAt(0).search('[a-z]') !== 0) {
|
if (i.charAt(0).search('[a-z]') !== 0) {
|
||||||
console.warn(`Settings should start with a lowercase character: '${i}'`);
|
console.warn(`Settings should start with a lowercase character: '${i}'`);
|
||||||
|
@ -503,17 +509,13 @@ const coerceValue = (stringValue) => {
|
||||||
return +stringValue;
|
return +stringValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the boolean literal case is easy.
|
switch (stringValue) {
|
||||||
if (stringValue === 'true') {
|
case 'true': return 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -596,8 +598,10 @@ const lookupEnvironmentVariables = (obj) => {
|
||||||
const defaultValue = match[3];
|
const defaultValue = match[3];
|
||||||
|
|
||||||
if ((envVarValue === undefined) && (defaultValue === undefined)) {
|
if ((envVarValue === undefined) && (defaultValue === undefined)) {
|
||||||
console.warn(`Environment variable "${envVarName}" does not contain any value for `+
|
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
|
* 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}`);
|
console.log(`Random string used for versioning assets: ${exports.randomVersionString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.exportedForTestingOnly = {
|
||||||
|
parseSettings,
|
||||||
|
};
|
||||||
|
|
||||||
// initially load settings
|
// initially load settings
|
||||||
exports.reloadSettings();
|
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",
|
"etherpad-yajsml": "0.0.4",
|
||||||
"express": "4.17.1",
|
"express": "4.17.1",
|
||||||
"express-rate-limit": "5.2.6",
|
"express-rate-limit": "5.2.6",
|
||||||
"express-session": "1.17.1",
|
"express-session": "1.17.2",
|
||||||
"find-root": "1.1.0",
|
"find-root": "1.1.0",
|
||||||
"formidable": "1.2.2",
|
"formidable": "1.2.2",
|
||||||
"http-errors": "1.8.0",
|
"http-errors": "1.8.0",
|
||||||
|
@ -54,8 +54,8 @@
|
||||||
"measured-core": "1.51.1",
|
"measured-core": "1.51.1",
|
||||||
"mime-types": "^2.1.27",
|
"mime-types": "^2.1.27",
|
||||||
"nodeify": "1.0.1",
|
"nodeify": "1.0.1",
|
||||||
"npm": "6.14.11",
|
"npm": "6.14.13",
|
||||||
"openapi-backend": "^3.9.0",
|
"openapi-backend": "^3.9.1",
|
||||||
"proxy-addr": "^2.0.6",
|
"proxy-addr": "^2.0.6",
|
||||||
"rate-limiter-flexible": "^2.1.4",
|
"rate-limiter-flexible": "^2.1.4",
|
||||||
"rehype": "^10.0.0",
|
"rehype": "^10.0.0",
|
||||||
|
@ -69,33 +69,34 @@
|
||||||
"threads": "^1.4.0",
|
"threads": "^1.4.0",
|
||||||
"tiny-worker": "^2.3.0",
|
"tiny-worker": "^2.3.0",
|
||||||
"tinycon": "0.6.8",
|
"tinycon": "0.6.8",
|
||||||
"ueberdb2": "^1.4.4",
|
"ueberdb2": "^1.4.7",
|
||||||
"underscore": "1.12.0",
|
"underscore": "1.13.1",
|
||||||
"unorm": "1.6.0",
|
"unorm": "1.6.0",
|
||||||
"wtfnode": "^0.8.4"
|
"wtfnode": "^0.9.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"etherpad-lite": "node/server.js"
|
"etherpad-lite": "node/server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^7.20.0",
|
"eslint": "^7.28.0",
|
||||||
"eslint-config-etherpad": "^1.0.26",
|
"eslint-config-etherpad": "^2.0.0",
|
||||||
"eslint-plugin-cypress": "^2.11.2",
|
"eslint-plugin-cypress": "^2.11.3",
|
||||||
"eslint-plugin-eslint-comments": "^3.2.0",
|
"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-node": "^11.1.0",
|
||||||
"eslint-plugin-prefer-arrow": "^1.2.3",
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||||
"eslint-plugin-promise": "^4.3.1",
|
"eslint-plugin-promise": "^5.1.0",
|
||||||
"eslint-plugin-you-dont-need-lodash-underscore": "^6.11.0",
|
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
|
||||||
"etherpad-cli-client": "0.0.9",
|
"etherpad-cli-client": "0.0.9",
|
||||||
"mocha": "7.1.2",
|
"mocha": "7.1.2",
|
||||||
"mocha-froth": "^0.2.10",
|
"mocha-froth": "^0.2.10",
|
||||||
"openapi-schema-validation": "^0.4.2",
|
"openapi-schema-validation": "^0.4.2",
|
||||||
|
"selenium-webdriver": "^4.0.0-beta.3",
|
||||||
"set-cookie-parser": "^2.4.6",
|
"set-cookie-parser": "^2.4.6",
|
||||||
"sinon": "^9.2.0",
|
"sinon": "^9.2.0",
|
||||||
|
"split-grid": "^1.0.11",
|
||||||
"superagent": "^3.8.3",
|
"superagent": "^3.8.3",
|
||||||
"supertest": "4.0.2",
|
"supertest": "4.0.2"
|
||||||
"wd": "1.12.1"
|
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"ignorePatterns": [
|
"ignorePatterns": [
|
||||||
|
@ -234,7 +235,7 @@
|
||||||
"root": true
|
"root": true
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10.17.0 || >=11.14.0",
|
"node": ">=12.13.0",
|
||||||
"npm": ">=5.5.1"
|
"npm": ">=5.5.1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -246,6 +247,6 @@
|
||||||
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
|
"test": "mocha --timeout 120000 --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
|
||||||
"test-container": "mocha --timeout 5000 tests/container/specs/api"
|
"test-container": "mocha --timeout 5000 tests/container/specs/api"
|
||||||
},
|
},
|
||||||
"version": "1.8.13",
|
"version": "1.8.14",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,11 +58,6 @@ html.outer-editor, html.inner-editor {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
#innerdocbody.authorColors span {
|
|
||||||
padding-top: 3px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
option {
|
option {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,9 @@
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-variant: normal;
|
font-variant: normal;
|
||||||
text-rendering: auto;
|
text-rendering: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonicon:before, [class^="buttonicon-"]:before, [class*=" buttonicon-"]:before {
|
.buttonicon:before, [class^="buttonicon-"]:before, [class*=" buttonicon-"]:before {
|
||||||
|
@ -34,9 +37,6 @@
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: inherit;
|
text-decoration: inherit;
|
||||||
width: 1em;
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
/* For safety - reset parent styles, that can break glyph codes*/
|
/* For safety - reset parent styles, that can break glyph codes*/
|
||||||
font-variant: normal;
|
font-variant: normal;
|
||||||
|
|
|
@ -63,15 +63,20 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
|
||||||
@param attribs: an array of attributes
|
@param attribs: an array of attributes
|
||||||
*/
|
*/
|
||||||
setAttributesOnRange(start, end, attribs) {
|
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
|
// 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,
|
// 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
|
// see https://github.com/ether/etherpad-lite/issues/2772
|
||||||
let allChangesets;
|
let allChangesets;
|
||||||
for (let row = start[0]; row <= end[0]; row++) {
|
for (let row = start[0]; row <= end[0]; row++) {
|
||||||
const rowRange = this._findRowRange(row, start, end);
|
const [startCol, endCol] = this._findRowRange(row, start, end);
|
||||||
const startCol = rowRange[0];
|
|
||||||
const endCol = rowRange[1];
|
|
||||||
|
|
||||||
const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs);
|
const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs);
|
||||||
|
|
||||||
// compose changesets of all rows into a single changeset
|
// compose changesets of all rows into a single changeset
|
||||||
|
@ -89,36 +94,34 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
_findRowRange(row, start, end) {
|
_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);
|
// Subtract 1 for the end-of-line '\n' (it is never selected).
|
||||||
const endLineOffset = this.rep.lines.offsetOfIndex(row + 1);
|
const lineLength =
|
||||||
const lineLength = endLineOffset - startLineOffset;
|
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
|
const startCol = row === start[0] ? start[1] : markerWidth;
|
||||||
if (row === start[0]) { // are we on the first row of range?
|
if (startCol - markerWidth < 0) throw new RangeError('selection starts before line start');
|
||||||
startCol = start[1];
|
if (startCol > lineLength) throw new RangeError('selection starts after line end');
|
||||||
} else {
|
|
||||||
startCol = this.lineHasMarker(row) ? 1 : 0; // remove "*" used as line marker
|
|
||||||
}
|
|
||||||
|
|
||||||
// find column where range on this row ends
|
const endCol = row === end[0] ? end[1] : lineLength;
|
||||||
if (row === end[0]) { // are we on the last row of range?
|
if (endCol - markerWidth < 0) throw new RangeError('selection ends before line start');
|
||||||
endCol = end[1]; // if so, get the end of range, not end of row
|
if (endCol > lineLength) throw new RangeError('selection ends after line end');
|
||||||
} else {
|
if (startCol > endCol) throw new RangeError('selection ends before it starts');
|
||||||
endCol = lineLength - 1; // remove "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
return [startCol, endCol];
|
return [startCol, endCol];
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Sets attributes on a range, by line
|
* Sets attributes on a range, by line
|
||||||
@param row the row where range is
|
* @param row the row where range is
|
||||||
@param startCol column where range starts
|
* @param startCol column where range starts
|
||||||
@param endCol column where range ends
|
* @param endCol column where range ends (one past the last selected column)
|
||||||
@param attribs: an array of attributes
|
* @param attribs an array of attributes
|
||||||
*/
|
*/
|
||||||
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
|
_setAttributesOnRangeByLine(row, startCol, endCol, attribs) {
|
||||||
const builder = Changeset.builder(this.rep.lines.totalWidth());
|
const builder = Changeset.builder(this.rep.lines.totalWidth());
|
||||||
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]);
|
ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]);
|
||||||
|
|
|
@ -108,7 +108,6 @@ exports.newLen = (cs) => exports.unpack(cs).newLen;
|
||||||
* @return {Op} type object iterator
|
* @return {Op} type object iterator
|
||||||
*/
|
*/
|
||||||
exports.opIterator = (opsStr, optStartIndex) => {
|
exports.opIterator = (opsStr, optStartIndex) => {
|
||||||
// print(opsStr);
|
|
||||||
const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g;
|
const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g;
|
||||||
const startIndex = (optStartIndex || 0);
|
const startIndex = (optStartIndex || 0);
|
||||||
let curIndex = startIndex;
|
let curIndex = startIndex;
|
||||||
|
@ -126,10 +125,9 @@ exports.opIterator = (opsStr, optStartIndex) => {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
let regexResult = nextRegexMatch();
|
let regexResult = nextRegexMatch();
|
||||||
const obj = exports.newOp();
|
|
||||||
|
|
||||||
const next = (optObj) => {
|
const next = (optOp) => {
|
||||||
const op = (optObj || obj);
|
const op = optOp || exports.newOp();
|
||||||
if (regexResult[0]) {
|
if (regexResult[0]) {
|
||||||
op.attribs = regexResult[1];
|
op.attribs = regexResult[1];
|
||||||
op.lines = exports.parseNum(regexResult[2] || 0);
|
op.lines = exports.parseNum(regexResult[2] || 0);
|
||||||
|
@ -645,15 +643,8 @@ exports.textLinesMutator = (lines) => {
|
||||||
curLine += L;
|
curLine += L;
|
||||||
curCol = 0;
|
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
|
// tests case foo in remove(), which isn't otherwise covered in current impl
|
||||||
}
|
}
|
||||||
// debugPrint("skip");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const skip = (N, L, includeInSplice) => {
|
const skip = (N, L, includeInSplice) => {
|
||||||
|
@ -668,7 +659,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
putCurLineInSplice();
|
putCurLineInSplice();
|
||||||
}
|
}
|
||||||
curCol += N;
|
curCol += N;
|
||||||
// debugPrint("skip");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -685,10 +675,8 @@ exports.textLinesMutator = (lines) => {
|
||||||
return lines_slice(m, m + k).join('');
|
return lines_slice(m, m + k).join('');
|
||||||
};
|
};
|
||||||
if (isCurLineInSplice()) {
|
if (isCurLineInSplice()) {
|
||||||
// print(curCol);
|
|
||||||
if (curCol === 0) {
|
if (curCol === 0) {
|
||||||
removed = curSplice[curSplice.length - 1];
|
removed = curSplice[curSplice.length - 1];
|
||||||
// print("FOO"); // case foo
|
|
||||||
curSplice.length--;
|
curSplice.length--;
|
||||||
removed += nextKLinesText(L - 1);
|
removed += nextKLinesText(L - 1);
|
||||||
curSplice[1] += L - 1;
|
curSplice[1] += L - 1;
|
||||||
|
@ -705,7 +693,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
removed = nextKLinesText(L);
|
removed = nextKLinesText(L);
|
||||||
curSplice[1] += L;
|
curSplice[1] += L;
|
||||||
}
|
}
|
||||||
// debugPrint("remove");
|
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
};
|
};
|
||||||
|
@ -723,7 +710,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
removed = curSplice[sline].substring(curCol, curCol + N);
|
removed = curSplice[sline].substring(curCol, curCol + N);
|
||||||
curSplice[sline] = curSplice[sline].substring(0, curCol) +
|
curSplice[sline] = curSplice[sline].substring(0, curCol) +
|
||||||
curSplice[sline].substring(curCol + N);
|
curSplice[sline].substring(curCol + N);
|
||||||
// debugPrint("remove");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
|
@ -737,13 +723,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
if (L) {
|
if (L) {
|
||||||
const newLines = exports.splitTextLines(text);
|
const newLines = exports.splitTextLines(text);
|
||||||
if (isCurLineInSplice()) {
|
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 sline = curSplice.length - 1;
|
||||||
const theLine = curSplice[sline];
|
const theLine = curSplice[sline];
|
||||||
const lineCol = curCol;
|
const lineCol = curCol;
|
||||||
|
@ -754,7 +733,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
curLine += newLines.length;
|
curLine += newLines.length;
|
||||||
curSplice.push(theLine.substring(lineCol));
|
curSplice.push(theLine.substring(lineCol));
|
||||||
curCol = 0;
|
curCol = 0;
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
Array.prototype.push.apply(curSplice, newLines);
|
Array.prototype.push.apply(curSplice, newLines);
|
||||||
curLine += newLines.length;
|
curLine += newLines.length;
|
||||||
|
@ -768,12 +746,10 @@ exports.textLinesMutator = (lines) => {
|
||||||
curSplice[sline].substring(curCol);
|
curSplice[sline].substring(curCol);
|
||||||
curCol += text.length;
|
curCol += text.length;
|
||||||
}
|
}
|
||||||
// debugPrint("insert");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMore = () => {
|
const hasMore = () => {
|
||||||
// print(lines.length+" / "+inSplice+" / "+(curSplice.length - 2)+" / "+curSplice[1]);
|
|
||||||
let docLines = lines_length();
|
let docLines = lines_length();
|
||||||
if (inSplice) {
|
if (inSplice) {
|
||||||
docLines += curSplice.length - 2 - curSplice[1];
|
docLines += curSplice.length - 2 - curSplice[1];
|
||||||
|
@ -785,7 +761,6 @@ exports.textLinesMutator = (lines) => {
|
||||||
if (inSplice) {
|
if (inSplice) {
|
||||||
leaveSplice();
|
leaveSplice();
|
||||||
}
|
}
|
||||||
// debugPrint("close");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const self = {
|
const self = {
|
||||||
|
@ -827,7 +802,6 @@ exports.applyZip = (in1, idx1, in2, idx2, func) => {
|
||||||
if ((!op2.opcode) && iter2.hasNext()) iter2.next(op2);
|
if ((!op2.opcode) && iter2.hasNext()) iter2.next(op2);
|
||||||
func(op1, op2, opOut);
|
func(op1, op2, opOut);
|
||||||
if (opOut.opcode) {
|
if (opOut.opcode) {
|
||||||
// print(opOut.toSource());
|
|
||||||
assem.append(opOut);
|
assem.append(opOut);
|
||||||
opOut.opcode = '';
|
opOut.opcode = '';
|
||||||
}
|
}
|
||||||
|
@ -1012,7 +986,6 @@ exports.composeAttributes = (att1, att2, resultIsMutation, pool) => {
|
||||||
buf.append('*');
|
buf.append('*');
|
||||||
buf.append(exports.numToString(pool.putAttrib(atts[i])));
|
buf.append(exports.numToString(pool.putAttrib(atts[i])));
|
||||||
}
|
}
|
||||||
// print(att1+" / "+att2+" / "+buf.toString());
|
|
||||||
return 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
|
// attOp is the op from the sequence that is being operated on, either an
|
||||||
// attribution string or the earlier of two exportss being composed.
|
// attribution string or the earlier of two exportss being composed.
|
||||||
// pool can be null if definitely not needed.
|
// pool can be null if definitely not needed.
|
||||||
// print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
|
|
||||||
if (attOp.opcode === '-') {
|
if (attOp.opcode === '-') {
|
||||||
exports.copyOp(attOp, opOut);
|
exports.copyOp(attOp, opOut);
|
||||||
attOp.opcode = '';
|
attOp.opcode = '';
|
||||||
|
@ -1121,15 +1093,7 @@ exports.applyToAttribution = (cs, astr, pool) => {
|
||||||
(op1, op2, opOut) => exports._slicerZipperFunc(op1, op2, opOut, 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) => {
|
exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||||
// dmesg(cs);
|
|
||||||
// dmesg(lines.toSource()+" ->");
|
|
||||||
const unpacked = exports.unpack(cs);
|
const unpacked = exports.unpack(cs);
|
||||||
const csIter = exports.opIterator(unpacked.ops);
|
const csIter = exports.opIterator(unpacked.ops);
|
||||||
const csBank = unpacked.charBank;
|
const csBank = unpacked.charBank;
|
||||||
|
@ -1155,7 +1119,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||||
let lineAssem = null;
|
let lineAssem = null;
|
||||||
|
|
||||||
const outputMutOp = (op) => {
|
const outputMutOp = (op) => {
|
||||||
// print("outputMutOp: "+op.toSource());
|
|
||||||
if (!lineAssem) {
|
if (!lineAssem) {
|
||||||
lineAssem = exports.mergingOpAssembler();
|
lineAssem = exports.mergingOpAssembler();
|
||||||
}
|
}
|
||||||
|
@ -1175,17 +1138,12 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||||
if ((!csOp.opcode) && csIter.hasNext()) {
|
if ((!csOp.opcode) && csIter.hasNext()) {
|
||||||
csIter.next(csOp);
|
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()))) {
|
if ((!csOp.opcode) && (!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) {
|
||||||
break; // done
|
break; // done
|
||||||
} else if (csOp.opcode === '=' && csOp.lines > 0 && (!csOp.attribs) &&
|
} else if (csOp.opcode === '=' && csOp.lines > 0 && (!csOp.attribs) &&
|
||||||
(!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) {
|
(!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) {
|
||||||
// skip multiple lines; this is what makes small changes not order of the document size
|
// skip multiple lines; this is what makes small changes not order of the document size
|
||||||
mut.skipLines(csOp.lines);
|
mut.skipLines(csOp.lines);
|
||||||
// print("skipped: "+csOp.lines);
|
|
||||||
csOp.opcode = '';
|
csOp.opcode = '';
|
||||||
} else if (csOp.opcode === '+') {
|
} else if (csOp.opcode === '+') {
|
||||||
if (csOp.lines > 1) {
|
if (csOp.lines > 1) {
|
||||||
|
@ -1206,7 +1164,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||||
if ((!attOp.opcode) && isNextMutOp()) {
|
if ((!attOp.opcode) && isNextMutOp()) {
|
||||||
nextMutOp(attOp);
|
nextMutOp(attOp);
|
||||||
}
|
}
|
||||||
// print("attOp: "+attOp.toSource());
|
|
||||||
exports._slicerZipperFunc(attOp, csOp, opOut, pool);
|
exports._slicerZipperFunc(attOp, csOp, opOut, pool);
|
||||||
if (opOut.opcode) {
|
if (opOut.opcode) {
|
||||||
outputMutOp(opOut);
|
outputMutOp(opOut);
|
||||||
|
@ -1217,8 +1174,6 @@ exports.mutateAttributionLines = (cs, lines, pool) => {
|
||||||
|
|
||||||
exports.assert(!lineAssem, `line assembler not finished:${cs}`);
|
exports.assert(!lineAssem, `line assembler not finished:${cs}`);
|
||||||
mut.close();
|
mut.close();
|
||||||
|
|
||||||
// dmesg("-> "+lines.toSource());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1300,11 +1255,6 @@ exports.compose = (cs1, cs2, pool) => {
|
||||||
const bankAssem = exports.stringAssembler();
|
const bankAssem = exports.stringAssembler();
|
||||||
|
|
||||||
const newOps = exports.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, (op1, op2, opOut) => {
|
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 op1code = op1.opcode;
|
||||||
const op2code = op2.opcode;
|
const op2code = op2.opcode;
|
||||||
if (op1code === '+' && op2code === '-') {
|
if (op1code === '+' && op2code === '-') {
|
||||||
|
@ -1318,13 +1268,6 @@ exports.compose = (cs1, cs2, pool) => {
|
||||||
bankAssem.append(bankIter1.take(opOut.chars));
|
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());
|
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
|
// attOp is the op from the sequence that is being operated on, either an
|
||||||
// attribution string or the earlier of two exportss being composed.
|
// attribution string or the earlier of two exportss being composed.
|
||||||
// pool can be null if definitely not needed.
|
// pool can be null if definitely not needed.
|
||||||
// print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
|
|
||||||
if (attOp.opcode === '-') {
|
if (attOp.opcode === '-') {
|
||||||
exports.copyOp(attOp, opOut);
|
exports.copyOp(attOp, opOut);
|
||||||
attOp.opcode = '';
|
attOp.opcode = '';
|
||||||
|
|
|
@ -141,7 +141,7 @@ const Ace2Editor = function () {
|
||||||
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
|
this.getDebugProperty = (prop) => info.ace_getDebugProperty(prop);
|
||||||
|
|
||||||
this.getInInternationalComposition =
|
this.getInInternationalComposition =
|
||||||
() => loaded ? info.ace_getInInternationalComposition() : false;
|
() => loaded ? info.ace_getInInternationalComposition() : null;
|
||||||
|
|
||||||
// prepareUserChangeset:
|
// prepareUserChangeset:
|
||||||
// Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes
|
// 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);
|
innerDocument.head.appendChild(innerStyle);
|
||||||
const headLines = [];
|
const headLines = [];
|
||||||
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
|
hooks.callAll('aceInitInnerdocbodyHead', {iframeHTML: headLines});
|
||||||
const tmp = innerDocument.createElement('div');
|
innerDocument.head.appendChild(
|
||||||
tmp.innerHTML = headLines.join('\n');
|
innerDocument.createRange().createContextualFragment(headLines.join('\n')));
|
||||||
while (tmp.firstChild) innerDocument.head.appendChild(tmp.firstChild);
|
|
||||||
|
|
||||||
// <body> tag
|
// <body> tag
|
||||||
innerDocument.body.id = 'innerdocbody';
|
innerDocument.body.id = 'innerdocbody';
|
||||||
|
|
|
@ -86,14 +86,24 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
let outsideKeyPress = (e) => true;
|
let outsideKeyPress = (e) => true;
|
||||||
let outsideNotifyDirty = noop;
|
let outsideNotifyDirty = noop;
|
||||||
|
|
||||||
// selFocusAtStart -- determines whether the selection extends "backwards", so that the focus
|
// Document representation.
|
||||||
// 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!
|
|
||||||
const rep = {
|
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(),
|
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,
|
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,
|
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,
|
selFocusAtStart: false,
|
||||||
alltext: '',
|
alltext: '',
|
||||||
alines: [],
|
alines: [],
|
||||||
|
@ -136,17 +146,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
for (let i = 0; i < names.length; ++i) console[names[i]] = noop;
|
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
|
// "dmesg" is for displaying messages in the in-page output pane
|
||||||
// visible when "?djs=1" is appended to the pad URL. It generally
|
// 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
|
// 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') {
|
if ((typeof info.fade) === 'number') {
|
||||||
bgcolor = fadeColor(bgcolor, info.fade);
|
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 =
|
const textColor =
|
||||||
colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName);
|
colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName);
|
||||||
authorStyle.color = textColor;
|
const styles = [
|
||||||
parentAuthorStyle.color = textColor;
|
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) => {
|
const inCallStack = (type, action) => {
|
||||||
if (disposed) return;
|
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) => ({
|
const newEditEvent = (eventType) => ({
|
||||||
eventType,
|
eventType,
|
||||||
backset: null,
|
backset: null,
|
||||||
|
@ -388,11 +381,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
if (cleanExit) {
|
if (cleanExit) {
|
||||||
submitOldEvent(cs.editEvent);
|
submitOldEvent(cs.editEvent);
|
||||||
if (cs.domClean && cs.type !== 'setup') {
|
if (cs.domClean && cs.type !== 'setup') {
|
||||||
// if (cs.isUserChange)
|
|
||||||
// {
|
|
||||||
// if (cs.repChanged) parenModule.notifyChange();
|
|
||||||
// else parenModule.notifyTick();
|
|
||||||
// }
|
|
||||||
if (cs.selectionAffected) {
|
if (cs.selectionAffected) {
|
||||||
updateBrowserSelectionFromRep();
|
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
|
* This methed exposes a setter for some ace properties
|
||||||
// @param value the value to set to
|
* @param key the name of the parameter
|
||||||
|
* @param value the value to set to
|
||||||
|
*/
|
||||||
editorInfo.ace_setProperty = (key, value) => {
|
editorInfo.ace_setProperty = (key, value) => {
|
||||||
// These properties are exposed
|
// These properties are exposed
|
||||||
const setters = {
|
const setters = {
|
||||||
|
@ -753,7 +743,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
let printedTrace = false;
|
let printedTrace = false;
|
||||||
const isTimeUp = () => {
|
const isTimeUp = () => {
|
||||||
if (exceededAlready) {
|
if (exceededAlready) {
|
||||||
if ((!printedTrace)) { // && now() - startTime - ms > 300) {
|
if ((!printedTrace)) {
|
||||||
printedTrace = true;
|
printedTrace = true;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
@ -949,17 +939,12 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
clearObservedChanges();
|
clearObservedChanges();
|
||||||
|
|
||||||
const getCleanNodeByKey = (key) => {
|
const getCleanNodeByKey = (key) => {
|
||||||
const p = PROFILER('getCleanNodeByKey', false); // eslint-disable-line new-cap
|
|
||||||
p.extra = 0;
|
|
||||||
let n = doc.getElementById(key);
|
let n = doc.getElementById(key);
|
||||||
// copying and pasting can lead to duplicate ids
|
// copying and pasting can lead to duplicate ids
|
||||||
while (n && isNodeDirty(n)) {
|
while (n && isNodeDirty(n)) {
|
||||||
p.extra++;
|
|
||||||
n.id = '';
|
n.id = '';
|
||||||
n = doc.getElementById(key);
|
n = doc.getElementById(key);
|
||||||
}
|
}
|
||||||
p.literal(p.extra, 'extra');
|
|
||||||
p.end();
|
|
||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1025,9 +1010,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
if (currentCallStack.observedSelection) return;
|
if (currentCallStack.observedSelection) return;
|
||||||
currentCallStack.observedSelection = true;
|
currentCallStack.observedSelection = true;
|
||||||
|
|
||||||
const p = PROFILER('getSelection', false); // eslint-disable-line new-cap
|
|
||||||
const selection = getSelection();
|
const selection = getSelection();
|
||||||
p.end();
|
|
||||||
|
|
||||||
if (selection) {
|
if (selection) {
|
||||||
const node1 = topLevel(selection.startPoint.node);
|
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;
|
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
|
// returns true if dom changes were made
|
||||||
if (!root.firstChild) {
|
if (!root.firstChild) {
|
||||||
root.innerHTML = '<div><!-- --></div>';
|
root.innerHTML = '<div><!-- --></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mark('obs');
|
|
||||||
observeChangesAroundSelection();
|
observeChangesAroundSelection();
|
||||||
observeSuspiciousNodes();
|
observeSuspiciousNodes();
|
||||||
p.mark('dirty');
|
|
||||||
let dirtyRanges = getDirtyRanges();
|
let dirtyRanges = getDirtyRanges();
|
||||||
let dirtyRangesCheckOut = true;
|
let dirtyRangesCheckOut = true;
|
||||||
let j = 0;
|
let j = 0;
|
||||||
|
@ -1100,7 +1079,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
|
|
||||||
clearObservedChanges();
|
clearObservedChanges();
|
||||||
|
|
||||||
p.mark('getsel');
|
|
||||||
const selection = getSelection();
|
const selection = getSelection();
|
||||||
|
|
||||||
let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection
|
let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection
|
||||||
|
@ -1108,8 +1086,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
const splicesToDo = [];
|
const splicesToDo = [];
|
||||||
let netNumLinesChangeSoFar = 0;
|
let netNumLinesChangeSoFar = 0;
|
||||||
const toDeleteAtEnd = [];
|
const toDeleteAtEnd = [];
|
||||||
p.mark('ranges');
|
|
||||||
p.literal(dirtyRanges.length, 'numdirt');
|
|
||||||
const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]]
|
const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]]
|
||||||
while (i < dirtyRanges.length) {
|
while (i < dirtyRanges.length) {
|
||||||
const range = dirtyRanges[i];
|
const range = dirtyRanges[i];
|
||||||
|
@ -1176,7 +1152,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
entries.push(newEntry);
|
entries.push(newEntry);
|
||||||
lineNodeInfos[k] = newEntry.domInfo;
|
lineNodeInfos[k] = newEntry.domInfo;
|
||||||
}
|
}
|
||||||
// var fragment = magicdom.wrapDom(document.createDocumentFragment());
|
|
||||||
domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]);
|
domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]);
|
||||||
dirtyNodes.forEach((n) => {
|
dirtyNodes.forEach((n) => {
|
||||||
toDeleteAtEnd.push(n);
|
toDeleteAtEnd.push(n);
|
||||||
|
@ -1198,25 +1173,19 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
const domChanges = (splicesToDo.length > 0);
|
const domChanges = (splicesToDo.length > 0);
|
||||||
|
|
||||||
// update the representation
|
// update the representation
|
||||||
p.mark('splice');
|
|
||||||
splicesToDo.forEach((splice) => {
|
splicesToDo.forEach((splice) => {
|
||||||
doIncorpLineSplice(splice[0], splice[1], splice[2], splice[3], splice[4]);
|
doIncorpLineSplice(splice[0], splice[1], splice[2], splice[3], splice[4]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// do DOM inserts
|
// do DOM inserts
|
||||||
p.mark('insert');
|
|
||||||
domInsertsNeeded.forEach((ins) => {
|
domInsertsNeeded.forEach((ins) => {
|
||||||
insertDomLines(ins[0], ins[1]);
|
insertDomLines(ins[0], ins[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
p.mark('del');
|
|
||||||
// delete old dom nodes
|
// delete old dom nodes
|
||||||
toDeleteAtEnd.forEach((n) => {
|
toDeleteAtEnd.forEach((n) => {
|
||||||
// var id = n.uniqueId();
|
|
||||||
// parent of n may not be "root" in IE due to non-tree-shaped DOM (wtf)
|
// parent of n may not be "root" in IE due to non-tree-shaped DOM (wtf)
|
||||||
if (n.parentNode) n.parentNode.removeChild(n);
|
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
|
// 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);
|
$('#innerdocbody').scrollLeft(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mark('findsel');
|
|
||||||
// if the nodes that define the selection weren't encountered during
|
// if the nodes that define the selection weren't encountered during
|
||||||
// content collection, figure out where those nodes are now.
|
// content collection, figure out where those nodes are now.
|
||||||
if (selection && !selStart) {
|
if (selection && !selStart) {
|
||||||
// if (domChanges) dmesg("selection not collected");
|
|
||||||
const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', {
|
const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', {
|
||||||
callstack: currentCallStack,
|
callstack: currentCallStack,
|
||||||
editorInfo,
|
editorInfo,
|
||||||
|
@ -1266,14 +1233,12 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length;
|
selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mark('repsel');
|
|
||||||
// update rep if we have a new selection
|
// update rep if we have a new selection
|
||||||
// NOTE: IE loses the selection when you click stuff in e.g. the
|
// 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
|
// editbar, so removing the selection when it's lost is not a good
|
||||||
// idea.
|
// idea.
|
||||||
if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart);
|
if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart);
|
||||||
// update browser selection
|
// update browser selection
|
||||||
p.mark('browsel');
|
|
||||||
if (selection && (domChanges || isCaret())) {
|
if (selection && (domChanges || isCaret())) {
|
||||||
// if no DOM changes (not this case), want to treat range selection delicately,
|
// 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;
|
// 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;
|
currentCallStack.domClean = true;
|
||||||
|
|
||||||
p.mark('fixview');
|
|
||||||
|
|
||||||
fixView();
|
fixView();
|
||||||
|
|
||||||
p.end('END');
|
|
||||||
|
|
||||||
return domChanges;
|
return domChanges;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1311,11 +1272,9 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
if (infoStructs.length < 1) return;
|
if (infoStructs.length < 1) return;
|
||||||
|
|
||||||
infoStructs.forEach((info) => {
|
infoStructs.forEach((info) => {
|
||||||
const p2 = PROFILER('insertLine', false); // eslint-disable-line new-cap
|
|
||||||
const node = info.node;
|
const node = info.node;
|
||||||
const key = uniqueId(node);
|
const key = uniqueId(node);
|
||||||
let entry;
|
let entry;
|
||||||
p2.mark('findEntry');
|
|
||||||
if (lastEntry) {
|
if (lastEntry) {
|
||||||
// optimization to avoid recalculation
|
// optimization to avoid recalculation
|
||||||
const next = rep.lines.next(lastEntry);
|
const next = rep.lines.next(lastEntry);
|
||||||
|
@ -1325,16 +1284,13 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
p2.literal(1, 'nonopt');
|
|
||||||
entry = rep.lines.atKey(key);
|
entry = rep.lines.atKey(key);
|
||||||
lineStartOffset = rep.lines.offsetOfKey(key);
|
lineStartOffset = rep.lines.offsetOfKey(key);
|
||||||
} else { p2.literal(0, 'nonopt'); }
|
}
|
||||||
lastEntry = entry;
|
lastEntry = entry;
|
||||||
p2.mark('spans');
|
|
||||||
getSpansForLine(entry, (tokenText, tokenClass) => {
|
getSpansForLine(entry, (tokenText, tokenClass) => {
|
||||||
info.appendSpan(tokenText, tokenClass);
|
info.appendSpan(tokenText, tokenClass);
|
||||||
}, lineStartOffset);
|
}, lineStartOffset);
|
||||||
p2.mark('addLine');
|
|
||||||
info.prepareForAdd();
|
info.prepareForAdd();
|
||||||
entry.lineMarker = info.lineMarker;
|
entry.lineMarker = info.lineMarker;
|
||||||
if (!nodeToAddAfter) {
|
if (!nodeToAddAfter) {
|
||||||
|
@ -1344,9 +1300,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
}
|
}
|
||||||
nodeToAddAfter = node;
|
nodeToAddAfter = node;
|
||||||
info.notifyAdded();
|
info.notifyAdded();
|
||||||
p2.mark('markClean');
|
|
||||||
markNodeClean(node);
|
markNodeClean(node);
|
||||||
p2.end();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1359,8 +1313,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
editorInfo.ace_isCaret = isCaret;
|
editorInfo.ace_isCaret = isCaret;
|
||||||
|
|
||||||
// prereq: isCaret()
|
// prereq: isCaret()
|
||||||
|
|
||||||
|
|
||||||
const caretLine = () => rep.selStart[0];
|
const caretLine = () => rep.selStart[0];
|
||||||
|
|
||||||
editorInfo.ace_caretLine = caretLine;
|
editorInfo.ace_caretLine = caretLine;
|
||||||
|
@ -1398,9 +1350,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
const getPointForLineAndChar = (lineAndChar) => {
|
const getPointForLineAndChar = (lineAndChar) => {
|
||||||
const line = lineAndChar[0];
|
const line = lineAndChar[0];
|
||||||
let charsLeft = lineAndChar[1];
|
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);
|
const lineEntry = rep.lines.atIndex(line);
|
||||||
charsLeft -= lineEntry.lineMarker;
|
charsLeft -= lineEntry.lineMarker;
|
||||||
if (charsLeft < 0) {
|
if (charsLeft < 0) {
|
||||||
|
@ -1574,7 +1523,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);
|
throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (function doRecordUndoInformation(changes) {
|
|
||||||
((changes) => {
|
((changes) => {
|
||||||
const editEvent = currentCallStack.editEvent;
|
const editEvent = currentCallStack.editEvent;
|
||||||
if (editEvent.eventType === 'nonundoable') {
|
if (editEvent.eventType === 'nonundoable') {
|
||||||
|
@ -1597,7 +1545,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
}
|
}
|
||||||
})(changes);
|
})(changes);
|
||||||
|
|
||||||
// rep.alltext = Changeset.applyToText(changes, rep.alltext);
|
|
||||||
Changeset.mutateAttributionLines(changes, rep.alines, rep.apool);
|
Changeset.mutateAttributionLines(changes, rep.alines, rep.apool);
|
||||||
|
|
||||||
if (changesetTracker.isTracking()) {
|
if (changesetTracker.isTracking()) {
|
||||||
|
@ -1605,9 +1552,9 @@ 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 lineAndColumnFromChar = (x) => {
|
||||||
const lineEntry = rep.lines.atOffset(x);
|
const lineEntry = rep.lines.atOffset(x);
|
||||||
const lineStart = rep.lines.offsetOfEntry(lineEntry);
|
const lineStart = rep.lines.offsetOfEntry(lineEntry);
|
||||||
|
@ -1994,7 +1941,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
theChangeset = builder.toString();
|
theChangeset = builder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// dmesg(htmlPrettyEscape(theChangeset));
|
|
||||||
doRepApplyChangeset(theChangeset);
|
doRepApplyChangeset(theChangeset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2170,13 +2116,8 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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;
|
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) => (
|
const isPadLoading = (eventType) => (
|
||||||
|
@ -2228,10 +2169,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
// indicating inserted content. for example, [0,0] means content was inserted
|
// 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,
|
// 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.
|
// 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 cleanNodeForIndexCache = {};
|
||||||
const N = rep.lines.length(); // old number of lines
|
const N = rep.lines.length(); // old number of lines
|
||||||
|
@ -2242,7 +2179,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
// in the document, return that node.
|
// in the document, return that node.
|
||||||
// if (i) is out of bounds, return true. else return false.
|
// if (i) is out of bounds, return true. else return false.
|
||||||
if (cleanNodeForIndexCache[i] === undefined) {
|
if (cleanNodeForIndexCache[i] === undefined) {
|
||||||
p.forIndices++;
|
|
||||||
let result;
|
let result;
|
||||||
if (i < 0 || i >= N) {
|
if (i < 0 || i >= N) {
|
||||||
result = true; // truthy, but no actual node
|
result = true; // truthy, but no actual node
|
||||||
|
@ -2258,7 +2194,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
|
|
||||||
const isConsecutive = (i) => {
|
const isConsecutive = (i) => {
|
||||||
if (isConsecutiveCache[i] === undefined) {
|
if (isConsecutiveCache[i] === undefined) {
|
||||||
p.consecutives++;
|
|
||||||
isConsecutiveCache[i] = (() => {
|
isConsecutiveCache[i] = (() => {
|
||||||
// returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes,
|
// 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
|
// or document boundaries, are consecutive in the changed DOM
|
||||||
|
@ -2320,7 +2255,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
|
|
||||||
const correctlyAssignLine = (line) => {
|
const correctlyAssignLine = (line) => {
|
||||||
if (correctedLines[line]) return true;
|
if (correctedLines[line]) return true;
|
||||||
p.corrections++;
|
|
||||||
correctedLines[line] = true;
|
correctedLines[line] = true;
|
||||||
// "line" is an index of a line in the un-updated rep.
|
// "line" is an index of a line in the un-updated rep.
|
||||||
// returns whether line was already correctly assigned (i.e. correctly
|
// returns whether line was already correctly assigned (i.e. correctly
|
||||||
|
@ -2384,16 +2318,13 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (N === 0) {
|
if (N === 0) {
|
||||||
p.cancel();
|
|
||||||
if (!isConsecutive(0)) {
|
if (!isConsecutive(0)) {
|
||||||
splitRange(0, 0);
|
splitRange(0, 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p.mark('topbot');
|
|
||||||
detectChangesAroundLine(0, 1);
|
detectChangesAroundLine(0, 1);
|
||||||
detectChangesAroundLine(N - 1, 1);
|
detectChangesAroundLine(N - 1, 1);
|
||||||
|
|
||||||
p.mark('obs');
|
|
||||||
for (const k in observedChanges.cleanNodesNearChanges) {
|
for (const k in observedChanges.cleanNodesNearChanges) {
|
||||||
if (observedChanges.cleanNodesNearChanges[k]) {
|
if (observedChanges.cleanNodesNearChanges[k]) {
|
||||||
const key = k.substring(1);
|
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 = [];
|
const dirtyRanges = [];
|
||||||
|
@ -2414,8 +2341,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]);
|
dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
p.end();
|
|
||||||
|
|
||||||
return dirtyRanges;
|
return dirtyRanges;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2428,13 +2353,11 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNodeDirty = (n) => {
|
const isNodeDirty = (n) => {
|
||||||
const p = PROFILER('cleanCheck', false); // eslint-disable-line new-cap
|
|
||||||
if (n.parentNode !== root) return true;
|
if (n.parentNode !== root) return true;
|
||||||
const data = getAssoc(n, 'dirtiness');
|
const data = getAssoc(n, 'dirtiness');
|
||||||
if (!data) return true;
|
if (!data) return true;
|
||||||
if (n.id !== data.nodeId) return true;
|
if (n.id !== data.nodeId) return true;
|
||||||
if (n.innerHTML !== data.knownHTML) return true;
|
if (n.innerHTML !== data.knownHTML) return true;
|
||||||
p.end();
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2641,7 +2564,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
const tabSize = THE_TAB.length;
|
const tabSize = THE_TAB.length;
|
||||||
const toDelete = ((col2 - 1) % tabSize) + 1;
|
const toDelete = ((col2 - 1) % tabSize) + 1;
|
||||||
performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], '');
|
performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], '');
|
||||||
// scrollSelectionIntoView();
|
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2730,7 +2652,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
const altKey = evt.altKey;
|
const altKey = evt.altKey;
|
||||||
const shiftKey = evt.shiftKey;
|
const shiftKey = evt.shiftKey;
|
||||||
|
|
||||||
// dmesg("keyevent type: "+type+", which: "+which);
|
|
||||||
// Don't take action based on modifier keys going up and down.
|
// Don't take action based on modifier keys going up and down.
|
||||||
// Modifier keys do not generate "keypress" events.
|
// Modifier keys do not generate "keypress" events.
|
||||||
// 224 is the command-key under Mac Firefox.
|
// 224 is the command-key under Mac Firefox.
|
||||||
|
@ -2930,7 +2851,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
fastIncorp(4);
|
fastIncorp(4);
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
doReturnKey();
|
doReturnKey();
|
||||||
// scrollSelectionIntoView();
|
|
||||||
scheduler.setTimeout(() => {
|
scheduler.setTimeout(() => {
|
||||||
outerWin.scrollBy(-100, 0);
|
outerWin.scrollBy(-100, 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
@ -2979,7 +2899,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
fastIncorp(5);
|
fastIncorp(5);
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
doTabKey(evt.shiftKey);
|
doTabKey(evt.shiftKey);
|
||||||
// scrollSelectionIntoView();
|
|
||||||
specialHandled = true;
|
specialHandled = true;
|
||||||
}
|
}
|
||||||
if ((!specialHandled) &&
|
if ((!specialHandled) &&
|
||||||
|
@ -3273,7 +3192,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
const diveDeep = () => {
|
const diveDeep = () => {
|
||||||
while (p.node.childNodes.length > 0) {
|
while (p.node.childNodes.length > 0) {
|
||||||
// && (p.node == root || p.node.parentNode == root)) {
|
|
||||||
if (p.index === 0) {
|
if (p.index === 0) {
|
||||||
p.node = p.node.firstChild;
|
p.node = p.node.firstChild;
|
||||||
p.maxIndex = nodeMaxIndex(p.node);
|
p.maxIndex = nodeMaxIndex(p.node);
|
||||||
|
@ -3504,16 +3422,7 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
|
|
||||||
const teardown = () => _teardownActions.forEach((a) => a());
|
const teardown = () => _teardownActions.forEach((a) => a());
|
||||||
|
|
||||||
let inInternationalComposition = false;
|
let inInternationalComposition = null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
|
editorInfo.ace_getInInternationalComposition = () => inInternationalComposition;
|
||||||
|
|
||||||
const bindTheEventHandlers = () => {
|
const bindTheEventHandlers = () => {
|
||||||
|
@ -3523,9 +3432,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
$(document).on('click', handleClick);
|
$(document).on('click', handleClick);
|
||||||
// dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer
|
// dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer
|
||||||
$(outerWin.document).on('click', hideEditBarDropdowns);
|
$(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.
|
// If non-nullish, pasting on a link should be suppressed.
|
||||||
let suppressPasteOnLink = null;
|
let suppressPasteOnLink = null;
|
||||||
|
@ -3602,8 +3508,15 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document.documentElement).on('compositionstart', handleCompositionEvent);
|
$(document.documentElement).on('compositionstart', () => {
|
||||||
$(document.documentElement).on('compositionend', handleCompositionEvent);
|
if (inInternationalComposition) return;
|
||||||
|
inInternationalComposition = new Promise((resolve) => {
|
||||||
|
$(document.documentElement).one('compositionend', () => {
|
||||||
|
inInternationalComposition = null;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const topLevel = (n) => {
|
const topLevel = (n) => {
|
||||||
|
@ -3717,7 +3630,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
|
|
||||||
const mods = [];
|
const mods = [];
|
||||||
for (let n = firstLine; n <= lastLine; n++) {
|
for (let n = firstLine; n <= lastLine; n++) {
|
||||||
// var t = '';
|
|
||||||
let level = 0;
|
let level = 0;
|
||||||
let togglingOn = true;
|
let togglingOn = true;
|
||||||
const listType = /([a-z]+)([0-9]+)/.exec(getLineListType(n));
|
const listType = /([a-z]+)([0-9]+)/.exec(getLineListType(n));
|
||||||
|
@ -3728,7 +3640,6 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listType) {
|
if (listType) {
|
||||||
// t = listType[1];
|
|
||||||
level = Number(listType[2]);
|
level = Number(listType[2]);
|
||||||
}
|
}
|
||||||
const t = getLineListType(n);
|
const t = getLineListType(n);
|
||||||
|
|
|
@ -195,7 +195,7 @@ const getSelectionRange = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection.rangeCount > 0) {
|
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
|
||||||
return selection.getRangeAt(0);
|
return selection.getRangeAt(0);
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -109,14 +109,6 @@ exports.chat = (() => {
|
||||||
// correct the time
|
// correct the time
|
||||||
msg.time += this._pad.clientTimeOffset;
|
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 (!msg.userId) {
|
||||||
/*
|
/*
|
||||||
* If, for a bug or a database corruption, the message coming from the
|
* 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.');
|
'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 '-';
|
if (c === '.') return '-';
|
||||||
return `z${c.charCodeAt(0)}z`;
|
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
|
// the hook args
|
||||||
const ctx = {
|
const ctx = {
|
||||||
authorName,
|
authorName: msg.userName != null ? msg.userName : html10n.get('pad.userlist.unnamed'),
|
||||||
author: msg.userId,
|
author: msg.userId,
|
||||||
text,
|
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
|
||||||
sticky: false,
|
sticky: false,
|
||||||
timestamp: msg.time,
|
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,
|
duration: 4000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -159,7 +152,7 @@ exports.chat = (() => {
|
||||||
// does this message contain this user's name? (is the curretn user mentioned?)
|
// does this message contain this user's name? (is the curretn user mentioned?)
|
||||||
const myName = $('#myusernameedit').val();
|
const myName = $('#myusernameedit').val();
|
||||||
const wasMentioned =
|
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 the user was mentioned, make the message sticky
|
||||||
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
|
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
|
||||||
|
@ -170,11 +163,23 @@ exports.chat = (() => {
|
||||||
|
|
||||||
// Call chat message hook
|
// Call chat message hook
|
||||||
hooks.aCallAll('chatNewMessage', ctx, () => {
|
hooks.aCallAll('chatNewMessage', ctx, () => {
|
||||||
const html =
|
const cls = authorClass(ctx.author);
|
||||||
`<p data-authorId='${msg.userId}' class='${authorClass}'><b>${authorName}:</b>` +
|
const chatMsg = $('<p>')
|
||||||
`<span class='time ${authorClass}'>${ctx.timeStr}</span> ${ctx.text}</p>`;
|
.attr('data-authorId', ctx.author)
|
||||||
if (isHistoryAdd) $(html).insertAfter('#chatloadmessagesbutton');
|
.addClass(cls)
|
||||||
else $('#chattext').append(html);
|
.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??
|
// should we increment the counter??
|
||||||
if (increment && !isHistoryAdd) {
|
if (increment && !isHistoryAdd) {
|
||||||
|
@ -185,10 +190,11 @@ exports.chat = (() => {
|
||||||
|
|
||||||
if (!chatOpen && ctx.duration > 0) {
|
if (!chatOpen && ctx.duration > 0) {
|
||||||
$.gritter.add({
|
$.gritter.add({
|
||||||
// Note: ctx.authorName and ctx.text are already HTML-escaped.
|
|
||||||
text: $('<p>')
|
text: $('<p>')
|
||||||
.append($('<span>').addClass('author-name').html(ctx.authorName))
|
.append($('<span>').addClass('author-name').text(ctx.authorName))
|
||||||
.append(ctx.text),
|
// 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,
|
sticky: ctx.sticky,
|
||||||
time: 5000,
|
time: 5000,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
|
|
|
@ -39,22 +39,18 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
pad = _pad; // Inject pad to avoid a circular dependency.
|
pad = _pad; // Inject pad to avoid a circular dependency.
|
||||||
|
|
||||||
let rev = serverVars.rev;
|
let rev = serverVars.rev;
|
||||||
let state = 'IDLE';
|
let committing = false;
|
||||||
let stateMessage;
|
let stateMessage;
|
||||||
let channelState = 'CONNECTING';
|
let channelState = 'CONNECTING';
|
||||||
let lastCommitTime = 0;
|
let lastCommitTime = 0;
|
||||||
let initialStartConnectTime = 0;
|
let initialStartConnectTime = 0;
|
||||||
|
let commitDelay = 500;
|
||||||
|
|
||||||
const userId = initialUserInfo.userId;
|
const userId = initialUserInfo.userId;
|
||||||
// var socket;
|
// var socket;
|
||||||
const userSet = {}; // userId -> userInfo
|
const userSet = {}; // userId -> userInfo
|
||||||
userSet[userId] = initialUserInfo;
|
userSet[userId] = initialUserInfo;
|
||||||
|
|
||||||
const caughtErrors = [];
|
|
||||||
const caughtErrorCatchers = [];
|
|
||||||
const caughtErrorTimes = [];
|
|
||||||
const msgQueue = [];
|
|
||||||
|
|
||||||
let isPendingRevision = false;
|
let isPendingRevision = false;
|
||||||
|
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
|
@ -78,77 +74,49 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUserChanges = () => {
|
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 ((!getSocket()) || channelState === 'CONNECTING') {
|
||||||
if (channelState === 'CONNECTING' && (((+new Date()) - initialStartConnectTime) > 20000)) {
|
if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
|
||||||
setChannelState('DISCONNECTED', 'initsocketfail');
|
setChannelState('DISCONNECTED', 'initsocketfail');
|
||||||
} else {
|
} else {
|
||||||
// check again in a bit
|
// check again in a bit
|
||||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 1000);
|
setTimeout(handleUserChanges, 1000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = (+new Date());
|
if (committing) {
|
||||||
|
if (now - lastCommitTime > 20000) {
|
||||||
if (state !== 'IDLE') {
|
|
||||||
if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 20000) {
|
|
||||||
// a commit is taking too long
|
// a commit is taking too long
|
||||||
setChannelState('DISCONNECTED', 'slowcommit');
|
setChannelState('DISCONNECTED', 'slowcommit');
|
||||||
} else if (state === 'COMMITTING' && msgQueue.length === 0 && (t - lastCommitTime) > 5000) {
|
} else if (now - lastCommitTime > 5000) {
|
||||||
callbacks.onConnectionTrouble('SLOW');
|
callbacks.onConnectionTrouble('SLOW');
|
||||||
} else {
|
} else {
|
||||||
// run again in a few seconds, to detect a disconnect
|
// run again in a few seconds, to detect a disconnect
|
||||||
setTimeout(wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges), 3000);
|
setTimeout(handleUserChanges, 3000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const earliestCommit = lastCommitTime + 500;
|
const earliestCommit = lastCommitTime + commitDelay;
|
||||||
if (t < earliestCommit) {
|
if (now < earliestCommit) {
|
||||||
setTimeout(
|
setTimeout(handleUserChanges, earliestCommit - now);
|
||||||
wrapRecordingErrors('setTimeout(handleUserChanges)', handleUserChanges),
|
|
||||||
earliestCommit - t);
|
|
||||||
return;
|
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;
|
let sentMessage = false;
|
||||||
// Check if there are any pending revisions to be received from server.
|
// 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
|
// Allow only if there are no pending revisions to be received from server
|
||||||
if (!isPendingRevision) {
|
if (!isPendingRevision) {
|
||||||
const userChangesData = editor.prepareUserChangeset();
|
const userChangesData = editor.prepareUserChangeset();
|
||||||
if (userChangesData.changeset) {
|
if (userChangesData.changeset) {
|
||||||
lastCommitTime = t;
|
lastCommitTime = now;
|
||||||
state = 'COMMITTING';
|
committing = true;
|
||||||
stateMessage = {
|
stateMessage = {
|
||||||
type: 'USER_CHANGES',
|
type: 'USER_CHANGES',
|
||||||
baseRev: rev,
|
baseRev: rev,
|
||||||
|
@ -161,20 +129,30 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// run again in a few seconds, to check if there was a reconnection attempt
|
// 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) {
|
if (sentMessage) {
|
||||||
// run again in a few seconds, to detect a disconnect
|
// 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 = () => {
|
const setUpSocket = () => {
|
||||||
setChannelState('CONNECTED');
|
setChannelState('CONNECTED');
|
||||||
doDeferredActions();
|
doDeferredActions();
|
||||||
|
|
||||||
initialStartConnectTime = +new Date();
|
initialStartConnectTime = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = (msg) => {
|
const sendMessage = (msg) => {
|
||||||
|
@ -186,24 +164,20 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const wrapRecordingErrors = (catcher, func) => function (...args) {
|
const serverMessageTaskQueue = new class {
|
||||||
try {
|
constructor() {
|
||||||
return func.call(this, ...args);
|
this._promiseChain = Promise.resolve();
|
||||||
} catch (e) {
|
|
||||||
caughtErrors.push(e);
|
|
||||||
caughtErrorCatchers.push(catcher);
|
|
||||||
caughtErrorTimes.push(+new Date());
|
|
||||||
// console.dir({catcher: catcher, e: e});
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const callCatchingErrors = (catcher, func) => {
|
async enqueue(fn) {
|
||||||
try {
|
const taskPromise = this._promiseChain.then(fn);
|
||||||
wrapRecordingErrors(catcher, func)();
|
// Use .catch() to prevent rejections from halting the queue.
|
||||||
} catch (e) { /* absorb*/
|
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) => {
|
const handleMessageFromServer = (evt) => {
|
||||||
if (!getSocket()) return;
|
if (!getSocket()) return;
|
||||||
|
@ -213,117 +187,61 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
const msg = wrapper.data;
|
const msg = wrapper.data;
|
||||||
|
|
||||||
if (msg.type === 'NEW_CHANGES') {
|
if (msg.type === 'NEW_CHANGES') {
|
||||||
const newRev = msg.newRev;
|
serverMessageTaskQueue.enqueue(async () => {
|
||||||
const changeset = msg.changeset;
|
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
|
||||||
const author = (msg.author || '');
|
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
|
||||||
const apool = msg.apool;
|
// 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
|
||||||
// When inInternationalComposition, msg pushed msgQueue.
|
// the `await` but before the next line of code after the `await` (or, if it is
|
||||||
if (msgQueue.length > 0 || editor.getInInternationalComposition()) {
|
// possible, that the chances are so small or the consequences so minor that it's not
|
||||||
const oldRev = msgQueue.length > 0 ? msgQueue[msgQueue.length - 1].newRev : rev;
|
// worth addressing).
|
||||||
if (newRev !== (oldRev + 1)) {
|
await editor.getInInternationalComposition();
|
||||||
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${oldRev + 1}`);
|
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");
|
// setChannelState("DISCONNECTED", "badmessage_newchanges");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msgQueue.push(msg);
|
rev = newRev;
|
||||||
return;
|
editor.applyChangesToBase(changeset, author, apool);
|
||||||
}
|
});
|
||||||
|
|
||||||
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') {
|
} else if (msg.type === 'ACCEPT_COMMIT') {
|
||||||
const newRev = msg.newRev;
|
serverMessageTaskQueue.enqueue(() => {
|
||||||
if (msgQueue.length > 0) {
|
const newRev = msg.newRev;
|
||||||
if (newRev !== (msgQueue[msgQueue.length - 1].newRev + 1)) {
|
if (newRev !== (rev + 1)) {
|
||||||
window.console.warn('bad message revision on ACCEPT_COMMIT: ' +
|
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
|
||||||
`${newRev} not ${msgQueue[msgQueue.length - 1][0] + 1}`);
|
|
||||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msgQueue.push(msg);
|
rev = newRev;
|
||||||
return;
|
acceptCommit();
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
callCatchingErrors('onConnectionTrouble', () => {
|
|
||||||
callbacks.onConnectionTrouble('OK');
|
|
||||||
});
|
|
||||||
handleUserChanges();
|
|
||||||
} else if (msg.type === 'CLIENT_RECONNECT') {
|
} else if (msg.type === 'CLIENT_RECONNECT') {
|
||||||
// Server sends a CLIENT_RECONNECT message when there is a 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
|
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
|
||||||
if (msg.noChanges) {
|
serverMessageTaskQueue.enqueue(() => {
|
||||||
// If no revisions are pending, just make everything normal
|
if (msg.noChanges) {
|
||||||
setIsPendingRevision(false);
|
// If no revisions are pending, just make everything normal
|
||||||
return;
|
setIsPendingRevision(false);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
const headRev = msg.headRev;
|
const {headRev, newRev, changeset, author = '', apool} = msg;
|
||||||
const newRev = msg.newRev;
|
if (newRev !== (rev + 1)) {
|
||||||
const changeset = msg.changeset;
|
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
|
||||||
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");
|
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.type = 'NEW_CHANGES';
|
rev = newRev;
|
||||||
msgQueue.push(msg);
|
if (author === pad.getUserId()) {
|
||||||
return;
|
acceptCommit();
|
||||||
}
|
} else {
|
||||||
|
editor.applyChangesToBase(changeset, author, apool);
|
||||||
if (newRev !== (rev + 1)) {
|
}
|
||||||
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
|
if (newRev === headRev) {
|
||||||
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
|
// Once we have applied all pending revisions, make everything normal
|
||||||
return;
|
setIsPendingRevision(false);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
rev = newRev;
|
|
||||||
if (author === pad.getUserId()) {
|
|
||||||
editor.applyPreparedChangesetToBase();
|
|
||||||
setStateIdle();
|
|
||||||
callCatchingErrors('onInternalAction', () => {
|
|
||||||
callbacks.onInternalAction('commitAcceptedByServer');
|
|
||||||
});
|
|
||||||
callCatchingErrors('onConnectionTrouble', () => {
|
|
||||||
callbacks.onConnectionTrouble('OK');
|
|
||||||
});
|
|
||||||
handleUserChanges();
|
|
||||||
} 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') {
|
} else if (msg.type === 'USER_NEWINFO') {
|
||||||
const userInfo = msg.userInfo;
|
const userInfo = msg.userInfo;
|
||||||
const id = userInfo.userId;
|
const id = userInfo.userId;
|
||||||
|
@ -496,7 +414,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
const obj = {};
|
const obj = {};
|
||||||
obj.userInfo = userSet[userId];
|
obj.userInfo = userSet[userId];
|
||||||
obj.baseRev = rev;
|
obj.baseRev = rev;
|
||||||
if (state === 'COMMITTING' && stateMessage) {
|
if (committing && stateMessage) {
|
||||||
obj.committedChangeset = stateMessage.changeset;
|
obj.committedChangeset = stateMessage.changeset;
|
||||||
obj.committedChangesetAPool = stateMessage.apool;
|
obj.committedChangesetAPool = stateMessage.apool;
|
||||||
editor.applyPreparedChangesetToBase();
|
editor.applyPreparedChangesetToBase();
|
||||||
|
@ -510,7 +428,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
};
|
};
|
||||||
|
|
||||||
const setStateIdle = () => {
|
const setStateIdle = () => {
|
||||||
state = 'IDLE';
|
committing = false;
|
||||||
callbacks.onInternalAction('newlyIdle');
|
callbacks.onInternalAction('newlyIdle');
|
||||||
schedulePerhapsCallIdleFuncs();
|
schedulePerhapsCallIdleFuncs();
|
||||||
};
|
};
|
||||||
|
@ -528,7 +446,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
|
|
||||||
const schedulePerhapsCallIdleFuncs = () => {
|
const schedulePerhapsCallIdleFuncs = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (state === 'IDLE') {
|
if (!committing) {
|
||||||
while (idleFuncs.length > 0) {
|
while (idleFuncs.length > 0) {
|
||||||
const f = idleFuncs.shift();
|
const f = idleFuncs.shift();
|
||||||
f();
|
f();
|
||||||
|
@ -571,6 +489,8 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
setChannelState,
|
setChannelState,
|
||||||
setStateIdle,
|
setStateIdle,
|
||||||
setIsPendingRevision,
|
setIsPendingRevision,
|
||||||
|
set commitDelay(ms) { commitDelay = ms; },
|
||||||
|
get commitDelay() { return commitDelay; },
|
||||||
};
|
};
|
||||||
|
|
||||||
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
|
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
|
||||||
|
@ -578,8 +498,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
|
||||||
|
|
||||||
editor.setProperty('userAuthor', userId);
|
editor.setProperty('userAuthor', userId);
|
||||||
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
|
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
|
||||||
editor.setUserChangeNotificationCallback(
|
editor.setUserChangeNotificationCallback(handleUserChanges);
|
||||||
wrapRecordingErrors('handleUserChanges', handleUserChanges));
|
|
||||||
|
|
||||||
setUpSocket();
|
setUpSocket();
|
||||||
return self;
|
return self;
|
||||||
|
|
|
@ -56,7 +56,7 @@ const getAttribute = (n, a) => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
// supportedElems are Supported natively within Etherpad and don't require a plugin
|
// supportedElems are Supported natively within Etherpad and don't require a plugin
|
||||||
const supportedElems = [
|
const supportedElems = new Set([
|
||||||
'author',
|
'author',
|
||||||
'b',
|
'b',
|
||||||
'bold',
|
'bold',
|
||||||
|
@ -76,7 +76,7 @@ const supportedElems = [
|
||||||
'span',
|
'span',
|
||||||
'u',
|
'u',
|
||||||
'ul',
|
'ul',
|
||||||
];
|
]);
|
||||||
|
|
||||||
const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => {
|
const makeContentCollector = (collectStyles, abrowser, apool, className2Author) => {
|
||||||
const _blockElems = {
|
const _blockElems = {
|
||||||
|
@ -88,7 +88,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
||||||
|
|
||||||
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
hooks.callAll('ccRegisterBlockElements').forEach((element) => {
|
||||||
_blockElems[element] = 1;
|
_blockElems[element] = 1;
|
||||||
supportedElems.push(element);
|
supportedElems.add(element);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isBlockElement = (n) => !!_blockElems[tagName(n) || ''];
|
const isBlockElement = (n) => !!_blockElems[tagName(n) || ''];
|
||||||
|
@ -318,6 +318,7 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
||||||
cc.incrementAttrib(state, na);
|
cc.incrementAttrib(state, na);
|
||||||
};
|
};
|
||||||
cc.collectContent = function (node, state) {
|
cc.collectContent = function (node, state) {
|
||||||
|
let unsupportedElements = null;
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = {
|
state = {
|
||||||
flags: { /* name -> nesting counter*/
|
flags: { /* name -> nesting counter*/
|
||||||
|
@ -333,16 +334,15 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
||||||
'list': 'bullet1',
|
'list': 'bullet1',
|
||||||
*/
|
*/
|
||||||
},
|
},
|
||||||
|
unsupportedElements: new Set(),
|
||||||
};
|
};
|
||||||
|
unsupportedElements = state.unsupportedElements;
|
||||||
}
|
}
|
||||||
const localAttribs = state.localAttribs;
|
const localAttribs = state.localAttribs;
|
||||||
state.localAttribs = null;
|
state.localAttribs = null;
|
||||||
const isBlock = isBlockElement(node);
|
const isBlock = isBlockElement(node);
|
||||||
if (!isBlock && node.name && (node.name !== 'body')) {
|
if (!isBlock && node.name && (node.name !== 'body')) {
|
||||||
if (supportedElems.indexOf(node.name) === -1) {
|
if (!supportedElems.has(node.name)) state.unsupportedElements.add(node.name);
|
||||||
console.warn('Plugin missing: ' +
|
|
||||||
`You might want to install a plugin to support this node name: ${node.name}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const isEmpty = _isEmpty(node, state);
|
const isEmpty = _isEmpty(node, state);
|
||||||
if (isBlock) _ensureColumnZero(state);
|
if (isBlock) _ensureColumnZero(state);
|
||||||
|
@ -621,6 +621,10 @@ const makeContentCollector = (collectStyles, abrowser, apool, className2Author)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.localAttribs = localAttribs;
|
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
|
// can pass a falsy value for end of doc
|
||||||
cc.notifyNextNode = (node) => {
|
cc.notifyNextNode = (node) => {
|
||||||
|
|
|
@ -218,7 +218,14 @@ const sendClientReady = (isReconnect, messageType) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handshake = () => {
|
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, '/', {
|
socket = pad.socket = socketio.connect(exports.baseURL, '/', {
|
||||||
|
query: {padId},
|
||||||
reconnectionAttempts: 5,
|
reconnectionAttempts: 5,
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 1000,
|
||||||
|
|
|
@ -21,15 +21,15 @@ const Cookies = require('./pad_utils').Cookies;
|
||||||
exports.padcookie = new class {
|
exports.padcookie = new class {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
|
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
const prefs = this.readPrefs_() || {};
|
const prefs = this.readPrefs_() || {};
|
||||||
delete prefs.userId;
|
delete prefs.userId;
|
||||||
delete prefs.name;
|
delete prefs.name;
|
||||||
delete prefs.colorId;
|
delete prefs.colorId;
|
||||||
this.prefs_ = prefs;
|
this.writePrefs_(prefs);
|
||||||
this.savePrefs_();
|
// Re-read the saved cookie to test if cookies are enabled.
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
if (this.readPrefs_() == null) {
|
if (this.readPrefs_() == null) {
|
||||||
$.gritter.add({
|
$.gritter.add({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
@ -50,16 +50,21 @@ exports.padcookie = new class {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
savePrefs_() {
|
writePrefs_(prefs) {
|
||||||
Cookies.set(this.cookieName_, JSON.stringify(this.prefs_), {expires: 365 * 100});
|
Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100});
|
||||||
}
|
}
|
||||||
|
|
||||||
getPref(prefName) {
|
getPref(prefName) {
|
||||||
return this.prefs_[prefName];
|
return this.readPrefs_()[prefName];
|
||||||
}
|
}
|
||||||
|
|
||||||
setPref(prefName, value) {
|
setPref(prefName, value) {
|
||||||
this.prefs_[prefName] = value;
|
const prefs = this.readPrefs_();
|
||||||
this.savePrefs_();
|
prefs[prefName] = value;
|
||||||
|
this.writePrefs_(prefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.writePrefs_({});
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
|
|
|
@ -311,6 +311,9 @@ padutils.setupGlobalExceptionHandler = () => {
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`unknown event: ${e.toString()}`);
|
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);
|
const errorId = randomString(20);
|
||||||
|
|
||||||
let msgAlreadyVisible = false;
|
let msgAlreadyVisible = false;
|
||||||
|
@ -328,12 +331,12 @@ padutils.setupGlobalExceptionHandler = () => {
|
||||||
$('<p>')
|
$('<p>')
|
||||||
.text('If the problem persists, please send this error message to your webmaster:'),
|
.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')
|
$('<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(`ErrorId: ${errorId}`)).append($('<br>'))
|
||||||
.append(txt(type)).append($('<br>'))
|
.append(txt(type)).append($('<br>'))
|
||||||
.append(txt(`URL: ${window.location.href}`)).append($('<br>'))
|
.append(txt(`URL: ${window.location.href}`)).append($('<br>'))
|
||||||
.append(txt(`UserAgent: ${navigator.userAgent}`)).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>')),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$.gritter.add({
|
$.gritter.add({
|
||||||
|
|
|
@ -22,66 +22,57 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const Ace2Common = require('./ace2_common');
|
const _entryWidth = (e) => (e && e.width) || 0;
|
||||||
const _ = require('./underscore');
|
|
||||||
|
|
||||||
const noop = Ace2Common.noop;
|
class Node {
|
||||||
|
constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) {
|
||||||
function SkipList() {
|
this.key = entry != null ? entry.key : null;
|
||||||
let PROFILER = window.PROFILER;
|
this.entry = entry;
|
||||||
if (!PROFILER) {
|
this.levels = levels;
|
||||||
PROFILER = () => ({
|
this.upPtrs = Array(levels).fill(null);
|
||||||
start: noop,
|
this.downPtrs = Array(levels).fill(null);
|
||||||
mark: noop,
|
this.downSkips = Array(levels).fill(downSkips);
|
||||||
literal: noop,
|
this.downSkipWidths = Array(levels).fill(downSkipWidths);
|
||||||
end: noop,
|
|
||||||
cancel: noop,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N
|
propagateWidthChange() {
|
||||||
const start = {
|
const oldWidth = this.downSkipWidths[0];
|
||||||
key: null,
|
const newWidth = _entryWidth(this.entry);
|
||||||
levels: 1,
|
const widthChange = newWidth - oldWidth;
|
||||||
upPtrs: [null],
|
let n = this;
|
||||||
downPtrs: [null],
|
let lvl = 0;
|
||||||
downSkips: [1],
|
while (lvl < n.levels) {
|
||||||
downSkipWidths: [0],
|
n.downSkipWidths[lvl] += widthChange;
|
||||||
};
|
lvl++;
|
||||||
const end = {
|
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
|
||||||
key: null,
|
n = n.upPtrs[lvl - 1];
|
||||||
levels: 1,
|
}
|
||||||
upPtrs: [null],
|
}
|
||||||
downPtrs: [null],
|
return widthChange;
|
||||||
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.
|
|
||||||
|
|
||||||
|
// A "point" object at index x allows modifications immediately after the first x elements of the
|
||||||
const _getPoint = (targetLoc) => {
|
// skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point
|
||||||
const numLevels = start.levels;
|
// 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 lvl = numLevels - 1;
|
||||||
let i = -1;
|
let i = -1;
|
||||||
let ws = 0;
|
let ws = 0;
|
||||||
const nodes = new Array(numLevels);
|
const nodes = new Array(numLevels);
|
||||||
const idxs = new Array(numLevels);
|
const idxs = new Array(numLevels);
|
||||||
const widthSkips = new Array(numLevels);
|
const widthSkips = new Array(numLevels);
|
||||||
nodes[lvl] = start;
|
nodes[lvl] = this._skipList._start;
|
||||||
idxs[lvl] = -1;
|
idxs[lvl] = -1;
|
||||||
widthSkips[lvl] = 0;
|
widthSkips[lvl] = 0;
|
||||||
while (lvl >= 0) {
|
while (lvl >= 0) {
|
||||||
let n = nodes[lvl];
|
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];
|
i += n.downSkips[lvl];
|
||||||
ws += n.downSkipWidths[lvl];
|
ws += n.downSkipWidths[lvl];
|
||||||
n = n.downPtrs[lvl];
|
n = n.downPtrs[lvl];
|
||||||
|
@ -94,50 +85,27 @@ function SkipList() {
|
||||||
nodes[lvl] = n;
|
nodes[lvl] = n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
this.idxs = idxs;
|
||||||
nodes,
|
this.nodes = nodes;
|
||||||
idxs,
|
this.widthSkips = widthSkips;
|
||||||
loc: targetLoc,
|
}
|
||||||
widthSkips,
|
|
||||||
toString: () => `getPoint(${targetLoc})`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const _getNodeAtOffset = (targetOffset) => {
|
toString() {
|
||||||
let i = 0;
|
return `Point(${this.loc})`;
|
||||||
let n = start;
|
}
|
||||||
let lvl = start.levels - 1;
|
|
||||||
while (lvl >= 0 && n.downPtrs[lvl]) {
|
insert(entry) {
|
||||||
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
|
if (entry.key == null) throw new Error('entry.key must not be null');
|
||||||
i += n.downSkipWidths[lvl];
|
if (this._skipList.containsKey(entry.key)) {
|
||||||
n = n.downPtrs[lvl];
|
throw new Error(`an entry with key ${entry.key} already exists`);
|
||||||
}
|
|
||||||
lvl--;
|
|
||||||
}
|
}
|
||||||
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;
|
const newNode = new Node(entry);
|
||||||
|
const pNodes = this.nodes;
|
||||||
const _insertKeyAtPoint = (point, newKey, entry) => {
|
const pIdxs = this.idxs;
|
||||||
const p = PROFILER('insertKey', false); // eslint-disable-line new-cap
|
const pLoc = this.loc;
|
||||||
const newNode = {
|
const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];
|
||||||
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 newWidth = _entryWidth(entry);
|
const newWidth = _entryWidth(entry);
|
||||||
p.mark('loop1');
|
|
||||||
|
|
||||||
// The new node will have at least level 1
|
// The new node will have at least level 1
|
||||||
// With a proability of 0.01^(n-1) the nodes level will be >= n
|
// With a proability of 0.01^(n-1) the nodes level will be >= n
|
||||||
|
@ -145,17 +113,17 @@ function SkipList() {
|
||||||
const lvl = newNode.levels;
|
const lvl = newNode.levels;
|
||||||
newNode.levels++;
|
newNode.levels++;
|
||||||
if (lvl === pNodes.length) {
|
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
|
// than the skiplist currently supports
|
||||||
pNodes[lvl] = start;
|
pNodes[lvl] = this._skipList._start;
|
||||||
pIdxs[lvl] = -1;
|
pIdxs[lvl] = -1;
|
||||||
start.levels++;
|
this._skipList._start.levels++;
|
||||||
end.levels++;
|
this._skipList._end.levels++;
|
||||||
start.downPtrs[lvl] = end;
|
this._skipList._start.downPtrs[lvl] = this._skipList._end;
|
||||||
end.upPtrs[lvl] = start;
|
this._skipList._end.upPtrs[lvl] = this._skipList._start;
|
||||||
start.downSkips[lvl] = numNodes + 1;
|
this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1;
|
||||||
start.downSkipWidths[lvl] = totalWidth;
|
this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth;
|
||||||
point.widthSkips[lvl] = 0;
|
this.widthSkips[lvl] = 0;
|
||||||
}
|
}
|
||||||
const me = newNode;
|
const me = newNode;
|
||||||
const up = pNodes[lvl];
|
const up = pNodes[lvl];
|
||||||
|
@ -168,31 +136,24 @@ function SkipList() {
|
||||||
me.upPtrs[lvl] = up;
|
me.upPtrs[lvl] = up;
|
||||||
me.downPtrs[lvl] = down;
|
me.downPtrs[lvl] = down;
|
||||||
down.upPtrs[lvl] = me;
|
down.upPtrs[lvl] = me;
|
||||||
const widthSkip1 = widthLoc - point.widthSkips[lvl];
|
const widthSkip1 = widthLoc - this.widthSkips[lvl];
|
||||||
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
|
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
|
||||||
up.downSkipWidths[lvl] = widthSkip1;
|
up.downSkipWidths[lvl] = widthSkip1;
|
||||||
me.downSkipWidths[lvl] = widthSkip2;
|
me.downSkipWidths[lvl] = widthSkip2;
|
||||||
}
|
}
|
||||||
p.mark('loop2');
|
|
||||||
p.literal(pNodes.length, 'PNL');
|
|
||||||
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
|
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
|
||||||
const up = pNodes[lvl];
|
const up = pNodes[lvl];
|
||||||
up.downSkips[lvl]++;
|
up.downSkips[lvl]++;
|
||||||
up.downSkipWidths[lvl] += newWidth;
|
up.downSkipWidths[lvl] += newWidth;
|
||||||
}
|
}
|
||||||
p.mark('map');
|
this._skipList._keyToNodeMap.set(newNode.key, newNode);
|
||||||
keyToNodeMap[`$KEY$${newKey}`] = newNode;
|
this._skipList._totalWidth += newWidth;
|
||||||
numNodes++;
|
}
|
||||||
totalWidth += newWidth;
|
|
||||||
p.end();
|
|
||||||
};
|
|
||||||
|
|
||||||
const _getNodeAtPoint = (point) => point.nodes[0].downPtrs[0];
|
delete() {
|
||||||
|
const elem = this.nodes[0].downPtrs[0];
|
||||||
const _deleteKeyAtPoint = (point) => {
|
|
||||||
const elem = point.nodes[0].downPtrs[0];
|
|
||||||
const elemWidth = _entryWidth(elem.entry);
|
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) {
|
if (i < elem.levels) {
|
||||||
const up = elem.upPtrs[i];
|
const up = elem.upPtrs[i];
|
||||||
const down = elem.downPtrs[i];
|
const down = elem.downPtrs[i];
|
||||||
|
@ -203,59 +164,76 @@ function SkipList() {
|
||||||
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
|
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
|
||||||
up.downSkipWidths[i] = totalWidthSkip;
|
up.downSkipWidths[i] = totalWidthSkip;
|
||||||
} else {
|
} else {
|
||||||
const up = point.nodes[i];
|
const up = this.nodes[i];
|
||||||
up.downSkips[i]--;
|
up.downSkips[i]--;
|
||||||
up.downSkipWidths[i] -= elemWidth;
|
up.downSkipWidths[i] -= elemWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
delete keyToNodeMap[`$KEY$${elem.key}`];
|
this._skipList._keyToNodeMap.delete(elem.key);
|
||||||
numNodes--;
|
this._skipList._totalWidth -= elemWidth;
|
||||||
totalWidth -= elemWidth;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const _propagateWidthChange = (node) => {
|
getNode() {
|
||||||
const oldWidth = node.downSkipWidths[0];
|
return this.nodes[0].downPtrs[0];
|
||||||
const newWidth = _entryWidth(node.entry);
|
}
|
||||||
const widthChange = newWidth - oldWidth;
|
}
|
||||||
let n = node;
|
|
||||||
let lvl = 0;
|
/**
|
||||||
while (lvl < n.levels) {
|
* The skip-list contains "entries", JavaScript objects that each must have a unique "key"
|
||||||
n.downSkipWidths[lvl] += widthChange;
|
* property that is a string.
|
||||||
lvl++;
|
*/
|
||||||
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
|
class SkipList {
|
||||||
n = n.upPtrs[lvl - 1];
|
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--;
|
||||||
}
|
}
|
||||||
totalWidth += widthChange;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const _getNodeIndex = (node, byWidth) => {
|
_getNodeIndex(node, byWidth) {
|
||||||
let dist = (byWidth ? 0 : -1);
|
let dist = (byWidth ? 0 : -1);
|
||||||
let n = node;
|
let n = node;
|
||||||
while (n !== start) {
|
while (n !== this._start) {
|
||||||
const lvl = n.levels - 1;
|
const lvl = n.levels - 1;
|
||||||
n = n.upPtrs[lvl];
|
n = n.upPtrs[lvl];
|
||||||
if (byWidth) dist += n.downSkipWidths[lvl];
|
if (byWidth) dist += n.downSkipWidths[lvl];
|
||||||
else dist += n.downSkips[lvl];
|
else dist += n.downSkips[lvl];
|
||||||
}
|
}
|
||||||
return dist;
|
return dist;
|
||||||
};
|
}
|
||||||
|
|
||||||
const _getNodeByKey = (key) => keyToNodeMap[`$KEY$${key}`];
|
|
||||||
|
|
||||||
// Returns index of first entry such that entryFunc(entry) is truthy,
|
// Returns index of first entry such that entryFunc(entry) is truthy,
|
||||||
// or length() if no such entry. Assumes all falsy entries come before
|
// or length() if no such entry. Assumes all falsy entries come before
|
||||||
// all truthy entries.
|
// all truthy entries.
|
||||||
|
search(entryFunc) {
|
||||||
|
let low = this._start;
|
||||||
const _search = (entryFunc) => {
|
let lvl = this._start.levels - 1;
|
||||||
let low = start;
|
|
||||||
let lvl = start.levels - 1;
|
|
||||||
let lowIndex = -1;
|
let lowIndex = -1;
|
||||||
|
|
||||||
const f = (node) => {
|
const f = (node) => {
|
||||||
if (node === start) return false;
|
if (node === this._start) return false;
|
||||||
else if (node === end) return true;
|
else if (node === this._end) return true;
|
||||||
else return entryFunc(node.entry);
|
else return entryFunc(node.entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -269,97 +247,85 @@ function SkipList() {
|
||||||
lvl--;
|
lvl--;
|
||||||
}
|
}
|
||||||
return lowIndex + 1;
|
return lowIndex + 1;
|
||||||
};
|
}
|
||||||
|
|
||||||
/*
|
length() { return this._keyToNodeMap.size; }
|
||||||
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) => {
|
|
||||||
if (i < 0) console.warn(`atIndex(${i})`);
|
|
||||||
if (i >= numNodes) console.warn(`atIndex(${i}>=${numNodes})`);
|
|
||||||
return _getNodeAtPoint(_getPoint(i)).entry;
|
|
||||||
},
|
|
||||||
// differs from Array.splice() in that new elements are in an array, not varargs
|
|
||||||
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);
|
|
||||||
console.trace();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newEntryArray) newEntryArray = [];
|
atIndex(i) {
|
||||||
const pt = _getPoint(start);
|
if (i < 0) console.warn(`atIndex(${i})`);
|
||||||
for (let i = 0; i < deleteCount; i++) {
|
if (i >= this._keyToNodeMap.size) console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
|
||||||
_deleteKeyAtPoint(pt);
|
return (new Point(this, i)).getNode().entry;
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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) => {
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
if (start < 0) start = 0;
|
// differs from Array.splice() in that new elements are in an array, not varargs
|
||||||
if (start > numNodes) start = numNodes;
|
splice(start, deleteCount, newEntryArray) {
|
||||||
if (end < 0) end = 0;
|
if (start < 0) console.warn(`splice(${start}, ...)`);
|
||||||
if (end > numNodes) end = 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();
|
||||||
|
}
|
||||||
|
|
||||||
window.dmesg(String([start, end, numNodes]));
|
if (!newEntryArray) newEntryArray = [];
|
||||||
if (end <= start) return [];
|
const pt = new Point(this, start);
|
||||||
let n = self.atIndex(start);
|
for (let i = 0; i < deleteCount; i++) pt.delete();
|
||||||
const array = [n];
|
for (let i = (newEntryArray.length - 1); i >= 0; i--) {
|
||||||
for (let i = 1; i < (end - start); i++) {
|
const entry = newEntryArray[i];
|
||||||
n = self.next(n);
|
pt.insert(entry);
|
||||||
array.push(n);
|
}
|
||||||
}
|
}
|
||||||
return array;
|
|
||||||
},
|
next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; }
|
||||||
atKey: (key) => _getNodeByKey(key).entry,
|
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; }
|
||||||
indexOfKey: (key) => _getNodeIndex(_getNodeByKey(key)),
|
push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); }
|
||||||
indexOfEntry: (entry) => self.indexOfKey(entry.key),
|
|
||||||
containsKey: (key) => !!(_getNodeByKey(key)),
|
slice(start, end) {
|
||||||
// gets the last entry starting at or before the offset
|
// act like Array.slice()
|
||||||
atOffset: (offset) => _getNodeAtOffset(offset).entry,
|
if (start === undefined) start = 0;
|
||||||
keyAtOffset: (offset) => self.atOffset(offset).key,
|
else if (start < 0) start += this._keyToNodeMap.size;
|
||||||
offsetOfKey: (key) => _getNodeIndex(_getNodeByKey(key), true),
|
if (end === undefined) end = this._keyToNodeMap.size;
|
||||||
offsetOfEntry: (entry) => self.offsetOfKey(entry.key),
|
else if (end < 0) end += this._keyToNodeMap.size;
|
||||||
setEntryWidth: (entry, width) => {
|
|
||||||
entry.width = width;
|
if (start < 0) start = 0;
|
||||||
_propagateWidthChange(_getNodeByKey(entry.key));
|
if (start > this._keyToNodeMap.size) start = this._keyToNodeMap.size;
|
||||||
},
|
if (end < 0) end = 0;
|
||||||
totalWidth: () => totalWidth,
|
if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size;
|
||||||
offsetOfIndex: (i) => {
|
|
||||||
if (i < 0) return 0;
|
window.dmesg(String([start, end, this._keyToNodeMap.size]));
|
||||||
if (i >= numNodes) return totalWidth;
|
if (end <= start) return [];
|
||||||
return self.offsetOfEntry(self.atIndex(i));
|
let n = this.atIndex(start);
|
||||||
},
|
const array = [n];
|
||||||
indexOfOffset: (offset) => {
|
for (let i = 1; i < (end - start); i++) {
|
||||||
if (offset <= 0) return 0;
|
n = this.next(n);
|
||||||
if (offset >= totalWidth) return numNodes;
|
array.push(n);
|
||||||
return self.indexOfEntry(self.atOffset(offset));
|
}
|
||||||
},
|
return array;
|
||||||
search: (entryFunc) => _search(entryFunc),
|
}
|
||||||
// debugToString: _debugToString,
|
|
||||||
debugGetPoint: _getPoint,
|
atKey(key) { return this._keyToNodeMap.get(key).entry; }
|
||||||
debugDepth: () => start.levels,
|
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) { 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;
|
||||||
|
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
|
||||||
|
}
|
||||||
|
totalWidth() { return this._totalWidth; }
|
||||||
|
offsetOfIndex(i) {
|
||||||
|
if (i < 0) return 0;
|
||||||
|
if (i >= this._keyToNodeMap.size) return this._totalWidth;
|
||||||
|
return this.offsetOfEntry(this.atIndex(i));
|
||||||
|
}
|
||||||
|
indexOfOffset(offset) {
|
||||||
|
if (offset <= 0) return 0;
|
||||||
|
if (offset >= this._totalWidth) return this._keyToNodeMap.size;
|
||||||
|
return this.indexOfEntry(this.atOffset(offset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = SkipList;
|
module.exports = SkipList;
|
||||||
|
|
|
@ -52,7 +52,7 @@ const init = () => {
|
||||||
Cookies.set('token', token, {expires: 60});
|
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
|
// send the ready message once we're connected
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
|
|
@ -23,9 +23,3 @@ button, .btn
|
||||||
color: #485365;
|
color: #485365;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttonicon:before, [class^="buttonicon-"]:before, [class*=" buttonicon-"]:before {
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
<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">
|
<link rel="localizations" type="application/l10n+json" href="locales.json">
|
||||||
<script type="text/javascript" src="static/js/vendors/html10n.js?v=<%=settings.randomVersionString%>"></script>
|
<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="robots" content="noindex, nofollow">
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
<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"); %>
|
<% e.begin_block("styles"); %>
|
||||||
<link href="../static/css/pad.css?v=<%=settings.randomVersionString%>" rel="stylesheet">
|
<link href="../static/css/pad.css?v=<%=settings.randomVersionString%>" rel="stylesheet">
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<meta name="referrer" content="no-referrer">
|
<meta name="referrer" content="no-referrer">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
|
<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"); %>
|
<% e.begin_block("timesliderStyles"); %>
|
||||||
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">
|
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">
|
||||||
<link rel="stylesheet" href="../../static/css/iframe_editor.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()));
|
.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]) {
|
||||||
this.timeout(250);
|
it(`can export from read-only pad ID, authn ${authn}`, async function () {
|
||||||
const ro = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
|
this.timeout(250);
|
||||||
.expect(200)
|
settings.requireAuthentication = authn;
|
||||||
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID));
|
const get = (ep) => {
|
||||||
const readOnlyId = JSON.parse(ro.text).data.readOnlyID;
|
let req = agent.get(ep);
|
||||||
|
if (authn) req = req.auth('user', 'user-password');
|
||||||
await agent.get(`/p/${readOnlyId}/export/html`)
|
return req.expect(200);
|
||||||
.expect(200)
|
};
|
||||||
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
const ro = await get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
|
||||||
|
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID));
|
||||||
await agent.get(`/p/${readOnlyId}/export/txt`)
|
const readOnlyId = JSON.parse(ro.text).data.readOnlyID;
|
||||||
.expect(200)
|
await get(`/p/${readOnlyId}/export/html`)
|
||||||
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
||||||
});
|
await get(`/p/${readOnlyId}/export/txt`)
|
||||||
|
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('Import/Export tests requiring AbiWord/LibreOffice', function () {
|
describe('Import/Export tests requiring AbiWord/LibreOffice', function () {
|
||||||
this.timeout(10000);
|
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 io = require('socket.io-client');
|
||||||
const padManager = require('../../../node/db/PadManager');
|
const padManager = require('../../../node/db/PadManager');
|
||||||
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||||
|
const readOnlyManager = require('../../../node/db/ReadOnlyManager');
|
||||||
const setCookieParser = require('set-cookie-parser');
|
const setCookieParser = require('set-cookie-parser');
|
||||||
const settings = require('../../../node/utils/Settings');
|
const settings = require('../../../node/utils/Settings');
|
||||||
|
|
||||||
|
@ -52,12 +53,16 @@ const connect = async (res) => {
|
||||||
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
|
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
|
||||||
|
|
||||||
logger.debug('socket.io connecting...');
|
logger.debug('socket.io connecting...');
|
||||||
|
let padId = null;
|
||||||
|
if (res) {
|
||||||
|
padId = res.req.path.split('/p/')[1];
|
||||||
|
}
|
||||||
const socket = io(`${common.baseUrl}/`, {
|
const socket = io(`${common.baseUrl}/`, {
|
||||||
forceNew: true, // Different tests will have different query parameters.
|
forceNew: true, // Different tests will have different query parameters.
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
|
// 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.
|
// express_sid cookie must be passed as a query parameter.
|
||||||
query: {cookie: reqCookieHdr},
|
query: {cookie: reqCookieHdr, padId},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await getSocketEvent(socket, 'connect');
|
await getSocketEvent(socket, 'connect');
|
||||||
|
@ -164,6 +169,33 @@ describe(__filename, function () {
|
||||||
const clientVars = await handshake(socket, 'pad');
|
const clientVars = await handshake(socket, 'pad');
|
||||||
assert.equal(clientVars.type, 'CLIENT_VARS');
|
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 () {
|
it('authz user /p/pad -> 200, ok', async function () {
|
||||||
this.timeout(400);
|
this.timeout(400);
|
||||||
settings.requireAuthentication = true;
|
settings.requireAuthentication = true;
|
||||||
|
@ -199,6 +231,24 @@ describe(__filename, function () {
|
||||||
const message = await handshake(socket, 'pad');
|
const message = await handshake(socket, 'pad');
|
||||||
assert.equal(message.accessStatus, 'deny');
|
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 () {
|
it('authn !cookie -> error', async function () {
|
||||||
this.timeout(400);
|
this.timeout(400);
|
||||||
settings.requireAuthentication = true;
|
settings.requireAuthentication = true;
|
||||||
|
|
|
@ -6,17 +6,16 @@ const helper = {};
|
||||||
let $iframe;
|
let $iframe;
|
||||||
const jsLibraries = {};
|
const jsLibraries = {};
|
||||||
|
|
||||||
helper.init = (cb) => {
|
helper.init = async () => {
|
||||||
$.get('/static/js/vendors/jquery.js').done((code) => {
|
[
|
||||||
// make sure we don't override existing jquery
|
jsLibraries.jquery,
|
||||||
jsLibraries.jquery = `if(typeof $ === 'undefined') {\n${code}\n}`;
|
jsLibraries.sendkeys,
|
||||||
|
] = await Promise.all([
|
||||||
$.get('/tests/frontend/lib/sendkeys.js').done((code) => {
|
$.get('../../static/js/vendors/jquery.js'),
|
||||||
jsLibraries.sendkeys = code;
|
$.get('lib/sendkeys.js'),
|
||||||
|
]);
|
||||||
cb();
|
// make sure we don't override existing jquery
|
||||||
});
|
jsLibraries.jquery = `if (typeof $ === 'undefined') {\n${jsLibraries.jquery}\n}`;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
helper.randomString = (len) => {
|
helper.randomString = (len) => {
|
||||||
|
@ -51,26 +50,21 @@ const helper = {};
|
||||||
};
|
};
|
||||||
|
|
||||||
helper.clearSessionCookies = () => {
|
helper.clearSessionCookies = () => {
|
||||||
// Expire cookies, so author and language are changed after reloading the pad. See:
|
window.Cookies.remove('token');
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_4_reset_the_previous_cookie
|
window.Cookies.remove('language');
|
||||||
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=/';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Can only happen when the iframe exists, so we're doing it separately from other cookies
|
// Can only happen when the iframe exists, so we're doing it separately from other cookies
|
||||||
helper.clearPadPrefCookie = () => {
|
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.
|
// Overwrite all prefs in pad cookie.
|
||||||
//
|
|
||||||
// `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
|
|
||||||
helper.setPadPrefCookie = (prefs) => {
|
helper.setPadPrefCookie = (prefs) => {
|
||||||
helper.padChrome$.document.cookie =
|
const {padcookie} = helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_cookie');
|
||||||
(`prefsHttp=${escape(JSON.stringify(prefs))};expires=Thu, 01 Jan 3000 00:00:00 GMT`);
|
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
|
// Functionality for knowing what key event type is required for tests
|
||||||
|
@ -89,19 +83,22 @@ const helper = {};
|
||||||
}
|
}
|
||||||
helper.evtType = evtType;
|
helper.evtType = evtType;
|
||||||
|
|
||||||
// @todo needs fixing asap
|
// Deprecated; use helper.aNewPad() instead.
|
||||||
// newPad occasionally timeouts, might be a problem with ready/onload code during page setup
|
helper.newPad = (opts, id) => {
|
||||||
// This ensures that tests run regardless of this problem
|
if (!id) id = `FRONTEND_TEST_${helper.randomString(20)}`;
|
||||||
helper.retry = 0;
|
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) => {
|
helper.aNewPad = async (opts = {}) => {
|
||||||
// build opts object
|
opts = Object.assign({
|
||||||
let opts = {clearCookies: true};
|
_retry: 0,
|
||||||
if (typeof cb === 'function') {
|
clearCookies: true,
|
||||||
opts.cb = cb;
|
id: `FRONTEND_TEST_${helper.randomString(20)}`,
|
||||||
} else {
|
}, opts);
|
||||||
opts = _.defaults(cb, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah.
|
// if opts.params is set we manipulate the URL to include URL parameters IE ?foo=Bah.
|
||||||
let encodedParams;
|
let encodedParams;
|
||||||
|
@ -118,10 +115,7 @@ const helper = {};
|
||||||
helper.clearSessionCookies();
|
helper.clearSessionCookies();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!padName) padName = `FRONTEND_TEST_${helper.randomString(20)}`;
|
$iframe = $(`<iframe src='/p/${opts.id}${hash || ''}${encodedParams || ''}'></iframe>`);
|
||||||
$iframe = $(`<iframe src='/p/${padName}${hash || ''}${encodedParams || ''}'></iframe>`);
|
|
||||||
// needed for retry
|
|
||||||
const origPadName = padName;
|
|
||||||
|
|
||||||
// clean up inner iframe references
|
// clean up inner iframe references
|
||||||
helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null;
|
helper.padChrome$ = helper.padOuter$ = helper.padInner$ = null;
|
||||||
|
@ -130,55 +124,53 @@ const helper = {};
|
||||||
$('#iframe-container iframe').remove();
|
$('#iframe-container iframe').remove();
|
||||||
// set new iframe
|
// set new iframe
|
||||||
$('#iframe-container').append($iframe);
|
$('#iframe-container').append($iframe);
|
||||||
$iframe.one('load', () => {
|
await new Promise((resolve) => $iframe.one('load', resolve));
|
||||||
helper.padChrome$ = getFrameJQuery($('#iframe-container iframe'));
|
helper.padChrome$ = getFrameJQuery($('#iframe-container iframe'));
|
||||||
if (opts.clearCookies) {
|
helper.padChrome$.padeditor =
|
||||||
helper.clearPadPrefCookie();
|
helper.padChrome$.window.require('ep_etherpad-lite/static/js/pad_editor').padeditor;
|
||||||
}
|
if (opts.clearCookies) {
|
||||||
if (opts.padPrefs) {
|
helper.clearPadPrefCookie();
|
||||||
helper.setPadPrefCookie(opts.padPrefs);
|
}
|
||||||
}
|
if (opts.padPrefs) {
|
||||||
helper.waitFor(() => !$iframe.contents().find('#editorloadingbox')
|
helper.setPadPrefCookie(opts.padPrefs);
|
||||||
.is(':visible'), 10000).done(() => {
|
}
|
||||||
helper.padOuter$ = getFrameJQuery(helper.padChrome$('iframe[name="ace_outer"]'));
|
try {
|
||||||
helper.padInner$ = getFrameJQuery(helper.padOuter$('iframe[name="ace_inner"]'));
|
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"]'));
|
||||||
|
|
||||||
// disable all animations, this makes tests faster and easier
|
// disable all animations, this makes tests faster and easier
|
||||||
helper.padChrome$.fx.off = true;
|
helper.padChrome$.fx.off = true;
|
||||||
helper.padOuter$.fx.off = true;
|
helper.padOuter$.fx.off = true;
|
||||||
helper.padInner$.fx.off = true;
|
helper.padInner$.fx.off = true;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* chat messages received
|
* chat messages received
|
||||||
* @type {Array}
|
* @type {Array}
|
||||||
*/
|
*/
|
||||||
helper.chatMessages = [];
|
helper.chatMessages = [];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* changeset commits from the server
|
* changeset commits from the server
|
||||||
* @type {Array}
|
* @type {Array}
|
||||||
*/
|
*/
|
||||||
helper.commits = [];
|
helper.commits = [];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* userInfo messages from the server
|
* userInfo messages from the server
|
||||||
* @type {Array}
|
* @type {Array}
|
||||||
*/
|
*/
|
||||||
helper.userInfos = [];
|
helper.userInfos = [];
|
||||||
|
|
||||||
// listen for server messages
|
// listen for server messages
|
||||||
helper.spyOnSocketIO();
|
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) => {
|
helper.newAdmin = async (page) => {
|
||||||
|
@ -269,6 +261,22 @@ const helper = {};
|
||||||
selection.addRange(range);
|
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 getTextNodeAndOffsetOf = ($targetLine, targetOffsetAtLine) => {
|
||||||
const $textNodes = $targetLine.find('*').contents().filter(function () {
|
const $textNodes = $targetLine.find('*').contents().filter(function () {
|
||||||
return this.nodeType === Node.TEXT_NODE;
|
return this.nodeType === Node.TEXT_NODE;
|
||||||
|
|
|
@ -6,14 +6,13 @@
|
||||||
*/
|
*/
|
||||||
helper.spyOnSocketIO = () => {
|
helper.spyOnSocketIO = () => {
|
||||||
helper.contentWindow().pad.socket.on('message', (msg) => {
|
helper.contentWindow().pad.socket.on('message', (msg) => {
|
||||||
if (msg.type === 'COLLABROOM') {
|
if (msg.type !== 'COLLABROOM') return;
|
||||||
if (msg.data.type === 'ACCEPT_COMMIT') {
|
if (msg.data.type === 'ACCEPT_COMMIT') {
|
||||||
helper.commits.push(msg);
|
helper.commits.push(msg);
|
||||||
} else if (msg.data.type === 'USER_NEWINFO') {
|
} else if (msg.data.type === 'USER_NEWINFO') {
|
||||||
helper.userInfos.push(msg);
|
helper.userInfos.push(msg);
|
||||||
} else if (msg.data.type === 'CHAT_MESSAGE') {
|
} else if (msg.data.type === 'CHAT_MESSAGE') {
|
||||||
helper.chatMessages.push(msg);
|
helper.chatMessages.push(msg);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -33,8 +32,11 @@ helper.spyOnSocketIO = () => {
|
||||||
helper.edit = async (message, line) => {
|
helper.edit = async (message, line) => {
|
||||||
const editsNum = helper.commits.length;
|
const editsNum = helper.commits.length;
|
||||||
line = line ? line - 1 : 0;
|
line = line ? line - 1 : 0;
|
||||||
helper.linesDiv()[line].sendkeys(message);
|
await helper.withFastCommit(async (incorp) => {
|
||||||
return helper.waitForPromise(() => editsNum + 1 === helper.commits.length);
|
helper.linesDiv()[line].sendkeys(message);
|
||||||
|
incorp();
|
||||||
|
await helper.waitForPromise(() => editsNum + 1 === helper.commits.length);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,11 +47,7 @@ helper.edit = async (message, line) => {
|
||||||
*
|
*
|
||||||
* @returns {Array.<HTMLElement>} array of divs
|
* @returns {Array.<HTMLElement>} array of divs
|
||||||
*/
|
*/
|
||||||
helper.linesDiv = () => {
|
helper.linesDiv = () => helper.padInner$('.ace-line').map(function () { return $(this); }).get();
|
||||||
return helper.padInner$('.ace-line').map(function () {
|
|
||||||
return $(this);
|
|
||||||
}).get();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pad text as an array of lines
|
* The pad text as an array of lines
|
||||||
|
@ -81,10 +79,10 @@ helper.defaultText =
|
||||||
* @param {string} message the chat message to be sent
|
* @param {string} message the chat message to be sent
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.sendChatMessage = (message) => {
|
helper.sendChatMessage = async (message) => {
|
||||||
const noOfChatMessages = helper.chatMessages.length;
|
const noOfChatMessages = helper.chatMessages.length;
|
||||||
helper.padChrome$('#chatinput').sendkeys(message);
|
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}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.showSettings = () => {
|
helper.showSettings = async () => {
|
||||||
if (!helper.isSettingsShown()) {
|
if (helper.isSettingsShown()) return;
|
||||||
helper.settingsButton().click();
|
helper.settingsButton().click();
|
||||||
return helper.waitForPromise(() => helper.isSettingsShown(), 2000);
|
await helper.waitForPromise(() => helper.isSettingsShown(), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -105,11 +102,10 @@ helper.showSettings = () => {
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
* @todo untested
|
* @todo untested
|
||||||
*/
|
*/
|
||||||
helper.hideSettings = () => {
|
helper.hideSettings = async () => {
|
||||||
if (helper.isSettingsShown()) {
|
if (!helper.isSettingsShown()) return;
|
||||||
helper.settingsButton().click();
|
helper.settingsButton().click();
|
||||||
return helper.waitForPromise(() => !helper.isSettingsShown(), 2000);
|
await helper.waitForPromise(() => !helper.isSettingsShown(), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -118,12 +114,11 @@ helper.hideSettings = () => {
|
||||||
*
|
*
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.enableStickyChatviaSettings = () => {
|
helper.enableStickyChatviaSettings = async () => {
|
||||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||||
if (helper.isSettingsShown() && !stickyChat.is(':checked')) {
|
if (!helper.isSettingsShown() || stickyChat.is(':checked')) return;
|
||||||
stickyChat.click();
|
stickyChat.click();
|
||||||
return helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,12 +127,11 @@ helper.enableStickyChatviaSettings = () => {
|
||||||
*
|
*
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.disableStickyChatviaSettings = () => {
|
helper.disableStickyChatviaSettings = async () => {
|
||||||
const stickyChat = helper.padChrome$('#options-stickychat');
|
const stickyChat = helper.padChrome$('#options-stickychat');
|
||||||
if (helper.isSettingsShown() && stickyChat.is(':checked')) {
|
if (!helper.isSettingsShown() || !stickyChat.is(':checked')) return;
|
||||||
stickyChat.click();
|
stickyChat.click();
|
||||||
return helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
await helper.waitForPromise(() => !helper.isChatboxSticky(), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -146,12 +140,11 @@ helper.disableStickyChatviaSettings = () => {
|
||||||
*
|
*
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.enableStickyChatviaIcon = () => {
|
helper.enableStickyChatviaIcon = async () => {
|
||||||
const stickyChat = helper.padChrome$('#titlesticky');
|
const stickyChat = helper.padChrome$('#titlesticky');
|
||||||
if (helper.isChatboxShown() && !helper.isChatboxSticky()) {
|
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||||
stickyChat.click();
|
stickyChat.click();
|
||||||
return helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
await helper.waitForPromise(() => helper.isChatboxSticky(), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -160,11 +153,10 @@ helper.enableStickyChatviaIcon = () => {
|
||||||
*
|
*
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.disableStickyChatviaIcon = () => {
|
helper.disableStickyChatviaIcon = async () => {
|
||||||
if (helper.isChatboxShown() && helper.isChatboxSticky()) {
|
if (!helper.isChatboxShown() || !helper.isChatboxSticky()) return;
|
||||||
helper.titlecross().click();
|
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
|
* @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
|
* 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}` : '';
|
revision = Number.isInteger(revision) ? `#${revision}` : '';
|
||||||
const iframe = $('#iframe-container iframe');
|
const iframe = $('#iframe-container iframe');
|
||||||
iframe.attr('src', `${iframe.attr('src')}/timeslider${revision}`);
|
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);
|
!Number.isNaN(new Date(helper.timesliderTimerTime()).getTime()), 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -227,7 +219,10 @@ helper.clearPad = async () => {
|
||||||
await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed);
|
await helper.waitForPromise(() => !helper.padInner$.document.getSelection().isCollapsed);
|
||||||
const e = new helper.padInner$.Event(helper.evtType);
|
const e = new helper.padInner$.Event(helper.evtType);
|
||||||
e.keyCode = 8; // delete key
|
e.keyCode = 8; // delete key
|
||||||
helper.padInner$('#innerdocbody').trigger(e);
|
await helper.withFastCommit(async (incorp) => {
|
||||||
await helper.waitForPromise(helper.padIsEmpty);
|
helper.padInner$('#innerdocbody').trigger(e);
|
||||||
await helper.waitForPromise(() => helper.commits.length > commitsBefore);
|
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}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.showChat = () => {
|
helper.showChat = async () => {
|
||||||
const chaticon = helper.chatIcon();
|
const chaticon = helper.chatIcon();
|
||||||
if (chaticon.hasClass('visible')) {
|
if (!chaticon.hasClass('visible')) return;
|
||||||
chaticon.click();
|
chaticon.click();
|
||||||
return helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);
|
await helper.waitForPromise(() => !chaticon.hasClass('visible'), 2000);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -26,11 +25,10 @@ helper.showChat = () => {
|
||||||
*
|
*
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
helper.hideChat = () => {
|
helper.hideChat = async () => {
|
||||||
if (helper.isChatboxShown() && !helper.isChatboxSticky()) {
|
if (!helper.isChatboxShown() || helper.isChatboxSticky()) return;
|
||||||
helper.titlecross().click();
|
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
|
* @returns {HTMLElement} timer
|
||||||
*/
|
*/
|
||||||
helper.timesliderTimer = () => {
|
helper.timesliderTimer = () => {
|
||||||
if (typeof helper.contentWindow().$ === 'function') {
|
if (typeof helper.contentWindow().$ !== 'function') return;
|
||||||
return helper.contentWindow().$('#timer');
|
return helper.contentWindow().$('#timer');
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -143,9 +140,8 @@ helper.timesliderTimer = () => {
|
||||||
* @returns {HTMLElement} timer
|
* @returns {HTMLElement} timer
|
||||||
*/
|
*/
|
||||||
helper.timesliderTimerTime = () => {
|
helper.timesliderTimerTime = () => {
|
||||||
if (helper.timesliderTimer()) {
|
if (!helper.timesliderTimer()) return;
|
||||||
return helper.timesliderTimer().text();
|
return helper.timesliderTimer().text();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,16 +1,23 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
<head>
|
||||||
<title>Frontend tests</title>
|
<title>Frontend tests</title>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
||||||
<link rel="stylesheet" href="runner.css" />
|
<link rel="stylesheet" href="runner.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
<div id="console"></div>
|
<div id="console"></div>
|
||||||
<div id="mocha"></div>
|
<div id="split-view">
|
||||||
<div id="iframe-container"></div>
|
<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/require-kernel.js"></script>
|
||||||
<script src="/static/js/vendors/browser.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/underscore.js"></script>
|
||||||
|
|
||||||
<script src="lib/mocha.js"></script>
|
<script src="lib/mocha.js"></script>
|
||||||
|
@ -20,7 +27,7 @@
|
||||||
<script src="helper.js"></script>
|
<script src="helper.js"></script>
|
||||||
<script src="helper/methods.js"></script>
|
<script src="helper/methods.js"></script>
|
||||||
<script src="helper/ui.js"></script>
|
<script src="helper/ui.js"></script>
|
||||||
|
<script src="helper/multipleUsers.js"></script>
|
||||||
<script src="specs_list.js"></script>
|
|
||||||
<script src="runner.js"></script>
|
<script src="runner.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -6,8 +6,6 @@ body {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,37 +13,39 @@ body {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#iframe-container {
|
#split-view {
|
||||||
width: 80%;
|
width: 100%;
|
||||||
min-width: 820px;
|
|
||||||
height: 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 {
|
#iframe-container iframe {
|
||||||
|
border: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width:100%;
|
width: 100%;
|
||||||
|
min-width: 820px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mocha {
|
#mocha {
|
||||||
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
border-right: 2px solid #999;
|
|
||||||
flex: 1 auto;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
min-height: 100%; /* https://css-tricks.com/preventing-a-grid-blowout/ */
|
||||||
width:20%;
|
|
||||||
font-size:80%;
|
font-size:80%;
|
||||||
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
|
||||||
#mocha #report {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mocha li {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#mocha ul {
|
#mocha ul {
|
||||||
|
@ -57,7 +57,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#mocha h1 {
|
#mocha h1 {
|
||||||
margin-top: 15px;
|
padding-top: 15px; /* margin-top breaks autoscrolling */
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
}
|
}
|
||||||
|
@ -68,7 +68,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
#mocha .suite .suite h1 {
|
#mocha .suite .suite h1 {
|
||||||
margin-top: 0;
|
padding-top: 0;
|
||||||
font-size: .8em;
|
font-size: .8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,26 +170,33 @@ body {
|
||||||
-webkit-box-shadow: 0 1px 3px #eee;
|
-webkit-box-shadow: 0 1px 3px #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
#report ul {
|
#mocha-report {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mocha-report ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#report.pass .test.fail {
|
#mocha-report.pass .test.fail {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#report.fail .test.pass {
|
#mocha-report.fail .test.pass {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#error {
|
#mocha-error {
|
||||||
color: #c00;
|
color: #c00;
|
||||||
font-size: 1.5 em;
|
font-size: 1.5 em;
|
||||||
font-weight: 100;
|
font-weight: 100;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stats {
|
#mocha-stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -197,30 +204,26 @@ body {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mocha-stats {
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mocha-stats .progress {
|
#mocha-stats .progress {
|
||||||
float: right;
|
float: right;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
margin-right:5px;
|
margin-right:5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stats em {
|
#mocha-stats em {
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stats a {
|
#mocha-stats a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stats a:hover {
|
#mocha-stats a:hover {
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stats li {
|
#mocha-stats li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
'use strict';
|
'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) => {
|
const stringifyException = (exception) => {
|
||||||
let err = exception.stack || exception.toString();
|
let err = exception.stack || exception.toString();
|
||||||
|
|
||||||
|
@ -29,13 +29,49 @@ $(() => {
|
||||||
|
|
||||||
if (!runner) return;
|
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', () => {
|
runner.on('start', () => {
|
||||||
stats.start = new Date();
|
stats.start = new Date();
|
||||||
});
|
});
|
||||||
|
|
||||||
runner.on('suite', (suite) => {
|
runner.on('suite', (suite) => {
|
||||||
suite.root || stats.suites++;
|
|
||||||
if (suite.root) return;
|
if (suite.root) return;
|
||||||
|
autoscroll($('#mocha-report .suite').last()[0]);
|
||||||
|
stats.suites++;
|
||||||
append(suite.title);
|
append(suite.title);
|
||||||
level++;
|
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
|
// max time a test is allowed to run
|
||||||
// TODO this should be lowered once timeslider_revision.js is faster
|
// TODO this should be lowered once timeslider_revision.js is faster
|
||||||
let killTimeout;
|
let killTimeout;
|
||||||
runner.on('test end', () => {
|
runner.on('test end', () => {
|
||||||
|
autoscroll($('#mocha-report .test').last()[0]);
|
||||||
stats.tests++;
|
stats.tests++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -102,27 +133,9 @@ $(() => {
|
||||||
|
|
||||||
const $console = $('#console');
|
const $console = $('#console');
|
||||||
const append = (text) => {
|
const append = (text) => {
|
||||||
const oldText = $console.text();
|
// Indent each line.
|
||||||
|
const lines = text.split('\n').map((line) => ' '.repeat(level * 2) + line);
|
||||||
let space = '';
|
$console.append(document.createTextNode(`${lines.join('\n')}\n`));
|
||||||
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`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const total = runner.total;
|
const total = runner.total;
|
||||||
|
@ -156,29 +169,156 @@ $(() => {
|
||||||
|
|
||||||
const getURLParameter = (name) => (new URLSearchParams(location.search)).get(name);
|
const getURLParameter = (name) => (new URLSearchParams(location.search)).get(name);
|
||||||
|
|
||||||
// get the list of specs and filter it if requested
|
const absUrl = (url) => new URL(url, window.location.href).href;
|
||||||
const specs = specs_list.slice();
|
require.setRootURI(absUrl('../../javascripts/src'));
|
||||||
|
require.setLibraryURI(absUrl('../../javascripts/lib'));
|
||||||
|
require.setGlobalKeyPath('require');
|
||||||
|
|
||||||
// inject spec scripts into the dom
|
const Split = require('split-grid/dist/split-grid.min');
|
||||||
const $body = $('body');
|
new Split({
|
||||||
$.each(specs, (i, spec) => {
|
columnGutters: [{
|
||||||
// if the spec isn't a plugin spec which means the spec file might be in a different subfolder
|
track: 1,
|
||||||
if (!spec.startsWith('/')) {
|
element: document.getElementById('separator'),
|
||||||
$body.append(`<script src="specs/${spec}"></script>`);
|
}],
|
||||||
} else {
|
|
||||||
$body.append(`<script src="${spec}"></script>`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// initialize the test helper
|
// Speed up tests by loading test definitions in parallel. Approach: Define a new global object
|
||||||
helper.init(() => {
|
// that has a define() method, which is a wrapper around window.require.define(). The wrapper
|
||||||
// configure and start the test framework
|
// mutates the module definition function to temporarily replace Mocha's functions with
|
||||||
const grep = getURLParameter('grep');
|
// placeholders. The placeholders make it possible to defer the actual Mocha function calls until
|
||||||
if (grep != null) {
|
// after the modules are all loaded in parallel. require.setGlobalKeyPath() is used to coax
|
||||||
mocha.grep(grep);
|
// require-kernel into using the wrapper define() method instead of require.define().
|
||||||
}
|
|
||||||
|
|
||||||
const runner = mocha.run();
|
// Per-module log of attempted Mocha function calls. Key is module path, value is an array of
|
||||||
customRunner(runner);
|
// [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';
|
const expectedString = 'abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
// create a new pad before each test run
|
// create a new pad before each test run
|
||||||
beforeEach(function (cb) {
|
beforeEach(async function () {
|
||||||
helper.newPad(cb);
|
await helper.aNewPad();
|
||||||
this.timeout(60000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when you enter any char it appears right', function (done) {
|
it('when you enter any char it appears right', function (done) {
|
||||||
this.timeout(250);
|
|
||||||
const inner$ = helper.padInner$;
|
const inner$ = helper.padInner$;
|
||||||
|
|
||||||
// get the first text element out of the inner iframe
|
// get the first text element out of the inner iframe
|
||||||
|
|
|
@ -6,71 +6,61 @@ describe('author of pad edition', function () {
|
||||||
const LINE_WITH_UNORDERED_LIST = 2;
|
const LINE_WITH_UNORDERED_LIST = 2;
|
||||||
|
|
||||||
// author 1 creates a new pad with some content (regular lines and lists)
|
// author 1 creates a new pad with some content (regular lines and lists)
|
||||||
before(function (done) {
|
before(async function () {
|
||||||
const padId = helper.newPad(() => {
|
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']
|
|
||||||
.join('<br>');
|
|
||||||
$firstLine.html(threeLines);
|
|
||||||
|
|
||||||
// wait for lines to be processed by Etherpad
|
// make sure pad has at least 3 lines
|
||||||
helper.waitFor(() => {
|
const $firstLine = helper.padInner$('div').first();
|
||||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
const threeLines = ['regular line', 'line with ordered list', 'line with unordered list']
|
||||||
return $lineWithUnorderedList.text() === 'line with unordered list';
|
.join('<br>');
|
||||||
}).done(() => {
|
$firstLine.html(threeLines);
|
||||||
// create the unordered list
|
|
||||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
|
||||||
$lineWithUnorderedList.sendkeys('{selectall}');
|
|
||||||
|
|
||||||
const $insertUnorderedListButton = helper.padChrome$('.buttonicon-insertunorderedlist');
|
// wait for lines to be processed by Etherpad
|
||||||
$insertUnorderedListButton.click();
|
await helper.waitForPromise(() => (
|
||||||
|
getLine(LINE_WITH_UNORDERED_LIST).text() === 'line with unordered list' &&
|
||||||
|
helper.commits.length === 1));
|
||||||
|
|
||||||
helper.waitFor(() => {
|
// create the unordered list
|
||||||
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
const $lineWithUnorderedList = getLine(LINE_WITH_UNORDERED_LIST);
|
||||||
return $lineWithUnorderedList.find('ul li').length === 1;
|
$lineWithUnorderedList.sendkeys('{selectall}');
|
||||||
}).done(() => {
|
|
||||||
// create the ordered list
|
|
||||||
const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);
|
|
||||||
$lineWithOrderedList.sendkeys('{selectall}');
|
|
||||||
|
|
||||||
const $insertOrderedListButton = helper.padChrome$('.buttonicon-insertorderedlist');
|
const $insertUnorderedListButton = helper.padChrome$('.buttonicon-insertunorderedlist');
|
||||||
$insertOrderedListButton.click();
|
$insertUnorderedListButton.click();
|
||||||
|
|
||||||
helper.waitFor(() => {
|
await helper.waitForPromise(() => (
|
||||||
const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);
|
getLine(LINE_WITH_UNORDERED_LIST).find('ul li').length === 1 &&
|
||||||
return $lineWithOrderedList.find('ol li').length === 1;
|
helper.commits.length === 2));
|
||||||
}).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=/';
|
|
||||||
|
|
||||||
helper.newPad(done, padId);
|
// create the ordered list
|
||||||
}, 1000);
|
const $lineWithOrderedList = getLine(LINE_WITH_ORDERED_LIST);
|
||||||
});
|
$lineWithOrderedList.sendkeys('{selectall}');
|
||||||
});
|
|
||||||
});
|
const $insertOrderedListButton = helper.padChrome$('.buttonicon-insertorderedlist');
|
||||||
});
|
$insertOrderedListButton.click();
|
||||||
this.timeout(60000);
|
|
||||||
|
await helper.waitForPromise(() => (
|
||||||
|
getLine(LINE_WITH_ORDERED_LIST).find('ol li').length === 1 &&
|
||||||
|
helper.commits.length === 3));
|
||||||
|
|
||||||
|
// 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
|
// 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) {
|
it('regular line', async function () {
|
||||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(REGULAR_LINE, 'x', done);
|
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(REGULAR_LINE, 'x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks only the new content as changes of the second user on a ' +
|
it('line with ordered list', async function () {
|
||||||
'line with ordered list', function (done) {
|
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_ORDERED_LIST, 'y');
|
||||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_ORDERED_LIST, 'y', done);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks only the new content as changes of the second user on ' +
|
it('line with unordered list', async function () {
|
||||||
'a line with unordered list', function (done) {
|
await changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_UNORDERED_LIST, 'z');
|
||||||
changeLineAndCheckOnlyThatChangeIsFromThisAuthor(LINE_WITH_UNORDERED_LIST, 'z', done);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ********************** Helper functions ************************ */
|
/* ********************** Helper functions ************************ */
|
||||||
|
@ -78,7 +68,7 @@ describe('author of pad edition', function () {
|
||||||
|
|
||||||
const getAuthorFromClassList = (classes) => classes.find((cls) => cls.startsWith('author'));
|
const getAuthorFromClassList = (classes) => classes.find((cls) => cls.startsWith('author'));
|
||||||
|
|
||||||
const changeLineAndCheckOnlyThatChangeIsFromThisAuthor = (lineNumber, textChange, done) => {
|
const changeLineAndCheckOnlyThatChangeIsFromThisAuthor = async (lineNumber, textChange) => {
|
||||||
// get original author class
|
// get original author class
|
||||||
const classes = getLine(lineNumber).find('span').first().attr('class').split(' ');
|
const classes = getLine(lineNumber).find('span').first().attr('class').split(' ');
|
||||||
const originalAuthor = getAuthorFromClassList(classes);
|
const originalAuthor = getAuthorFromClassList(classes);
|
||||||
|
@ -90,18 +80,16 @@ describe('author of pad edition', function () {
|
||||||
|
|
||||||
// wait for change to be processed by Etherpad
|
// wait for change to be processed by Etherpad
|
||||||
let otherAuthorsOfLine;
|
let otherAuthorsOfLine;
|
||||||
helper.waitFor(() => {
|
await helper.waitForPromise(() => {
|
||||||
const authorsOfLine = getLine(lineNumber).find('span').map(function () {
|
const authorsOfLine = getLine(lineNumber).find('span').map(function () {
|
||||||
return getAuthorFromClassList($(this).attr('class').split(' '));
|
return getAuthorFromClassList($(this).attr('class').split(' '));
|
||||||
}).get();
|
}).get();
|
||||||
otherAuthorsOfLine = authorsOfLine.filter((author) => author !== originalAuthor);
|
otherAuthorsOfLine = authorsOfLine.filter((author) => author !== originalAuthor);
|
||||||
const lineHasChangeOfThisAuthor = otherAuthorsOfLine.length > 0;
|
const lineHasChangeOfThisAuthor = otherAuthorsOfLine.length > 0;
|
||||||
return lineHasChangeOfThisAuthor;
|
return lineHasChangeOfThisAuthor;
|
||||||
}).done(() => {
|
|
||||||
const thisAuthor = otherAuthorsOfLine[0];
|
|
||||||
const $changeOfThisAuthor = getLine(lineNumber).find(`span.${thisAuthor}`);
|
|
||||||
expect($changeOfThisAuthor.text()).to.be(textChange);
|
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
const thisAuthor = otherAuthorsOfLine[0];
|
||||||
|
const $changeOfThisAuthor = getLine(lineNumber).find(`span.${thisAuthor}`);
|
||||||
|
expect($changeOfThisAuthor.text()).to.be(textChange);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
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