diff --git a/node_modules/ep_etherpad-lite b/node_modules/ep_etherpad-lite deleted file mode 120000 index 5cd551cf2..000000000 --- a/node_modules/ep_etherpad-lite +++ /dev/null @@ -1 +0,0 @@ -../src \ No newline at end of file diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index b2bfc877b..db5c5a4ca 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -4,7 +4,15 @@ */ import AttributeMap from '../../static/js/AttributeMap'; -import {applyToAText, copyAText, makeAText} from '../../static/js/Changeset'; +import { + applyToAText, checkRep, + copyAText, deserializeOps, + makeAText, + makeSplice, + opsFromAText, + pack, + smartOpAssembler, unpack +} from '../../static/js/Changeset'; import ChatMessage from '../../static/js/ChatMessage'; import {AttributePool} from '../../static/js/AttributePool'; import {Stream} from '../utils/Stream'; @@ -266,7 +274,7 @@ export class Pad { (!ins && start > 0 && orig[start - 1] === '\n'); if (!willEndWithNewline) ins += '\n'; if (ndel === 0 && ins.length === 0) return; - const changeset = Changeset.makeSplice(orig, start, ndel, ins); + const changeset = makeSplice(orig, start, ndel, ins); await this.appendRevision(changeset, authorId); } @@ -366,7 +374,7 @@ export class Pad { if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); text = cleanText(context.content); } - const firstChangeset = Changeset.makeSplice('\n', 0, 0, text); + const firstChangeset = makeSplice('\n', 0, 0, text); await this.appendRevision(firstChangeset, authorId); } await aCallAll('padLoad', {pad: this}); @@ -490,8 +498,8 @@ export class Pad { const oldAText = this.atext; // based on Changeset.makeSplice - const assem = Changeset.smartOpAssembler(); - for (const op of Changeset.opsFromAText(oldAText)) assem.append(op); + const assem = smartOpAssembler(); + for (const op of opsFromAText(oldAText)) assem.append(op); assem.endDocument(); // although we have instantiated the dstPad with '\n', an additional '\n' is @@ -503,7 +511,7 @@ export class Pad { // create a changeset that removes the previous text and add the newText with // all atributes present on the source pad - const changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); + const changeset = pack(oldLength, newLength, assem.toString(), newText); dstPad.appendRevision(changeset, authorId); await aCallAll('padCopy', { @@ -677,7 +685,7 @@ export class Pad { } }) .batch(100).buffer(99); - let atext = Changeset.makeAText('\n'); + let atext = makeAText('\n'); for await (const [r, changeset, authorId, timestamp, isKeyRev, keyAText] of revs) { try { assert(authorId != null); @@ -688,10 +696,10 @@ export class Pad { assert(timestamp > 0); assert(changeset != null); assert.equal(typeof changeset, 'string'); - Changeset.checkRep(changeset); - const unpacked = Changeset.unpack(changeset); + checkRep(changeset); + const unpacked = unpack(changeset); let text = atext.text; - for (const op of Changeset.deserializeOps(unpacked.ops)) { + for (const op of deserializeOps(unpacked.ops)) { if (['=', '-'].includes(op.opcode)) { assert(text.length >= op.chars); const consumed = text.slice(0, op.chars); @@ -702,7 +710,7 @@ export class Pad { } assert.equal(op.attribs, AttributeMap.fromString(op.attribs, pool).toString()); } - atext = Changeset.applyToAText(changeset, atext, pool); + atext = applyToAText(changeset, atext, pool); if (isKeyRev) assert.deepEqual(keyAText, atext); } catch (err) { err.message = `(pad ${this.id} revision ${r}) ${err.message}`; diff --git a/src/package-lock.json b/src/package-lock.json index 618ce284a..47345ad47 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -4626,15 +4626,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/abbrev": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/agent-base": { "version": "4.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "es6-promisify": "^5.0.0" }, @@ -4644,9 +4646,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/agentkeepalive": { "version": "3.5.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "humanize-ms": "^1.2.1" }, @@ -4656,27 +4659,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ansi-align": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^2.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ansi-regex": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ansi-styles": { "version": "3.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4686,33 +4692,38 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ansicolors": { "version": "0.3.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ansistyles": { "version": "0.1.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/aproba": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/archy": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/are-we-there-yet": { "version": "1.1.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" @@ -4720,9 +4731,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/are-we-there-yet/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -4735,78 +4747,89 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/are-we-there-yet/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/asap": { "version": "2.0.6", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/asn1": { "version": "0.2.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": "~2.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/assert-plus": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/asynckit": { "version": "0.4.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/aws-sign2": { "version": "0.7.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/aws4": { "version": "1.8.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/balanced-match": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/bcrypt-pbkdf": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "dependencies": { "tweetnacl": "^0.14.3" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/bin-links": { "version": "1.1.8", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Artistic-2.0", + "peer": true, "dependencies": { "bluebird": "^3.5.3", "cmd-shim": "^3.0.0", @@ -4818,15 +4841,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/bluebird": { "version": "3.5.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/boxen": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-align": "^2.0.0", "camelcase": "^4.0.0", @@ -4842,9 +4867,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/brace-expansion": { "version": "1.1.11", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4852,39 +4878,44 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/buffer-from": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/builtins": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/byline": { "version": "5.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/byte-size": { "version": "5.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cacache": { "version": "12.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "bluebird": "^3.5.5", "chownr": "^1.1.1", @@ -4905,39 +4936,44 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/call-limit": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/camelcase": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/capture-stack-trace": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/caseless": { "version": "0.12.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/chalk": { "version": "2.4.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -4949,21 +4985,24 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/chownr": { "version": "1.1.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ci-info": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cidr-regex": { "version": "2.0.10", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "ip-regex": "^2.1.0" }, @@ -4973,18 +5012,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cli-boxes": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cli-columns": { "version": "3.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "string-width": "^2.0.0", "strip-ansi": "^3.0.1" @@ -4995,9 +5036,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cli-table3": { "version": "0.5.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "object-assign": "^4.1.0", "string-width": "^2.1.1" @@ -5011,9 +5053,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cliui": { "version": "5.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^3.1.0", "strip-ansi": "^5.2.0", @@ -5022,27 +5065,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cliui/node_modules/ansi-regex": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cliui/node_modules/is-fullwidth-code-point": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cliui/node_modules/string-width": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -5054,9 +5100,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cliui/node_modules/strip-ansi": { "version": "5.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -5066,18 +5113,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/clone": { "version": "1.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cmd-shim": { "version": "3.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "mkdirp": "~0.5.0" @@ -5085,42 +5134,48 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/code-point-at": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/color-convert": { "version": "1.9.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "color-name": "^1.1.1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/color-name": { "version": "1.1.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/colors": { "version": "1.3.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.1.90" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/columnify": { "version": "1.5.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "strip-ansi": "^3.0.0", "wcwidth": "^1.0.0" @@ -5128,9 +5183,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/combined-stream": { "version": "1.0.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5140,18 +5196,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/concat-map": { "version": "0.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/concat-stream": { "version": "1.6.2", + "dev": true, "engines": [ "node >= 0.8" ], - "extraneous": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", @@ -5161,9 +5219,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/concat-stream/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5176,17 +5235,19 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/concat-stream/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/config-chain": { "version": "1.1.12", - "extraneous": true, + "dev": true, "inBundle": true, + "peer": true, "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" @@ -5194,9 +5255,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/configstore": { "version": "3.1.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "dot-prop": "^4.2.1", "graceful-fs": "^4.1.2", @@ -5211,15 +5273,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/console-control-strings": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/copy-concurrently": { "version": "1.0.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^1.1.1", "fs-write-stream-atomic": "^1.0.8", @@ -5231,27 +5295,31 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/copy-concurrently/node_modules/aproba": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/copy-concurrently/node_modules/iferr": { "version": "0.1.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/core-util-is": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/create-error-class": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "capture-stack-trace": "^1.0.0" }, @@ -5261,9 +5329,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cross-spawn": { "version": "5.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "lru-cache": "^4.0.1", "shebang-command": "^1.2.0", @@ -5272,9 +5341,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cross-spawn/node_modules/lru-cache": { "version": "4.1.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "pseudomap": "^1.0.2", "yallist": "^2.1.2" @@ -5282,29 +5352,33 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cross-spawn/node_modules/yallist": { "version": "2.1.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/crypto-random-string": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/cyclist": { "version": "0.2.2", - "extraneous": true, - "inBundle": true + "dev": true, + "inBundle": true, + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/dashdash": { "version": "1.14.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "assert-plus": "^1.0.0" }, @@ -5314,69 +5388,77 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/debug": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/debug/node_modules/ms": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/debuglog": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/decamelize": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/decode-uri-component": { "version": "0.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/deep-extend": { "version": "0.6.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/defaults": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "clone": "^1.0.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/define-properties": { "version": "1.1.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "object-keys": "^1.0.12" }, @@ -5386,42 +5468,47 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/delayed-stream": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/delegates": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/detect-indent": { "version": "5.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/detect-newline": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/dezalgo": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "asap": "^2.0.0", "wrappy": "1" @@ -5429,9 +5516,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/dot-prop": { "version": "4.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "is-obj": "^1.0.0" }, @@ -5441,24 +5529,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/dotenv": { "version": "5.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.6.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/duplexer3": { "version": "0.1.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/duplexify": { "version": "3.6.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.0.0", "inherits": "^2.0.1", @@ -5468,9 +5559,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/duplexify/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5483,18 +5575,21 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/duplexify/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ecc-jsbn": { "version": "0.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -5502,54 +5597,61 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/editor": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/emoji-regex": { "version": "7.0.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/encoding": { "version": "0.1.12", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "~0.4.13" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/end-of-stream": { "version": "1.4.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "once": "^1.4.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/env-paths": { "version": "2.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/err-code": { "version": "1.1.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/errno": { "version": "0.1.7", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "prr": "~1.0.1" }, @@ -5559,9 +5661,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/es-abstract": { "version": "1.12.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "es-to-primitive": "^1.1.1", "function-bind": "^1.1.1", @@ -5575,9 +5678,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/es-to-primitive": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -5589,33 +5693,37 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/es6-promise": { "version": "4.2.8", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/es6-promisify": { "version": "5.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "es6-promise": "^4.0.3" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/escape-string-regexp": { "version": "1.0.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/execa": { "version": "0.7.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "cross-spawn": "^5.0.1", "get-stream": "^3.0.0", @@ -5631,51 +5739,58 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/execa/node_modules/get-stream": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/extend": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/extsprintf": { "version": "1.3.0", + "dev": true, "engines": [ "node >=0.6.0" ], - "extraneous": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fast-json-stable-stringify": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/figgy-pudding": { "version": "3.5.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/find-npm-prefix": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/flush-write-stream": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.4" @@ -5683,9 +5798,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/flush-write-stream/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5698,27 +5814,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/flush-write-stream/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/forever-agent": { "version": "0.6.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/form-data": { "version": "2.3.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "1.0.6", @@ -5730,9 +5849,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/from2": { "version": "2.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" @@ -5740,9 +5860,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/from2/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5755,27 +5876,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/from2/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-minipass": { "version": "1.2.7", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minipass": "^2.6.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-minipass/node_modules/minipass": { "version": "2.9.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5783,9 +5907,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-vacuum": { "version": "1.2.10", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "path-is-inside": "^1.0.1", @@ -5794,9 +5919,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-write-stream-atomic": { "version": "1.0.10", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "iferr": "^0.1.5", @@ -5806,15 +5932,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-write-stream-atomic/node_modules/iferr": { "version": "0.1.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-write-stream-atomic/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -5827,30 +5955,34 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs-write-stream-atomic/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/fs.realpath": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/function-bind": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gauge": { "version": "2.7.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^1.0.3", "console-control-strings": "^1.0.0", @@ -5864,15 +5996,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gauge/node_modules/aproba": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gauge/node_modules/string-width": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5884,15 +6018,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/genfun": { "version": "5.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gentle-fs": { "version": "2.3.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Artistic-2.0", + "peer": true, "dependencies": { "aproba": "^1.1.2", "chownr": "^1.1.2", @@ -5909,30 +6045,34 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gentle-fs/node_modules/aproba": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/gentle-fs/node_modules/iferr": { "version": "0.1.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/get-caller-file": { "version": "2.0.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/get-stream": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "pump": "^3.0.0" }, @@ -5942,18 +6082,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/getpass": { "version": "0.1.7", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "assert-plus": "^1.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/glob": { "version": "7.1.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5971,9 +6113,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/global-dirs": { "version": "0.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ini": "^1.3.4" }, @@ -5983,9 +6126,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/got": { "version": "6.7.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "create-error-class": "^3.0.0", "duplexer3": "^0.1.4", @@ -6005,24 +6149,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/got/node_modules/get-stream": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/graceful-fs": { "version": "4.2.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/har-schema": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": ">=4" } @@ -6030,9 +6177,10 @@ "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/har-validator": { "version": "5.1.5", "deprecated": "this library is no longer supported", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -6043,9 +6191,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/har-validator/node_modules/ajv": { "version": "6.12.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6059,21 +6208,24 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/har-validator/node_modules/fast-deep-equal": { "version": "3.1.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/har-validator/node_modules/json-schema-traverse": { "version": "0.4.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/has": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -6083,45 +6235,51 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/has-flag": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/has-symbols": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/has-unicode": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/hosted-git-info": { "version": "2.8.9", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/http-cache-semantics": { "version": "3.8.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/http-proxy-agent": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "4", "debug": "3.1.0" @@ -6132,9 +6290,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/http-signature": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -6147,9 +6306,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/https-proxy-agent": { "version": "2.2.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^4.3.0", "debug": "^3.1.0" @@ -6160,18 +6320,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/humanize-ms": { "version": "1.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/iconv-lite": { "version": "0.4.23", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -6181,51 +6343,57 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/iferr": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ignore-walk": { "version": "3.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "minimatch": "^3.0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/import-lazy": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/imurmurhash": { "version": "0.1.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.8.19" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/infer-owner": { "version": "1.0.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/inflight": { "version": "1.0.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -6233,21 +6401,24 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/inherits": { "version": "2.0.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ini": { "version": "1.3.8", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/init-package-json": { "version": "1.10.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.1", "npm-package-arg": "^4.0.0 || ^5.0.0 || ^6.0.0", @@ -6261,33 +6432,37 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ip": { "version": "1.1.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ip-regex": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-callable": { "version": "1.1.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-ci": { "version": "1.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ci-info": "^1.5.0" }, @@ -6297,15 +6472,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-ci/node_modules/ci-info": { "version": "1.6.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-cidr": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "cidr-regex": "^2.0.10" }, @@ -6315,18 +6492,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-date-object": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-fullwidth-code-point": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "number-is-nan": "^1.0.0" }, @@ -6336,9 +6515,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-installed-globally": { "version": "0.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "global-dirs": "^0.1.0", "is-path-inside": "^1.0.0" @@ -6349,27 +6529,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-npm": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-obj": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-path-inside": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "path-is-inside": "^1.0.1" }, @@ -6379,18 +6562,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-redirect": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-regex": { "version": "1.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "has": "^1.0.1" }, @@ -6400,27 +6585,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-retry-allowed": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-stream": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-symbol": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "has-symbols": "^1.0.0" }, @@ -6430,65 +6618,76 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/is-typedarray": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/isarray": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/isexe": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/isstream": { "version": "0.1.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/jsbn": { "version": "0.1.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/json-parse-better-errors": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/json-schema": { "version": "0.2.3", - "extraneous": true, - "inBundle": true + "dev": true, + "inBundle": true, + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/json-stringify-safe": { "version": "5.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/jsonparse": { "version": "1.3.1", + "dev": true, "engines": [ "node >= 0.2.0" ], - "extraneous": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/JSONStream": { "version": "1.3.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "(MIT OR Apache-2.0)", + "peer": true, "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" @@ -6502,12 +6701,13 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/jsprim": { "version": "1.4.1", + "dev": true, "engines": [ "node >=0.6.0" ], - "extraneous": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -6517,9 +6717,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/latest-version": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "package-json": "^4.0.0" }, @@ -6529,15 +6730,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lazy-property": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libcipm": { "version": "4.0.8", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "bin-links": "^1.1.2", "bluebird": "^3.5.1", @@ -6558,9 +6761,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpm": { "version": "3.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "bin-links": "^1.1.2", "bluebird": "^3.5.3", @@ -6586,9 +6790,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmaccess": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "get-stream": "^4.0.0", @@ -6598,9 +6803,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig": { "version": "1.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "figgy-pudding": "^3.5.1", "find-up": "^3.0.0", @@ -6609,9 +6815,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig/node_modules/find-up": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -6621,9 +6828,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig/node_modules/locate-path": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -6634,9 +6842,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig/node_modules/p-limit": { "version": "2.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -6646,9 +6855,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig/node_modules/p-locate": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -6658,18 +6868,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmconfig/node_modules/p-try": { "version": "2.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmhook": { "version": "5.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "figgy-pudding": "^3.4.1", @@ -6679,9 +6891,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmorg": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "figgy-pudding": "^3.4.1", @@ -6691,9 +6904,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmpublish": { "version": "1.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "figgy-pudding": "^3.5.1", @@ -6708,9 +6922,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmsearch": { "version": "2.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "figgy-pudding": "^3.5.1", "get-stream": "^4.0.0", @@ -6719,9 +6934,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpmteam": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^2.0.0", "figgy-pudding": "^3.4.1", @@ -6731,9 +6947,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/libnpx": { "version": "10.2.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "dotenv": "^5.0.1", "npm-package-arg": "^6.0.0", @@ -6750,9 +6967,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lock-verify": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-package-arg": "^6.1.0", "semver": "^5.4.1" @@ -6760,24 +6978,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lockfile": { "version": "1.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "signal-exit": "^3.0.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._baseindexof": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._baseuniq": { "version": "4.6.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "lodash._createset": "~4.0.0", "lodash._root": "~3.0.0" @@ -6785,96 +7006,110 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._bindcallback": { "version": "3.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._cacheindexof": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._createcache": { "version": "3.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "lodash._getnative": "^3.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._createset": { "version": "4.0.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._getnative": { "version": "3.9.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash._root": { "version": "3.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash.clonedeep": { "version": "4.5.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash.restparam": { "version": "3.6.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash.union": { "version": "4.6.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash.uniq": { "version": "4.5.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lodash.without": { "version": "4.4.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lowercase-keys": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/lru-cache": { "version": "5.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/make-dir": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "pify": "^3.0.0" }, @@ -6884,9 +7119,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/make-fetch-happen": { "version": "5.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "agentkeepalive": "^3.4.1", "cacache": "^12.0.0", @@ -6903,24 +7139,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/meant": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mime-db": { "version": "1.35.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mime-types": { "version": "2.1.19", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "mime-db": "~1.35.0" }, @@ -6930,9 +7169,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/minimatch": { "version": "3.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6942,24 +7182,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/minimist": { "version": "1.2.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/minizlib": { "version": "1.3.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "minipass": "^2.9.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/minizlib/node_modules/minipass": { "version": "2.9.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6967,9 +7210,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mississippi": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "concat-stream": "^1.5.0", "duplexify": "^3.4.2", @@ -6988,9 +7232,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mkdirp": { "version": "0.5.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.5" }, @@ -7000,15 +7245,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mkdirp/node_modules/minimist": { "version": "1.2.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/move-concurrently": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^1.1.1", "copy-concurrently": "^1.0.0", @@ -7020,27 +7267,31 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/move-concurrently/node_modules/aproba": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ms": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/mute-stream": { "version": "0.0.7", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/node-fetch-npm": { "version": "2.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "encoding": "^0.1.11", "json-parse-better-errors": "^1.0.0", @@ -7052,9 +7303,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/node-gyp": { "version": "5.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", @@ -7077,9 +7329,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/nopt": { "version": "4.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "abbrev": "1", "osenv": "^0.1.4" @@ -7090,9 +7343,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/normalize-package-data": { "version": "2.5.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", @@ -7102,18 +7356,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/normalize-package-data/node_modules/resolve": { "version": "1.10.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "path-parse": "^1.0.6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-audit-report": { "version": "1.3.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "cli-table3": "^0.5.0", "console-control-strings": "^1.1.0" @@ -7121,33 +7377,37 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-bundled": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "npm-normalize-package-bin": "^1.0.1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-cache-filename": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-install-checks": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "semver": "^2.3.0 || 3.x || 4 || 5" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-lifecycle": { "version": "3.1.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Artistic-2.0", + "peer": true, "dependencies": { "byline": "^5.0.0", "graceful-fs": "^4.1.15", @@ -7161,21 +7421,24 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-logical-tree": { "version": "1.2.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-normalize-package-bin": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-package-arg": { "version": "6.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "hosted-git-info": "^2.7.1", "osenv": "^0.1.5", @@ -7185,9 +7448,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-packlist": { "version": "1.4.8", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "ignore-walk": "^3.0.1", "npm-bundled": "^1.0.1", @@ -7196,9 +7460,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-pick-manifest": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "figgy-pudding": "^3.5.1", "npm-package-arg": "^6.0.0", @@ -7207,9 +7472,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-profile": { "version": "4.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^1.1.2 || 2", "figgy-pudding": "^3.4.1", @@ -7218,9 +7484,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-registry-fetch": { "version": "4.0.7", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "bluebird": "^3.5.1", "figgy-pudding": "^3.4.1", @@ -7233,7 +7500,7 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-registry-fetch/node_modules/safe-buffer": { "version": "5.2.1", - "extraneous": true, + "dev": true, "funding": [ { "type": "github", @@ -7249,13 +7516,15 @@ } ], "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-run-path": { "version": "2.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "path-key": "^2.0.0" }, @@ -7265,15 +7534,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npm-user-validate": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/npmlog": { "version": "4.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -7283,45 +7554,50 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/number-is-nan": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/oauth-sign": { "version": "0.9.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/object-assign": { "version": "4.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/object-keys": { "version": "1.0.12", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/object.getownpropertydescriptors": { "version": "2.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "define-properties": "^1.1.2", "es-abstract": "^1.5.1" @@ -7332,45 +7608,50 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/once": { "version": "1.4.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/opener": { "version": "1.5.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "(WTFPL OR MIT)", + "peer": true, "bin": { "opener": "bin/opener-bin.js" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/os-homedir": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/os-tmpdir": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/osenv": { "version": "0.1.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "os-homedir": "^1.0.0", "os-tmpdir": "^1.0.0" @@ -7378,18 +7659,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/p-finally": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/package-json": { "version": "4.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "got": "^6.7.1", "registry-auth-token": "^3.0.1", @@ -7402,9 +7685,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pacote": { "version": "9.5.12", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "bluebird": "^3.5.3", "cacache": "^12.0.2", @@ -7440,9 +7724,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pacote/node_modules/minipass": { "version": "2.9.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -7450,9 +7735,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/parallel-transform": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "cyclist": "~0.2.2", "inherits": "^2.0.3", @@ -7461,9 +7747,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/parallel-transform/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -7476,93 +7763,105 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/parallel-transform/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/path-exists": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/path-is-absolute": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/path-is-inside": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "(WTFPL OR MIT)" + "license": "(WTFPL OR MIT)", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/path-key": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/path-parse": { "version": "1.0.6", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/performance-now": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pify": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/prepend-http": { "version": "1.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/process-nextick-args": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/promise-inflight": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/promise-retry": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "err-code": "^1.0.0", "retry": "^0.10.0" @@ -7573,60 +7872,68 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/promise-retry/node_modules/retry": { "version": "0.10.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/promzard": { "version": "0.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "read": "1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/proto-list": { "version": "1.2.4", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/protoduck": { "version": "5.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "genfun": "^5.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/prr": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pseudomap": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/psl": { "version": "1.1.29", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pump": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7634,9 +7941,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pumpify": { "version": "1.5.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "duplexify": "^3.6.0", "inherits": "^2.0.3", @@ -7645,9 +7953,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/pumpify/node_modules/pump": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7655,32 +7964,36 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/punycode": { "version": "1.4.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/qrcode-terminal": { "version": "0.12.0", - "extraneous": true, + "dev": true, "inBundle": true, + "peer": true, "bin": { "qrcode-terminal": "bin/qrcode-terminal.js" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/qs": { "version": "6.5.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/query-string": { "version": "6.8.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "decode-uri-component": "^0.2.0", "split-on-first": "^1.0.0", @@ -7692,15 +8005,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/qw": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/rc": { "version": "1.2.8", - "extraneous": true, + "dev": true, "inBundle": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "peer": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -7713,9 +8028,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/read": { "version": "1.0.7", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "mute-stream": "~0.0.4" }, @@ -7725,18 +8041,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/read-cmd-shim": { "version": "1.0.5", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/read-installed": { "version": "4.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "debuglog": "^1.0.1", "read-package-json": "^2.0.0", @@ -7751,9 +8069,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/read-package-json": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.1", "json-parse-better-errors": "^1.0.1", @@ -7766,9 +8085,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/read-package-tree": { "version": "5.3.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "read-package-json": "^2.0.0", "readdir-scoped-modules": "^1.0.0", @@ -7777,9 +8097,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/readable-stream": { "version": "3.6.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -7791,9 +8112,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/readdir-scoped-modules": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "debuglog": "^1.0.1", "dezalgo": "^1.0.0", @@ -7803,9 +8125,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/registry-auth-token": { "version": "3.4.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "rc": "^1.1.6", "safe-buffer": "^5.0.1" @@ -7813,9 +8136,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/registry-url": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "rc": "^1.0.1" }, @@ -7825,9 +8149,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/request": { "version": "2.88.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -7856,42 +8181,47 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/require-directory": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/require-main-filename": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/resolve-from": { "version": "4.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/retry": { "version": "0.12.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/rimraf": { "version": "2.7.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -7901,45 +8231,51 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/run-queue": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "aproba": "^1.1.1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/run-queue/node_modules/aproba": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/safe-buffer": { "version": "5.1.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/semver": { "version": "5.7.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/semver-diff": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "semver": "^5.0.3" }, @@ -7949,24 +8285,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/set-blocking": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sha": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "(BSD-2-Clause OR MIT)", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/shebang-command": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^1.0.0" }, @@ -7976,33 +8315,37 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/shebang-regex": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/signal-exit": { "version": "3.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/slide": { "version": "1.1.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/smart-buffer": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -8010,9 +8353,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/socks": { "version": "2.3.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ip": "1.1.5", "smart-buffer": "^4.1.0" @@ -8024,9 +8368,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/socks-proxy-agent": { "version": "4.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "~4.2.1", "socks": "~2.3.2" @@ -8037,9 +8382,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/socks-proxy-agent/node_modules/agent-base": { "version": "4.2.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "es6-promisify": "^5.0.0" }, @@ -8049,15 +8395,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-object": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "(WTFPL OR MIT)" + "license": "(WTFPL OR MIT)", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-union-stream": { "version": "2.1.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "from2": "^1.3.0", "stream-iterate": "^1.1.0" @@ -8065,9 +8413,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-union-stream/node_modules/from2": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.10" @@ -8075,15 +8424,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-union-stream/node_modules/isarray": { "version": "0.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-union-stream/node_modules/readable-stream": { "version": "1.1.14", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", @@ -8093,15 +8444,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sorted-union-stream/node_modules/string_decoder": { "version": "0.10.31", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/spdx-correct": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -8109,15 +8462,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/spdx-exceptions": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "CC-BY-3.0" + "license": "CC-BY-3.0", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/spdx-expression-parse": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -8125,24 +8480,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/spdx-license-ids": { "version": "3.0.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/split-on-first": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/sshpk": { "version": "1.14.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -8167,18 +8525,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/ssri": { "version": "6.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "figgy-pudding": "^3.5.1" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stream-each": { "version": "1.2.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "stream-shift": "^1.0.0" @@ -8186,9 +8546,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stream-iterate": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.1.5", "stream-shift": "^1.0.0" @@ -8196,9 +8557,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stream-iterate/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8211,48 +8573,54 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stream-iterate/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stream-shift": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/strict-uri-encode": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string_decoder": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string-width": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^4.0.0" @@ -8263,27 +8631,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string-width/node_modules/ansi-regex": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string-width/node_modules/is-fullwidth-code-point": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/string-width/node_modules/strip-ansi": { "version": "4.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^3.0.0" }, @@ -8293,15 +8664,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/stringify-package": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/strip-ansi": { "version": "3.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^2.0.0" }, @@ -8311,27 +8684,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/strip-eof": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/strip-json-comments": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/supports-color": { "version": "5.4.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -8341,9 +8717,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tar": { "version": "4.4.13", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "chownr": "^1.1.1", "fs-minipass": "^1.2.5", @@ -8359,9 +8736,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tar/node_modules/minipass": { "version": "2.9.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -8369,9 +8747,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/term-size": { "version": "1.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "execa": "^0.7.0" }, @@ -8381,21 +8760,24 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/text-table": { "version": "0.2.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/through": { "version": "2.3.8", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/through2": { "version": "2.0.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.1.5", "xtend": "~4.0.1" @@ -8403,9 +8785,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/through2/node_modules/readable-stream": { "version": "2.3.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8418,33 +8801,37 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/through2/node_modules/string_decoder": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/timed-out": { "version": "4.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tiny-relative-date": { "version": "1.3.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tough-cookie": { "version": "2.4.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "psl": "^1.1.24", "punycode": "^1.4.1" @@ -8455,9 +8842,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tunnel-agent": { "version": "0.6.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -8467,54 +8855,62 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/tweetnacl": { "version": "0.14.5", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "Unlicense" + "license": "Unlicense", + "optional": true, + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/typedarray": { "version": "0.0.6", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/uid-number": { "version": "0.0.6", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "engines": { "node": "*" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/umask": { "version": "1.1.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/unique-filename": { "version": "1.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "unique-slug": "^2.0.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/unique-slug": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "imurmurhash": "^0.1.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/unique-string": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "crypto-random-string": "^1.0.0" }, @@ -8524,27 +8920,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/unpipe": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/unzip-response": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/update-notifier": { "version": "2.5.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "boxen": "^1.2.1", "chalk": "^2.0.1", @@ -8563,27 +8962,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/uri-js": { "version": "4.4.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "punycode": "^2.1.0" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/uri-js/node_modules/punycode": { "version": "2.1.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/url-parse-lax": { "version": "1.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "prepend-http": "^1.0.1" }, @@ -8593,39 +8995,44 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/util-deprecate": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/util-extend": { "version": "1.0.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/util-promisify": { "version": "2.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "object.getownpropertydescriptors": "^2.0.3" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/uuid": { "version": "3.3.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "bin": { "uuid": "bin/uuid" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/validate-npm-package-license": { "version": "3.0.4", - "extraneous": true, + "dev": true, "inBundle": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -8633,21 +9040,23 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/validate-npm-package-name": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "builtins": "^1.0.3" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/verror": { "version": "1.10.0", + "dev": true, "engines": [ "node >=0.6.0" ], - "extraneous": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -8656,18 +9065,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wcwidth": { "version": "1.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "defaults": "^1.0.3" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/which": { "version": "1.3.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8677,24 +9088,27 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/which-module": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wide-align": { "version": "1.1.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^1.0.2" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wide-align/node_modules/string-width": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -8706,9 +9120,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/widest-line": { "version": "2.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "string-width": "^2.1.1" }, @@ -8718,18 +9133,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/worker-farm": { "version": "1.7.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "errno": "~0.1.7" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrap-ansi": { "version": "5.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^3.2.0", "string-width": "^3.0.0", @@ -8741,27 +9158,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -8773,9 +9193,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "5.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, @@ -8785,15 +9206,17 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/wrappy": { "version": "1.0.2", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/write-file-atomic": { "version": "2.4.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "graceful-fs": "^4.1.11", "imurmurhash": "^0.1.4", @@ -8802,39 +9225,44 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/xdg-basedir": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/xtend": { "version": "4.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/y18n": { "version": "4.0.1", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yallist": { "version": "3.0.3", - "extraneous": true, + "dev": true, "inBundle": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs": { "version": "14.2.3", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "cliui": "^5.0.0", "decamelize": "^1.2.0", @@ -8851,9 +9279,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs-parser": { "version": "15.0.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "ISC", + "peer": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" @@ -8861,27 +9290,30 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs-parser/node_modules/camelcase": { "version": "5.3.1", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/ansi-regex": { "version": "4.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/find-up": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -8891,18 +9323,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "2.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/locate-path": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -8913,9 +9347,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/p-limit": { "version": "2.3.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -8928,9 +9363,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/p-locate": { "version": "3.0.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -8940,18 +9376,20 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/p-try": { "version": "2.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/string-width": { "version": "3.1.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", @@ -8963,9 +9401,10 @@ }, "node_modules/ep_etherpad-lite/node_modules/npm/node_modules/yargs/node_modules/strip-ansi": { "version": "5.2.0", - "extraneous": true, + "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^4.1.0" }, diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js index f508af641..5734769f6 100644 --- a/src/static/js/AttributeManager.js +++ b/src/static/js/AttributeManager.js @@ -1,23 +1,18 @@ +import AttributeMap from "./AttributeMap.js"; +import * as Changeset from "./Changeset.js"; +import * as ChangesetUtils from "./ChangesetUtils.js"; +import * as attributes from "./attributes.js"; +import * as _ from "./underscore.js"; 'use strict'; - -const AttributeMap = require('./AttributeMap'); -const Changeset = require('./Changeset'); -const ChangesetUtils = require('./ChangesetUtils'); -const attributes = require('./attributes'); -const _ = require('./underscore'); - const lineMarkerAttribute = 'lmkr'; - // Some of these attributes are kept for compatibility purposes. // Not sure if we need all of them const DEFAULT_LINE_ATTRIBUTES = ['author', 'lmkr', 'insertorder', 'start']; - // If one of these attributes are set to the first character of a // line it is considered as a line attribute marker i.e. attributes // set on this marker are applied to the whole line. // The list attribute is only maintained for compatibility reasons const lineAttributes = [lineMarkerAttribute, 'list']; - /* The Attribute manager builds changesets based on a document representation for setting and removing range or line-based attributes. @@ -32,351 +27,318 @@ const lineAttributes = [lineMarkerAttribute, 'list']; - an Attribute pool `apool` - a SkipList `lines` containing the text lines of the document. */ - const AttributeManager = function (rep, applyChangesetCallback) { - this.rep = rep; - this.applyChangesetCallback = applyChangesetCallback; - this.author = ''; - - // If the first char in a line has one of the following attributes - // it will be considered as a line marker + this.rep = rep; + this.applyChangesetCallback = applyChangesetCallback; + this.author = ''; + // If the first char in a line has one of the following attributes + // it will be considered as a line marker }; - AttributeManager.DEFAULT_LINE_ATTRIBUTES = DEFAULT_LINE_ATTRIBUTES; AttributeManager.lineAttributes = lineAttributes; - AttributeManager.prototype = _(AttributeManager.prototype).extend({ - - applyChangeset(changeset) { - if (!this.applyChangesetCallback) return changeset; - - const cs = changeset.toString(); - if (!Changeset.isIdentity(cs)) { - this.applyChangesetCallback(cs); - } - - return changeset; - }, - - /* - Sets attributes on a range - @param start [row, col] tuple pointing to the start of the range - @param end [row, col] tuple pointing to the end of the range - @param attribs: an array of attributes - */ - setAttributesOnRange(start, end, attribs) { - if (start[0] < 0) throw new RangeError('selection start line number is negative'); - if (start[1] < 0) throw new RangeError('selection start column number is negative'); - if (end[0] < 0) throw new RangeError('selection end line number is negative'); - if (end[1] < 0) throw new RangeError('selection end column number is negative'); - if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) { - throw new RangeError('selection ends before it starts'); - } - - // instead of applying the attributes to the whole range at once, we need to apply them - // line by line, to be able to disregard the "*" used as line marker. For more details, - // see https://github.com/ether/etherpad-lite/issues/2772 - let allChangesets; - for (let row = start[0]; row <= end[0]; row++) { - const [startCol, endCol] = this._findRowRange(row, start, end); - const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs); - - // compose changesets of all rows into a single changeset - // as the range might not be continuous - // due to the presence of line markers on the rows - if (allChangesets) { - allChangesets = Changeset.compose( - allChangesets.toString(), rowChangeset.toString(), this.rep.apool); - } else { - allChangesets = rowChangeset; - } - } - - return this.applyChangeset(allChangesets); - }, - - _findRowRange(row, start, end) { - 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`); - - // Subtract 1 for the end-of-line '\n' (it is never selected). - const lineLength = - this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1; - const markerWidth = this.lineHasMarker(row) ? 1 : 0; - if (lineLength - markerWidth < 0) throw new Error(`line ${row} has negative length`); - - if (start[1] < 0) throw new RangeError('selection starts at negative column'); - const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0); - if (startCol > lineLength) throw new RangeError('selection starts after line end'); - - if (end[1] < 0) throw new RangeError('selection ends at negative column'); - const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength); - if (endCol > lineLength) throw new RangeError('selection ends after line end'); - if (startCol > endCol) throw new RangeError('selection ends before it starts'); - - return [startCol, endCol]; - }, - - /** - * Sets attributes on a range, by line - * @param row the row where range is - * @param startCol column where range starts - * @param endCol column where range ends (one past the last selected column) - * @param attribs an array of attributes - */ - _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); - ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); - ChangesetUtils.buildKeepRange( - this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); - return builder; - }, - - /* - Returns if the line already has a line marker - @param lineNum: the number of the line - */ - lineHasMarker(lineNum) { - return lineAttributes.find( - (attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined; - }, - - /* - Gets a specified attribute on a line - @param lineNum: the number of the line to set the attribute for - @param attributeKey: the name of the attribute to get, e.g. list - */ - getAttributeOnLine(lineNum, attributeName) { - // get `attributeName` attribute of first char of line - const aline = this.rep.alines[lineNum]; - if (!aline) return ''; - const [op] = Changeset.deserializeOps(aline); - if (op == null) return ''; - return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || ''; - }, - - /* - Gets all attributes on a line - @param lineNum: the number of the line to get the attribute for - */ - getAttributesOnLine(lineNum) { - // get attributes of first char of line - const aline = this.rep.alines[lineNum]; - if (!aline) return []; - const [op] = Changeset.deserializeOps(aline); - if (op == null) return []; - return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; - }, - - /* - Gets a given attribute on a selection - @param attributeName - @param prevChar - returns true or false if an attribute is visible in range - */ - getAttributeOnSelection(attributeName, prevChar) { - const rep = this.rep; - if (!(rep.selStart && rep.selEnd)) return; - // If we're looking for the caret attribute not the selection - // has the user already got a selection or is this purely a caret location? - const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); - if (isNotSelection) { - if (prevChar) { - // If it's not the start of the line - if (rep.selStart[1] !== 0) { - rep.selStart[1]--; + applyChangeset(changeset) { + if (!this.applyChangesetCallback) + return changeset; + const cs = changeset.toString(); + if (!Changeset.isIdentity(cs)) { + this.applyChangesetCallback(cs); } - } - } - - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); - - const rangeHasAttrib = (selStart, selEnd) => { - // if range is collapsed -> no attribs in range - if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; - - if (selStart[0] !== selEnd[0]) { // -> More than one line selected - // from selStart to the end of the first line - let hasAttrib = rangeHasAttrib( - selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); - - // for all lines in between - for (let n = selStart[0] + 1; n < selEnd[0]; n++) { - hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); + return changeset; + }, + /* + Sets attributes on a range + @param start [row, col] tuple pointing to the start of the range + @param end [row, col] tuple pointing to the end of the range + @param attribs: an array of attributes + */ + setAttributesOnRange(start, end, attribs) { + if (start[0] < 0) + throw new RangeError('selection start line number is negative'); + if (start[1] < 0) + throw new RangeError('selection start column number is negative'); + if (end[0] < 0) + throw new RangeError('selection end line number is negative'); + if (end[1] < 0) + throw new RangeError('selection end column number is negative'); + if (start[0] > end[0] || (start[0] === end[0] && start[1] > end[1])) { + throw new RangeError('selection ends before it starts'); + } + // instead of applying the attributes to the whole range at once, we need to apply them + // line by line, to be able to disregard the "*" used as line marker. For more details, + // see https://github.com/ether/etherpad-lite/issues/2772 + let allChangesets; + for (let row = start[0]; row <= end[0]; row++) { + const [startCol, endCol] = this._findRowRange(row, start, end); + const rowChangeset = this._setAttributesOnRangeByLine(row, startCol, endCol, attribs); + // compose changesets of all rows into a single changeset + // as the range might not be continuous + // due to the presence of line markers on the rows + if (allChangesets) { + allChangesets = Changeset.compose(allChangesets.toString(), rowChangeset.toString(), this.rep.apool); + } + else { + allChangesets = rowChangeset; + } + } + return this.applyChangeset(allChangesets); + }, + _findRowRange(row, start, end) { + 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`); + // Subtract 1 for the end-of-line '\n' (it is never selected). + const lineLength = this.rep.lines.offsetOfIndex(row + 1) - this.rep.lines.offsetOfIndex(row) - 1; + const markerWidth = this.lineHasMarker(row) ? 1 : 0; + if (lineLength - markerWidth < 0) + throw new Error(`line ${row} has negative length`); + if (start[1] < 0) + throw new RangeError('selection starts at negative column'); + const startCol = Math.max(markerWidth, row === start[0] ? start[1] : 0); + if (startCol > lineLength) + throw new RangeError('selection starts after line end'); + if (end[1] < 0) + throw new RangeError('selection ends at negative column'); + const endCol = Math.max(markerWidth, row === end[0] ? end[1] : lineLength); + if (endCol > lineLength) + throw new RangeError('selection ends after line end'); + if (startCol > endCol) + throw new RangeError('selection ends before it starts'); + return [startCol, endCol]; + }, + /** + * Sets attributes on a range, by line + * @param row the row where range is + * @param startCol column where range starts + * @param endCol column where range ends (one past the last selected column) + * @param attribs an array of attributes + */ + _setAttributesOnRangeByLine(row, startCol, endCol, attribs) { + const builder = Changeset.builder(this.rep.lines.totalWidth()); + ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [row, startCol]); + ChangesetUtils.buildKeepRange(this.rep, builder, [row, startCol], [row, endCol], attribs, this.rep.apool); + return builder; + }, + /* + Returns if the line already has a line marker + @param lineNum: the number of the line + */ + lineHasMarker(lineNum) { + return lineAttributes.find((attribute) => this.getAttributeOnLine(lineNum, attribute) !== '') !== undefined; + }, + /* + Gets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to get, e.g. list + */ + getAttributeOnLine(lineNum, attributeName) { + // get `attributeName` attribute of first char of line + const aline = this.rep.alines[lineNum]; + if (!aline) + return ''; + const [op] = Changeset.deserializeOps(aline); + if (op == null) + return ''; + return AttributeMap.fromString(op.attribs, this.rep.apool).get(attributeName) || ''; + }, + /* + Gets all attributes on a line + @param lineNum: the number of the line to get the attribute for + */ + getAttributesOnLine(lineNum) { + // get attributes of first char of line + const aline = this.rep.alines[lineNum]; + if (!aline) + return []; + const [op] = Changeset.deserializeOps(aline); + if (op == null) + return []; + return [...attributes.attribsFromString(op.attribs, this.rep.apool)]; + }, + /* + Gets a given attribute on a selection + @param attributeName + @param prevChar + returns true or false if an attribute is visible in range + */ + getAttributeOnSelection(attributeName, prevChar) { + const rep = this.rep; + if (!(rep.selStart && rep.selEnd)) + return; + // If we're looking for the caret attribute not the selection + // has the user already got a selection or is this purely a caret location? + const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); + if (isNotSelection) { + if (prevChar) { + // If it's not the start of the line + if (rep.selStart[1] !== 0) { + rep.selStart[1]--; + } + } + } + const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); + const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); + const hasIt = (attribs) => withItRegex.test(attribs); + const rangeHasAttrib = (selStart, selEnd) => { + // if range is collapsed -> no attribs in range + if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) + return false; + if (selStart[0] !== selEnd[0]) { // -> More than one line selected + // from selStart to the end of the first line + let hasAttrib = rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); + // for all lines in between + for (let n = selStart[0] + 1; n < selEnd[0]; n++) { + hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); + } + // for the last, potentially partial, line + hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); + return hasAttrib; + } + // Logic tells us we now have a range on a single line + const lineNum = selStart[0]; + const start = selStart[1]; + const end = selEnd[1]; + let hasAttrib = true; + let indexIntoLine = 0; + for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { + const opStartInLine = indexIntoLine; + const opEndInLine = opStartInLine + op.chars; + if (!hasIt(op.attribs)) { + // does op overlap selection? + if (!(opEndInLine <= start || opStartInLine >= end)) { + // since it's overlapping but hasn't got the attrib -> range hasn't got it + hasAttrib = false; + break; + } + } + indexIntoLine = opEndInLine; + } + return hasAttrib; + }; + return rangeHasAttrib(rep.selStart, rep.selEnd); + }, + /* + Gets all attributes at a position containing line number and column + @param lineNumber starting with zero + @param column starting with zero + returns a list of attributes in the format + [ ["key","value"], ["key","value"], ... ] + */ + getAttributesOnPosition(lineNumber, column) { + // get all attributes of the line + const aline = this.rep.alines[lineNumber]; + if (!aline) { + return []; + } + // we need to sum up how much characters each operations take until the wanted position + let currentPointer = 0; + for (const currentOperation of Changeset.deserializeOps(aline)) { + currentPointer += currentOperation.chars; + if (currentPointer <= column) + continue; + return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)]; + } + return []; + }, + /* + Gets all attributes at caret position + if the user selected a range, the start of the selection is taken + returns a list of attributes in the format + [ ["key","value"], ["key","value"], ... ] + */ + getAttributesOnCaret() { + return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]); + }, + /* + Sets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to set, e.g. list + @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) + + */ + setAttributeOnLine(lineNum, attributeName, attributeValue) { + let loc = [0, 0]; + const builder = Changeset.builder(this.rep.lines.totalWidth()); + const hasMarker = this.lineHasMarker(lineNum); + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); + if (hasMarker) { + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ + [attributeName, attributeValue], + ], this.rep.apool); + } + else { + // add a line marker + builder.insert('*', [ + ['author', this.author], + ['insertorder', 'first'], + [lineMarkerAttribute, '1'], + [attributeName, attributeValue], + ], this.rep.apool); + } + return this.applyChangeset(builder); + }, + /** + * Removes a specified attribute on a line + * @param lineNum the number of the affected line + * @param attributeName the name of the attribute to remove, e.g. list + * @param attributeValue if given only attributes with equal value will be removed + */ + removeAttributeOnLine(lineNum, attributeName, attributeValue) { + const builder = Changeset.builder(this.rep.lines.totalWidth()); + const hasMarker = this.lineHasMarker(lineNum); + let found = false; + const attribs = this.getAttributesOnLine(lineNum).map((attrib) => { + if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) { + found = true; + return [attrib[0], '']; + } + else if (attrib[0] === 'author') { + // update last author to make changes to line attributes on this line + return [attrib[0], this.author]; + } + return attrib; + }); + if (!found) { + return; + } + ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); + const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1]) + .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); + // if we have marker and any of attributes don't need to have marker. we need delete it + if (hasMarker && !countAttribsWithMarker) { + ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); + } + else { + ChangesetUtils.buildKeepRange(this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); + } + return this.applyChangeset(builder); + }, + /* + Toggles a line attribute for the specified line number + If a line attribute with the specified name exists with any value it will be removed + Otherwise it will be set to the given value + @param lineNum: the number of the line to toggle the attribute for + @param attributeKey: the name of the attribute to toggle, e.g. list + @param attributeValue: the value to pass to the attribute (e.g. indention level) + */ + toggleAttributeOnLine(lineNum, attributeName, attributeValue) { + return this.getAttributeOnLine(lineNum, attributeName) + ? this.removeAttributeOnLine(lineNum, attributeName) + : this.setAttributeOnLine(lineNum, attributeName, attributeValue); + }, + hasAttributeOnSelectionOrCaretPosition(attributeName) { + const hasSelection = ((this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1])); + let hasAttrib; + if (hasSelection) { + hasAttrib = this.getAttributeOnSelection(attributeName); + } + else { + const attributesOnCaretPosition = this.getAttributesOnCaret(); + const allAttribs = [].concat(...attributesOnCaretPosition); // flatten + hasAttrib = allAttribs.includes(attributeName); } - - // for the last, potentially partial, line - hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); - return hasAttrib; - } - - // Logic tells us we now have a range on a single line - - const lineNum = selStart[0]; - const start = selStart[1]; - const end = selEnd[1]; - let hasAttrib = true; - - let indexIntoLine = 0; - for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= start || opStartInLine >= end)) { - // since it's overlapping but hasn't got the attrib -> range hasn't got it - hasAttrib = false; - break; - } - } - indexIntoLine = opEndInLine; - } - - return hasAttrib; - }; - return rangeHasAttrib(rep.selStart, rep.selEnd); - }, - - /* - Gets all attributes at a position containing line number and column - @param lineNumber starting with zero - @param column starting with zero - returns a list of attributes in the format - [ ["key","value"], ["key","value"], ... ] - */ - getAttributesOnPosition(lineNumber, column) { - // get all attributes of the line - const aline = this.rep.alines[lineNumber]; - - if (!aline) { - return []; - } - - // we need to sum up how much characters each operations take until the wanted position - let currentPointer = 0; - - for (const currentOperation of Changeset.deserializeOps(aline)) { - currentPointer += currentOperation.chars; - if (currentPointer <= column) continue; - return [...attributes.attribsFromString(currentOperation.attribs, this.rep.apool)]; - } - return []; - }, - - /* - Gets all attributes at caret position - if the user selected a range, the start of the selection is taken - returns a list of attributes in the format - [ ["key","value"], ["key","value"], ... ] - */ - getAttributesOnCaret() { - return this.getAttributesOnPosition(this.rep.selStart[0], this.rep.selStart[1]); - }, - - /* - Sets a specified attribute on a line - @param lineNum: the number of the line to set the attribute for - @param attributeKey: the name of the attribute to set, e.g. list - @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) - - */ - setAttributeOnLine(lineNum, attributeName, attributeValue) { - let loc = [0, 0]; - const builder = Changeset.builder(this.rep.lines.totalWidth()); - const hasMarker = this.lineHasMarker(lineNum); - - ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); - - if (hasMarker) { - ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ - [attributeName, attributeValue], - ], this.rep.apool); - } else { - // add a line marker - builder.insert('*', [ - ['author', this.author], - ['insertorder', 'first'], - [lineMarkerAttribute, '1'], - [attributeName, attributeValue], - ], this.rep.apool); - } - - return this.applyChangeset(builder); - }, - - /** - * Removes a specified attribute on a line - * @param lineNum the number of the affected line - * @param attributeName the name of the attribute to remove, e.g. list - * @param attributeValue if given only attributes with equal value will be removed - */ - removeAttributeOnLine(lineNum, attributeName, attributeValue) { - const builder = Changeset.builder(this.rep.lines.totalWidth()); - const hasMarker = this.lineHasMarker(lineNum); - let found = false; - - const attribs = this.getAttributesOnLine(lineNum).map((attrib) => { - if (attrib[0] === attributeName && (!attributeValue || attrib[0] === attributeValue)) { - found = true; - return [attrib[0], '']; - } else if (attrib[0] === 'author') { - // update last author to make changes to line attributes on this line - return [attrib[0], this.author]; - } - return attrib; - }); - - if (!found) { - return; - } - - ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, [lineNum, 0]); - - const countAttribsWithMarker = _.chain(attribs).filter((a) => !!a[1]) - .map((a) => a[0]).difference(DEFAULT_LINE_ATTRIBUTES).size().value(); - - // if we have marker and any of attributes don't need to have marker. we need delete it - if (hasMarker && !countAttribsWithMarker) { - ChangesetUtils.buildRemoveRange(this.rep, builder, [lineNum, 0], [lineNum, 1]); - } else { - ChangesetUtils.buildKeepRange( - this.rep, builder, [lineNum, 0], [lineNum, 1], attribs, this.rep.apool); - } - - return this.applyChangeset(builder); - }, - - /* - Toggles a line attribute for the specified line number - If a line attribute with the specified name exists with any value it will be removed - Otherwise it will be set to the given value - @param lineNum: the number of the line to toggle the attribute for - @param attributeKey: the name of the attribute to toggle, e.g. list - @param attributeValue: the value to pass to the attribute (e.g. indention level) - */ - toggleAttributeOnLine(lineNum, attributeName, attributeValue) { - return this.getAttributeOnLine(lineNum, attributeName) - ? this.removeAttributeOnLine(lineNum, attributeName) - : this.setAttributeOnLine(lineNum, attributeName, attributeValue); - }, - - hasAttributeOnSelectionOrCaretPosition(attributeName) { - const hasSelection = ( - (this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1]) - ); - let hasAttrib; - if (hasSelection) { - hasAttrib = this.getAttributeOnSelection(attributeName); - } else { - const attributesOnCaretPosition = this.getAttributesOnCaret(); - const allAttribs = [].concat(...attributesOnCaretPosition); // flatten - hasAttrib = allAttribs.includes(attributeName); - } - return hasAttrib; - }, + }, }); - -module.exports = AttributeManager; +export default AttributeManager; diff --git a/src/static/js/AttributeMap.js b/src/static/js/AttributeMap.js index 55640eb8b..4e55eb8a7 100644 --- a/src/static/js/AttributeMap.js +++ b/src/static/js/AttributeMap.js @@ -1,13 +1,10 @@ +import * as attributes from "./attributes.js"; 'use strict'; - -const attributes = require('./attributes'); - /** * A `[key, value]` pair of strings describing a text attribute. * * @typedef {[string, string]} Attribute */ - /** * A concatenated sequence of zero or more attribute identifiers, each one represented by an * asterisk followed by a base-36 encoded attribute number. @@ -16,76 +13,70 @@ const attributes = require('./attributes'); * * @typedef {string} AttributeString */ - /** * Convenience class to convert an Op's attribute string to/from a Map of key, value pairs. */ class AttributeMap extends Map { - /** - * Converts an attribute string into an AttributeMap. - * - * @param {AttributeString} str - The attribute string to convert into an AttributeMap. - * @param {AttributePool} pool - Attribute pool. - * @returns {AttributeMap} - */ - static fromString(str, pool) { - return new AttributeMap(pool).updateFromString(str); - } - - /** - * @param {AttributePool} pool - Attribute pool. - */ - constructor(pool) { - super(); - /** @public */ - this.pool = pool; - } - - /** - * @param {string} k - Attribute name. - * @param {string} v - Attribute value. - * @returns {AttributeMap} `this` (for chaining). - */ - set(k, v) { - k = k == null ? '' : String(k); - v = v == null ? '' : String(v); - this.pool.putAttrib([k, v]); - return super.set(k, v); - } - - toString() { - return attributes.attribsToString(attributes.sort([...this]), this.pool); - } - - /** - * @param {Iterable} entries - [key, value] pairs to insert into this map. - * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the - * key is removed from this map (if present). - * @returns {AttributeMap} `this` (for chaining). - */ - update(entries, emptyValueIsDelete = false) { - for (let [k, v] of entries) { - k = k == null ? '' : String(k); - v = v == null ? '' : String(v); - if (!v && emptyValueIsDelete) { - this.delete(k); - } else { - this.set(k, v); - } + /** + * Converts an attribute string into an AttributeMap. + * + * @param {AttributeString} str - The attribute string to convert into an AttributeMap. + * @param {AttributePool} pool - Attribute pool. + * @returns {AttributeMap} + */ + static fromString(str, pool) { + return new AttributeMap(pool).updateFromString(str); + } + /** + * @param {AttributePool} pool - Attribute pool. + */ + constructor(pool) { + super(); + /** @public */ + this.pool = pool; + } + /** + * @param {string} k - Attribute name. + * @param {string} v - Attribute value. + * @returns {AttributeMap} `this` (for chaining). + */ + set(k, v) { + k = k == null ? '' : String(k); + v = v == null ? '' : String(v); + this.pool.putAttrib([k, v]); + return super.set(k, v); + } + toString() { + return attributes.attribsToString(attributes.sort([...this]), this.pool); + } + /** + * @param {Iterable} entries - [key, value] pairs to insert into this map. + * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the + * key is removed from this map (if present). + * @returns {AttributeMap} `this` (for chaining). + */ + update(entries, emptyValueIsDelete = false) { + for (let [k, v] of entries) { + k = k == null ? '' : String(k); + v = v == null ? '' : String(v); + if (!v && emptyValueIsDelete) { + this.delete(k); + } + else { + this.set(k, v); + } + } + return this; + } + /** + * @param {AttributeString} str - The attribute string identifying the attributes to insert into + * this map. + * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the + * key is removed from this map (if present). + * @returns {AttributeMap} `this` (for chaining). + */ + updateFromString(str, emptyValueIsDelete = false) { + return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete); } - return this; - } - - /** - * @param {AttributeString} str - The attribute string identifying the attributes to insert into - * this map. - * @param {boolean} [emptyValueIsDelete] - If true and an entry's value is the empty string, the - * key is removed from this map (if present). - * @returns {AttributeMap} `this` (for chaining). - */ - updateFromString(str, emptyValueIsDelete = false) { - return this.update(attributes.attribsFromString(str, this.pool), emptyValueIsDelete); - } } - -module.exports = AttributeMap; +export default AttributeMap; diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index 614d82061..c6fa8402e 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -22,9 +22,9 @@ import { padutils } from "./pad_utils.js"; * @param {string} msg - Just some message */ const error = (msg) => { - const e = new Error(msg); - e.easysync = true; - throw e; + const e = new Error(msg); + e.easysync = true; + throw e; }; /** * Assert that a condition is truthy. If the condition is falsy, the `error` function is called to @@ -35,75 +35,75 @@ const error = (msg) => { * @type {(b: boolean, msg: string) => asserts b} */ const assert = (b, msg) => { - if (!b) - error(`Failed assertion: ${msg}`); + if (!b) + error(`Failed assertion: ${msg}`); }; /** * An operation to apply to a shared document. */ class Op { - /** - * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. - */ - constructor(opcode = '') { /** - * The operation's operator: - * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base - * document. - * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in - * the document. The inserted characters come from the changeset's character bank. - * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an - * operation. - * - * @type {(''|'='|'+'|'-')} - * @public + * @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property. */ - this.opcode = opcode; - /** - * The number of characters to keep, insert, or delete. - * - * @type {number} - * @public - */ - this.chars = 0; - /** - * The number of characters among the `chars` characters that are newlines. If non-zero, the - * last character must be a newline. - * - * @type {number} - * @public - */ - this.lines = 0; - /** - * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) - * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, - * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The - * identifiers come from the document's attribute pool. - * - * For keep ('=') operations, the attributes are merged with the base text's existing - * attributes: - * - A keep op attribute with a non-empty value replaces an existing base text attribute that - * has the same key. - * - A keep op attribute with an empty value is interpreted as an instruction to remove an - * existing base text attribute that has the same key, if one exists. - * - * This is the empty string for remove ('-') operations. - * - * @type {string} - * @public - */ - this.attribs = ''; - } - toString() { - if (!this.opcode) - throw new TypeError('null op'); - if (typeof this.attribs !== 'string') - throw new TypeError('attribs must be a string'); - const l = this.lines ? `|${exports.numToString(this.lines)}` : ''; - return this.attribs + l + this.opcode + exports.numToString(this.chars); - } + constructor(opcode = '') { + /** + * The operation's operator: + * - '=': Keep the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '-': Remove the next `chars` characters (containing `lines` newlines) from the base + * document. + * - '+': Insert `chars` characters (containing `lines` newlines) at the current position in + * the document. The inserted characters come from the changeset's character bank. + * - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an + * operation. + * + * @type {(''|'='|'+'|'-')} + * @public + */ + this.opcode = opcode; + /** + * The number of characters to keep, insert, or delete. + * + * @type {number} + * @public + */ + this.chars = 0; + /** + * The number of characters among the `chars` characters that are newlines. If non-zero, the + * last character must be a newline. + * + * @type {number} + * @public + */ + this.lines = 0; + /** + * Identifiers of attributes to apply to the text, represented as a repeated (zero or more) + * sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example, + * '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The + * identifiers come from the document's attribute pool. + * + * For keep ('=') operations, the attributes are merged with the base text's existing + * attributes: + * - A keep op attribute with a non-empty value replaces an existing base text attribute that + * has the same key. + * - A keep op attribute with an empty value is interpreted as an instruction to remove an + * existing base text attribute that has the same key, if one exists. + * + * This is the empty string for remove ('-') operations. + * + * @type {string} + * @public + */ + this.attribs = ''; + } + toString() { + if (!this.opcode) + throw new TypeError('null op'); + if (typeof this.attribs !== 'string') + throw new TypeError('attribs must be a string'); + const l = this.lines ? `|${exports.numToString(this.lines)}` : ''; + return this.attribs + l + this.opcode + exports.numToString(this.chars); + } } /** * Iterator over a changeset's operations. @@ -113,38 +113,38 @@ class Op { * @deprecated Use `deserializeOps` instead. */ class OpIter { - /** - * @param {string} ops - String encoding the change operations to iterate over. - */ - constructor(ops) { - this._gen = exports.deserializeOps(ops); - this._next = this._gen.next(); - } - /** - * @returns {boolean} Whether there are any remaining operations. - */ - hasNext() { - return !this._next.done; - } - /** - * Returns the next operation object and advances the iterator. - * - * Note: This does NOT implement the ECMAScript iterator protocol. - * - * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. - * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are - * no more operations. - */ - next(opOut = new Op()) { - if (this.hasNext()) { - copyOp(this._next.value, opOut); - this._next = this._gen.next(); + /** + * @param {string} ops - String encoding the change operations to iterate over. + */ + constructor(ops) { + this._gen = exports.deserializeOps(ops); + this._next = this._gen.next(); } - else { - clearOp(opOut); + /** + * @returns {boolean} Whether there are any remaining operations. + */ + hasNext() { + return !this._next.done; + } + /** + * Returns the next operation object and advances the iterator. + * + * Note: This does NOT implement the ECMAScript iterator protocol. + * + * @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value. + * @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are + * no more operations. + */ + next(opOut = new Op()) { + if (this.hasNext()) { + copyOp(this._next.value, opOut); + this._next = this._gen.next(); + } + else { + clearOp(opOut); + } + return opOut; } - return opOut; - } } /** * Cleans an Op object. @@ -152,10 +152,10 @@ class OpIter { * @param {Op} op - object to clear */ const clearOp = (op) => { - op.opcode = ''; - op.chars = 0; - op.lines = 0; - op.attribs = ''; + op.opcode = ''; + op.chars = 0; + op.lines = 0; + op.attribs = ''; }; /** * Copies op1 to op2 @@ -198,24 +198,24 @@ const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1); * @returns {Generator} */ const opsFromText = function* (opcode, text, attribs = '', pool = null) { - const op = new Op(opcode); - op.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString(); - const lastNewlinePos = text.lastIndexOf('\n'); - if (lastNewlinePos < 0) { - op.chars = text.length; - op.lines = 0; - yield op; - } - else { - op.chars = lastNewlinePos + 1; - op.lines = text.match(/\n/g).length; - yield op; - const op2 = copyOp(op); - op2.chars = text.length - (lastNewlinePos + 1); - op2.lines = 0; - yield op2; - } + const op = new Op(opcode); + op.attribs = typeof attribs === 'string' + ? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString(); + const lastNewlinePos = text.lastIndexOf('\n'); + if (lastNewlinePos < 0) { + op.chars = text.length; + op.lines = 0; + yield op; + } + else { + op.chars = lastNewlinePos + 1; + op.lines = text.match(/\n/g).length; + yield op; + const op2 = copyOp(op); + op2.chars = text.length - (lastNewlinePos + 1); + op2.lines = 0; + yield op2; + } }; /** * @typedef {object} StringArrayLike @@ -240,316 +240,316 @@ const opsFromText = function* (opcode, text, attribs = '', pool = null) { * with no newlines. */ class TextLinesMutator { - /** - * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). - */ - constructor(lines) { - this._lines = lines; /** - * this._curSplice holds values that will be passed as arguments to this._lines.splice() to - * insert, delete, or change lines: - * - this._curSplice[0] is an index into the this._lines array. - * - this._curSplice[1] is the number of lines that will be removed from the this._lines array - * starting at the index. - * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) - * to insert at the index. - * - * @type {[number, number?, ...string[]?]} + * @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place). */ - this._curSplice = [0, 0]; - this._inSplice = false; - // position in lines after curSplice is applied: - this._curLine = 0; - this._curCol = 0; - // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && - // curLine >= curSplice[0] - // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then - // curCol == 0 - } - /** - * Get a line from `lines` at given index. - * - * @param {number} idx - an index - * @returns {string} - */ - _linesGet(idx) { - if ('get' in this._lines) { - return this._lines.get(idx); - } - else { - return this._lines[idx]; - } - } - /** - * Return a slice from `lines`. - * - * @param {number} start - the start index - * @param {number} end - the end index - * @returns {string[]} - */ - _linesSlice(start, end) { - // can be unimplemented if removeLines's return value not needed - if (this._lines.slice) { - return this._lines.slice(start, end); - } - else { - return []; - } - } - /** - * Return the length of `lines`. - * - * @returns {number} - */ - _linesLength() { - if (typeof this._lines.length === 'number') { - return this._lines.length; - } - else { - return this._lines.length(); - } - } - /** - * Starts a new splice. - */ - _enterSplice() { - this._curSplice[0] = this._curLine; - this._curSplice[1] = 0; - // TODO(doc) when is this the case? - // check all enterSplice calls and changes to curCol - if (this._curCol > 0) - this._putCurLineInSplice(); - this._inSplice = true; - } - /** - * Changes the lines array according to the values in curSplice and resets curSplice. Called via - * close or TODO(doc). - */ - _leaveSplice() { - this._lines.splice(...this._curSplice); - this._curSplice.length = 2; - this._curSplice[0] = this._curSplice[1] = 0; - this._inSplice = false; - } - /** - * Indicates if curLine is already in the splice. This is necessary because the last element in - * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). - * - * @returns {boolean} true if curLine is in splice - */ - _isCurLineInSplice() { - // The value of `this._curSplice[1]` does not matter when determining the return value because - // `this._curLine` refers to the line number *after* the splice is applied (so after those lines - // are deleted). - return this._curLine - this._curSplice[0] < this._curSplice.length - 2; - } - /** - * Incorporates current line into the splice and marks its old position to be deleted. - * - * @returns {number} the index of the added line in curSplice - */ - _putCurLineInSplice() { - if (!this._isCurLineInSplice()) { - this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1])); - this._curSplice[1]++; - } - // TODO should be the same as this._curSplice.length - 1 - return 2 + this._curLine - this._curSplice[0]; - } - /** - * It will skip some newlines by putting them into the splice. - * - * @param {number} L - - * @param {boolean} includeInSplice - Indicates that attributes are present. - */ - skipLines(L, includeInSplice) { - if (!L) - return; - if (includeInSplice) { - if (!this._inSplice) - this._enterSplice(); - // TODO(doc) should this count the number of characters that are skipped to check? - for (let i = 0; i < L; i++) { + constructor(lines) { + this._lines = lines; + /** + * this._curSplice holds values that will be passed as arguments to this._lines.splice() to + * insert, delete, or change lines: + * - this._curSplice[0] is an index into the this._lines array. + * - this._curSplice[1] is the number of lines that will be removed from the this._lines array + * starting at the index. + * - The other elements represent mutated (changed by ops) lines or new lines (added by ops) + * to insert at the index. + * + * @type {[number, number?, ...string[]?]} + */ + this._curSplice = [0, 0]; + this._inSplice = false; + // position in lines after curSplice is applied: + this._curLine = 0; this._curCol = 0; - this._putCurLineInSplice(); - this._curLine++; - } + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 } - else { - if (this._inSplice) { - if (L > 1) { - // TODO(doc) figure out why single lines are incorporated into splice instead of ignored - this._leaveSplice(); + /** + * Get a line from `lines` at given index. + * + * @param {number} idx - an index + * @returns {string} + */ + _linesGet(idx) { + if ('get' in this._lines) { + return this._lines.get(idx); } else { - this._putCurLineInSplice(); + return this._lines[idx]; } - } - this._curLine += L; - this._curCol = 0; } - // tests case foo in remove(), which isn't otherwise covered in current impl - } - /** - * Skip some characters. Can contain newlines. - * - * @param {number} N - number of characters to skip - * @param {number} L - number of newlines to skip - * @param {boolean} includeInSplice - indicates if attributes are present - */ - skip(N, L, includeInSplice) { - if (!N) - return; - if (L) { - this.skipLines(L, includeInSplice); - } - else { - if (includeInSplice && !this._inSplice) - this._enterSplice(); - if (this._inSplice) { - // although the line is put into splice curLine is not increased, because - // only some chars are skipped, not the whole line - this._putCurLineInSplice(); - } - this._curCol += N; - } - } - /** - * Remove whole lines from lines array. - * - * @param {number} L - number of lines to remove - * @returns {string} - */ - removeLines(L) { - if (!L) - return ''; - if (!this._inSplice) - this._enterSplice(); /** - * Gets a string of joined lines after the end of the splice. + * Return a slice from `lines`. * - * @param {number} k - number of lines - * @returns {string} joined lines + * @param {number} start - the start index + * @param {number} end - the end index + * @returns {string[]} */ - const nextKLinesText = (k) => { - const m = this._curSplice[0] + this._curSplice[1]; - return this._linesSlice(m, m + k).join(''); - }; - let removed = ''; - if (this._isCurLineInSplice()) { - if (this._curCol === 0) { - removed = this._curSplice[this._curSplice.length - 1]; - this._curSplice.length--; - removed += nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - } - else { - removed = nextKLinesText(L - 1); - this._curSplice[1] += L - 1; - const sline = this._curSplice.length - 1; - removed = this._curSplice[sline].substring(this._curCol) + removed; + _linesSlice(start, end) { + // can be unimplemented if removeLines's return value not needed + if (this._lines.slice) { + return this._lines.slice(start, end); + } + else { + return []; + } + } + /** + * Return the length of `lines`. + * + * @returns {number} + */ + _linesLength() { + if (typeof this._lines.length === 'number') { + return this._lines.length; + } + else { + return this._lines.length(); + } + } + /** + * Starts a new splice. + */ + _enterSplice() { + this._curSplice[0] = this._curLine; + this._curSplice[1] = 0; + // TODO(doc) when is this the case? + // check all enterSplice calls and changes to curCol + if (this._curCol > 0) + this._putCurLineInSplice(); + this._inSplice = true; + } + /** + * Changes the lines array according to the values in curSplice and resets curSplice. Called via + * close or TODO(doc). + */ + _leaveSplice() { + this._lines.splice(...this._curSplice); + this._curSplice.length = 2; + this._curSplice[0] = this._curSplice[1] = 0; + this._inSplice = false; + } + /** + * Indicates if curLine is already in the splice. This is necessary because the last element in + * curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting). + * + * @returns {boolean} true if curLine is in splice + */ + _isCurLineInSplice() { + // The value of `this._curSplice[1]` does not matter when determining the return value because + // `this._curLine` refers to the line number *after* the splice is applied (so after those lines + // are deleted). + return this._curLine - this._curSplice[0] < this._curSplice.length - 2; + } + /** + * Incorporates current line into the splice and marks its old position to be deleted. + * + * @returns {number} the index of the added line in curSplice + */ + _putCurLineInSplice() { + if (!this._isCurLineInSplice()) { + this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1])); + this._curSplice[1]++; + } + // TODO should be the same as this._curSplice.length - 1 + return 2 + this._curLine - this._curSplice[0]; + } + /** + * It will skip some newlines by putting them into the splice. + * + * @param {number} L - + * @param {boolean} includeInSplice - Indicates that attributes are present. + */ + skipLines(L, includeInSplice) { + if (!L) + return; + if (includeInSplice) { + if (!this._inSplice) + this._enterSplice(); + // TODO(doc) should this count the number of characters that are skipped to check? + for (let i = 0; i < L; i++) { + this._curCol = 0; + this._putCurLineInSplice(); + this._curLine++; + } + } + else { + if (this._inSplice) { + if (L > 1) { + // TODO(doc) figure out why single lines are incorporated into splice instead of ignored + this._leaveSplice(); + } + else { + this._putCurLineInSplice(); + } + } + this._curLine += L; + this._curCol = 0; + } + // tests case foo in remove(), which isn't otherwise covered in current impl + } + /** + * Skip some characters. Can contain newlines. + * + * @param {number} N - number of characters to skip + * @param {number} L - number of newlines to skip + * @param {boolean} includeInSplice - indicates if attributes are present + */ + skip(N, L, includeInSplice) { + if (!N) + return; + if (L) { + this.skipLines(L, includeInSplice); + } + else { + if (includeInSplice && !this._inSplice) + this._enterSplice(); + if (this._inSplice) { + // although the line is put into splice curLine is not increased, because + // only some chars are skipped, not the whole line + this._putCurLineInSplice(); + } + this._curCol += N; + } + } + /** + * Remove whole lines from lines array. + * + * @param {number} L - number of lines to remove + * @returns {string} + */ + removeLines(L) { + if (!L) + return ''; + if (!this._inSplice) + this._enterSplice(); + /** + * Gets a string of joined lines after the end of the splice. + * + * @param {number} k - number of lines + * @returns {string} joined lines + */ + const nextKLinesText = (k) => { + const m = this._curSplice[0] + this._curSplice[1]; + return this._linesSlice(m, m + k).join(''); + }; + let removed = ''; + if (this._isCurLineInSplice()) { + if (this._curCol === 0) { + removed = this._curSplice[this._curSplice.length - 1]; + this._curSplice.length--; + removed += nextKLinesText(L - 1); + this._curSplice[1] += L - 1; + } + else { + removed = nextKLinesText(L - 1); + this._curSplice[1] += L - 1; + const sline = this._curSplice.length - 1; + removed = this._curSplice[sline].substring(this._curCol) + removed; + this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + + this._linesGet(this._curSplice[0] + this._curSplice[1]); + this._curSplice[1] += 1; + } + } + else { + removed = nextKLinesText(L); + this._curSplice[1] += L; + } + return removed; + } + /** + * Remove text from lines array. + * + * @param {number} N - characters to delete + * @param {number} L - lines to delete + * @returns {string} + */ + remove(N, L) { + if (!N) + return ''; + if (L) + return this.removeLines(L); + if (!this._inSplice) + this._enterSplice(); + // although the line is put into splice, curLine is not increased, because + // only some chars are removed not the whole line + const sline = this._putCurLineInSplice(); + const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N); this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._linesGet(this._curSplice[0] + this._curSplice[1]); - this._curSplice[1] += 1; - } + this._curSplice[sline].substring(this._curCol + N); + return removed; } - else { - removed = nextKLinesText(L); - this._curSplice[1] += L; + /** + * Inserts text into lines array. + * + * @param {string} text - the text to insert + * @param {number} L - number of newlines in text + */ + insert(text, L) { + if (!text) + return; + if (!this._inSplice) + this._enterSplice(); + if (L) { + const newLines = exports.splitTextLines(text); + if (this._isCurLineInSplice()) { + const sline = this._curSplice.length - 1; + /** @type {string} */ + const theLine = this._curSplice[sline]; + const lineCol = this._curCol; + // Insert the chars up to `curCol` and the first new line. + this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + this._curLine++; + newLines.splice(0, 1); + // insert the remaining new lines + this._curSplice.push(...newLines); + this._curLine += newLines.length; + // insert the remaining chars from the "old" line (e.g. the line we were in + // when we started to insert new lines) + this._curSplice.push(theLine.substring(lineCol)); + this._curCol = 0; // TODO(doc) why is this not set to the length of last line? + } + else { + this._curSplice.push(...newLines); + this._curLine += newLines.length; + } + } + else { + // There are no additional lines. Although the line is put into splice, curLine is not + // increased because there may be more chars in the line (newline is not reached). + const sline = this._putCurLineInSplice(); + if (!this._curSplice[sline]) { + const err = new Error('curSplice[sline] not populated, actual curSplice contents is ' + + `${JSON.stringify(this._curSplice)}. Possibly related to ` + + 'https://github.com/ether/etherpad-lite/issues/2802'); + console.error(err.stack || err.toString()); + } + this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text + + this._curSplice[sline].substring(this._curCol); + this._curCol += text.length; + } } - return removed; - } - /** - * Remove text from lines array. - * - * @param {number} N - characters to delete - * @param {number} L - lines to delete - * @returns {string} - */ - remove(N, L) { - if (!N) - return ''; - if (L) - return this.removeLines(L); - if (!this._inSplice) - this._enterSplice(); - // although the line is put into splice, curLine is not increased, because - // only some chars are removed not the whole line - const sline = this._putCurLineInSplice(); - const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N); - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + - this._curSplice[sline].substring(this._curCol + N); - return removed; - } - /** - * Inserts text into lines array. - * - * @param {string} text - the text to insert - * @param {number} L - number of newlines in text - */ - insert(text, L) { - if (!text) - return; - if (!this._inSplice) - this._enterSplice(); - if (L) { - const newLines = exports.splitTextLines(text); - if (this._isCurLineInSplice()) { - const sline = this._curSplice.length - 1; - /** @type {string} */ - const theLine = this._curSplice[sline]; - const lineCol = this._curCol; - // Insert the chars up to `curCol` and the first new line. - this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; - this._curLine++; - newLines.splice(0, 1); - // insert the remaining new lines - this._curSplice.push(...newLines); - this._curLine += newLines.length; - // insert the remaining chars from the "old" line (e.g. the line we were in - // when we started to insert new lines) - this._curSplice.push(theLine.substring(lineCol)); - this._curCol = 0; // TODO(doc) why is this not set to the length of last line? - } - else { - this._curSplice.push(...newLines); - this._curLine += newLines.length; - } + /** + * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. + * + * @returns {boolean} indicates if there are lines left + */ + hasMore() { + let docLines = this._linesLength(); + if (this._inSplice) { + docLines += this._curSplice.length - 2 - this._curSplice[1]; + } + return this._curLine < docLines; } - else { - // There are no additional lines. Although the line is put into splice, curLine is not - // increased because there may be more chars in the line (newline is not reached). - const sline = this._putCurLineInSplice(); - if (!this._curSplice[sline]) { - const err = new Error('curSplice[sline] not populated, actual curSplice contents is ' + - `${JSON.stringify(this._curSplice)}. Possibly related to ` + - 'https://github.com/ether/etherpad-lite/issues/2802'); - console.error(err.stack || err.toString()); - } - this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text + - this._curSplice[sline].substring(this._curCol); - this._curCol += text.length; + /** + * Closes the splice + */ + close() { + if (this._inSplice) + this._leaveSplice(); } - } - /** - * Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`. - * - * @returns {boolean} indicates if there are lines left - */ - hasMore() { - let docLines = this._linesLength(); - if (this._inSplice) { - docLines += this._curSplice.length - 2 - this._curSplice[1]; - } - return this._curLine < docLines; - } - /** - * Closes the splice - */ - close() { - if (this._inSplice) - this._leaveSplice(); - } } /** * Apply operations to other operations. @@ -573,28 +573,28 @@ class TextLinesMutator { * @returns {string} the integrated changeset */ const applyZip = (in1, in2, func) => { - const ops1 = exports.deserializeOps(in1); - const ops2 = exports.deserializeOps(in2); - let next1 = ops1.next(); - let next2 = ops2.next(); - const assem = exports.smartOpAssembler(); - while (!next1.done || !next2.done) { - if (!next1.done && !next1.value.opcode) - next1 = ops1.next(); - if (!next2.done && !next2.value.opcode) - next2 = ops2.next(); - if (next1.value == null) - next1.value = new Op(); - if (next2.value == null) - next2.value = new Op(); - if (!next1.value.opcode && !next2.value.opcode) - break; - const opOut = func(next1.value, next2.value); - if (opOut && opOut.opcode) - assem.append(opOut); - } - assem.endDocument(); - return assem.toString(); + const ops1 = exports.deserializeOps(in1); + const ops2 = exports.deserializeOps(in2); + let next1 = ops1.next(); + let next2 = ops2.next(); + const assem = exports.smartOpAssembler(); + while (!next1.done || !next2.done) { + if (!next1.done && !next1.value.opcode) + next1 = ops1.next(); + if (!next2.done && !next2.value.opcode) + next2 = ops2.next(); + if (next1.value == null) + next1.value = new Op(); + if (next2.value == null) + next2.value = new Op(); + if (!next1.value.opcode && !next2.value.opcode) + break; + const opOut = func(next1.value, next2.value); + if (opOut && opOut.opcode) + assem.append(opOut); + } + assem.endDocument(); + return assem.toString(); }; /** * Function used as parameter for applyZip to apply a Changeset to an attribute. @@ -606,59 +606,59 @@ const applyZip = (in1, in2, func) => { * @returns {Op} The result of applying `csOp` to `attOp`. */ const slicerZipperFunc = (attOp, csOp, pool) => { - const opOut = new Op(); - if (!attOp.opcode) { - copyOp(csOp, opOut); - csOp.opcode = ''; - } - else if (!csOp.opcode) { - copyOp(attOp, opOut); - attOp.opcode = ''; - } - else if (attOp.opcode === '-') { - copyOp(attOp, opOut); - attOp.opcode = ''; - } - else if (csOp.opcode === '+') { - copyOp(csOp, opOut); - csOp.opcode = ''; - } - else { - for (const op of [attOp, csOp]) { - assert(op.chars >= op.lines, `op has more newlines than chars: ${op.toString()}`); + const opOut = new Op(); + if (!attOp.opcode) { + copyOp(csOp, opOut); + csOp.opcode = ''; } - assert(attOp.chars < csOp.chars ? attOp.lines <= csOp.lines - : attOp.chars > csOp.chars ? attOp.lines >= csOp.lines - : attOp.lines === csOp.lines, 'line count mismatch when composing changesets A*B; ' + - `opA: ${attOp.toString()} opB: ${csOp.toString()}`); - assert(['+', '='].includes(attOp.opcode), `unexpected opcode in op: ${attOp.toString()}`); - assert(['-', '='].includes(csOp.opcode), `unexpected opcode in op: ${csOp.toString()}`); - opOut.opcode = { - '+': { - '-': '', - '=': '+', - }, - '=': { - '-': '-', - '=': '=', - }, - }[attOp.opcode][csOp.opcode]; - const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars); - opOut.chars = fullyConsumedOp.chars; - opOut.lines = fullyConsumedOp.lines; - opOut.attribs = csOp.opcode === '-' - // csOp is a remove op and remove ops normally never have any attributes, so this should - // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs - // them preserved so they are copied here. - ? csOp.attribs - : exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); - partiallyConsumedOp.chars -= fullyConsumedOp.chars; - partiallyConsumedOp.lines -= fullyConsumedOp.lines; - if (!partiallyConsumedOp.chars) - partiallyConsumedOp.opcode = ''; - fullyConsumedOp.opcode = ''; - } - return opOut; + else if (!csOp.opcode) { + copyOp(attOp, opOut); + attOp.opcode = ''; + } + else if (attOp.opcode === '-') { + copyOp(attOp, opOut); + attOp.opcode = ''; + } + else if (csOp.opcode === '+') { + copyOp(csOp, opOut); + csOp.opcode = ''; + } + else { + for (const op of [attOp, csOp]) { + assert(op.chars >= op.lines, `op has more newlines than chars: ${op.toString()}`); + } + assert(attOp.chars < csOp.chars ? attOp.lines <= csOp.lines + : attOp.chars > csOp.chars ? attOp.lines >= csOp.lines + : attOp.lines === csOp.lines, 'line count mismatch when composing changesets A*B; ' + + `opA: ${attOp.toString()} opB: ${csOp.toString()}`); + assert(['+', '='].includes(attOp.opcode), `unexpected opcode in op: ${attOp.toString()}`); + assert(['-', '='].includes(csOp.opcode), `unexpected opcode in op: ${csOp.toString()}`); + opOut.opcode = { + '+': { + '-': '', + '=': '+', + }, + '=': { + '-': '-', + '=': '=', + }, + }[attOp.opcode][csOp.opcode]; + const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars); + opOut.chars = fullyConsumedOp.chars; + opOut.lines = fullyConsumedOp.lines; + opOut.attribs = csOp.opcode === '-' + // csOp is a remove op and remove ops normally never have any attributes, so this should + // normally be the empty string. However, padDiff.js adds attributes to remove ops and needs + // them preserved so they are copied here. + ? csOp.attribs + : exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool); + partiallyConsumedOp.chars -= fullyConsumedOp.chars; + partiallyConsumedOp.lines -= fullyConsumedOp.lines; + if (!partiallyConsumedOp.chars) + partiallyConsumedOp.opcode = ''; + fullyConsumedOp.opcode = ''; + } + return opOut; }; /** * Transforms a changeset into a list of splices in the form [startChar, endChar, newText] meaning @@ -668,1296 +668,1296 @@ const slicerZipperFunc = (attOp, csOp, pool) => { * @returns {[number, number, string][]} */ const toSplices = (cs) => { - const unpacked = exports.unpack(cs); - /** @type {[number, number, string][]} */ - const splices = []; - let oldPos = 0; - const charIter = exports.stringIterator(unpacked.charBank); - let inSplice = false; - for (const op of exports.deserializeOps(unpacked.ops)) { - if (op.opcode === '=') { - oldPos += op.chars; - inSplice = false; + const unpacked = exports.unpack(cs); + /** @type {[number, number, string][]} */ + const splices = []; + let oldPos = 0; + const charIter = exports.stringIterator(unpacked.charBank); + let inSplice = false; + for (const op of exports.deserializeOps(unpacked.ops)) { + if (op.opcode === '=') { + oldPos += op.chars; + inSplice = false; + } + else { + if (!inSplice) { + splices.push([oldPos, oldPos, '']); + inSplice = true; + } + if (op.opcode === '-') { + oldPos += op.chars; + splices[splices.length - 1][1] += op.chars; + } + else if (op.opcode === '+') { + splices[splices.length - 1][2] += charIter.take(op.chars); + } + } } - else { - if (!inSplice) { - splices.push([oldPos, oldPos, '']); - inSplice = true; - } - if (op.opcode === '-') { - oldPos += op.chars; - splices[splices.length - 1][1] += op.chars; - } - else if (op.opcode === '+') { - splices[splices.length - 1][2] += charIter.take(op.chars); - } - } - } - return splices; + return splices; }; /** * @deprecated Use an AttributeMap instead. */ const attribsAttributeValue = (attribs, key, pool) => { - if (!attribs) + if (!attribs) + return ''; + for (const [k, v] of attributes.attribsFromString(attribs, pool)) { + if (k === key) + return v; + } return ''; - for (const [k, v] of attributes.attribsFromString(attribs, pool)) { - if (k === key) - return v; - } - return ''; }; const followAttributes = (att1, att2, pool) => { - // The merge of two sets of attribute changes to the same text - // takes the lexically-earlier value if there are two values - // for the same key. Otherwise, all key/value changes from - // both attribute sets are taken. This operation is the "follow", - // so a set of changes is produced that can be applied to att1 - // to produce the merged set. - if ((!att2) || (!pool)) - return ''; - if (!att1) - return att2; - const atts = new Map(); - att2.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); - atts.set(key, val); - return ''; - }); - att1.replace(/\*([0-9a-z]+)/g, (_, a) => { - const [key, val] = pool.getAttrib(exports.parseNum(a)); - if (atts.has(key) && val <= atts.get(key)) - atts.delete(key); - return ''; - }); - // we've only removed attributes, so they're already sorted - const buf = exports.stringAssembler(); - for (const att of atts) { - buf.append('*'); - buf.append(exports.numToString(pool.putAttrib(att))); - } - return buf.toString(); + // The merge of two sets of attribute changes to the same text + // takes the lexically-earlier value if there are two values + // for the same key. Otherwise, all key/value changes from + // both attribute sets are taken. This operation is the "follow", + // so a set of changes is produced that can be applied to att1 + // to produce the merged set. + if ((!att2) || (!pool)) + return ''; + if (!att1) + return att2; + const atts = new Map(); + att2.replace(/\*([0-9a-z]+)/g, (_, a) => { + const [key, val] = pool.getAttrib(exports.parseNum(a)); + atts.set(key, val); + return ''; + }); + att1.replace(/\*([0-9a-z]+)/g, (_, a) => { + const [key, val] = pool.getAttrib(exports.parseNum(a)); + if (atts.has(key) && val <= atts.get(key)) + atts.delete(key); + return ''; + }); + // we've only removed attributes, so they're already sorted + const buf = exports.stringAssembler(); + for (const att of atts) { + buf.append('*'); + buf.append(exports.numToString(pool.putAttrib(att))); + } + return buf.toString(); }; export const parseNum = (str) => parseInt(str, 36); export const numToString = (num) => num.toString(36).toLowerCase(); export const oldLen = (cs) => exports.unpack(cs).oldLen; export const newLen = (cs) => exports.unpack(cs).newLen; export const deserializeOps = function* (ops) { - // TODO: Migrate to String.prototype.matchAll() once there is enough browser support. - const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g; - let match; - while ((match = regex.exec(ops)) != null) { - if (match[5] === '$') - return; // Start of the insert operation character bank. - if (match[5] != null) - error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`); - const op = new Op(match[3]); - op.lines = exports.parseNum(match[2] || '0'); - op.chars = exports.parseNum(match[4]); - op.attribs = match[1]; - yield op; - } + // TODO: Migrate to String.prototype.matchAll() once there is enough browser support. + const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g; + let match; + while ((match = regex.exec(ops)) != null) { + if (match[5] === '$') + return; // Start of the insert operation character bank. + if (match[5] != null) + error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`); + const op = new Op(match[3]); + op.lines = exports.parseNum(match[2] || '0'); + op.chars = exports.parseNum(match[4]); + op.attribs = match[1]; + yield op; + } }; export const opIterator = (opsStr) => { - padutils.warnDeprecated('Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead'); - return new OpIter(opsStr); + padutils.warnDeprecated('Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead'); + return new OpIter(opsStr); }; export const newOp = (optOpcode) => { - padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead'); - return new Op(optOpcode); + padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead'); + return new Op(optOpcode); }; export const checkRep = (cs) => { - const unpacked = exports.unpack(cs); - const oldLen = unpacked.oldLen; - const newLen = unpacked.newLen; - const ops = unpacked.ops; - let charBank = unpacked.charBank; - const assem = exports.smartOpAssembler(); - let oldPos = 0; - let calcNewLen = 0; - for (const o of exports.deserializeOps(ops)) { - switch (o.opcode) { - case '=': - oldPos += o.chars; - calcNewLen += o.chars; - break; - case '-': - oldPos += o.chars; - assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`); - break; - case '+': - { - assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank'); - const chars = charBank.slice(0, o.chars); - const nlines = (chars.match(/\n/g) || []).length; - assert(nlines === o.lines, 'Invalid changeset: number of newlines in insert op does not match the charBank'); - assert(o.lines === 0 || chars.endsWith('\n'), 'Invalid changeset: multiline insert op does not end with a newline'); - charBank = charBank.slice(o.chars); - calcNewLen += o.chars; - assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`); - break; - } - default: - assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`); + const unpacked = exports.unpack(cs); + const oldLen = unpacked.oldLen; + const newLen = unpacked.newLen; + const ops = unpacked.ops; + let charBank = unpacked.charBank; + const assem = exports.smartOpAssembler(); + let oldPos = 0; + let calcNewLen = 0; + for (const o of exports.deserializeOps(ops)) { + switch (o.opcode) { + case '=': + oldPos += o.chars; + calcNewLen += o.chars; + break; + case '-': + oldPos += o.chars; + assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`); + break; + case '+': + { + assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank'); + const chars = charBank.slice(0, o.chars); + const nlines = (chars.match(/\n/g) || []).length; + assert(nlines === o.lines, 'Invalid changeset: number of newlines in insert op does not match the charBank'); + assert(o.lines === 0 || chars.endsWith('\n'), 'Invalid changeset: multiline insert op does not end with a newline'); + charBank = charBank.slice(o.chars); + calcNewLen += o.chars; + assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`); + break; + } + default: + assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`); + } + assem.append(o); } - assem.append(o); - } - calcNewLen += oldLen - oldPos; - assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); - assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); - assem.endDocument(); - const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); - assert(normalized === cs, 'Invalid changeset: not in canonical form'); - return cs; + calcNewLen += oldLen - oldPos; + assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length'); + assert(charBank === '', 'Invalid changeset: excess characters in the charBank'); + assem.endDocument(); + const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank); + assert(normalized === cs, 'Invalid changeset: not in canonical form'); + return cs; }; export const smartOpAssembler = () => { - const minusAssem = exports.mergingOpAssembler(); - const plusAssem = exports.mergingOpAssembler(); - const keepAssem = exports.mergingOpAssembler(); - const assem = exports.stringAssembler(); - let lastOpcode = ''; - let lengthChange = 0; - const flushKeeps = () => { - assem.append(keepAssem.toString()); - keepAssem.clear(); - }; - const flushPlusMinus = () => { - assem.append(minusAssem.toString()); - minusAssem.clear(); - assem.append(plusAssem.toString()); - plusAssem.clear(); - }; - const append = (op) => { - if (!op.opcode) - return; - if (!op.chars) - return; - if (op.opcode === '-') { - if (lastOpcode === '=') { - flushKeeps(); - } - minusAssem.append(op); - lengthChange -= op.chars; - } - else if (op.opcode === '+') { - if (lastOpcode === '=') { - flushKeeps(); - } - plusAssem.append(op); - lengthChange += op.chars; - } - else if (op.opcode === '=') { - if (lastOpcode !== '=') { + const minusAssem = exports.mergingOpAssembler(); + const plusAssem = exports.mergingOpAssembler(); + const keepAssem = exports.mergingOpAssembler(); + const assem = exports.stringAssembler(); + let lastOpcode = ''; + let lengthChange = 0; + const flushKeeps = () => { + assem.append(keepAssem.toString()); + keepAssem.clear(); + }; + const flushPlusMinus = () => { + assem.append(minusAssem.toString()); + minusAssem.clear(); + assem.append(plusAssem.toString()); + plusAssem.clear(); + }; + const append = (op) => { + if (!op.opcode) + return; + if (!op.chars) + return; + if (op.opcode === '-') { + if (lastOpcode === '=') { + flushKeeps(); + } + minusAssem.append(op); + lengthChange -= op.chars; + } + else if (op.opcode === '+') { + if (lastOpcode === '=') { + flushKeeps(); + } + plusAssem.append(op); + lengthChange += op.chars; + } + else if (op.opcode === '=') { + if (lastOpcode !== '=') { + flushPlusMinus(); + } + keepAssem.append(op); + } + lastOpcode = op.opcode; + }; + /** + * Generates operations from the given text and attributes. + * + * @deprecated Use `opsFromText` instead. + * @param {('-'|'+'|'=')} opcode - The operator to use. + * @param {string} text - The text to remove/add/keep. + * @param {(string|Iterable)} attribs - The attributes to apply to the operations. + * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of + * attribute key, value pairs. + */ + const appendOpWithText = (opcode, text, attribs, pool) => { + padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + + 'use opsFromText() instead.'); + for (const op of opsFromText(opcode, text, attribs, pool)) + append(op); + }; + const toString = () => { flushPlusMinus(); - } - keepAssem.append(op); - } - lastOpcode = op.opcode; - }; - /** - * Generates operations from the given text and attributes. - * - * @deprecated Use `opsFromText` instead. - * @param {('-'|'+'|'=')} opcode - The operator to use. - * @param {string} text - The text to remove/add/keep. - * @param {(string|Iterable)} attribs - The attributes to apply to the operations. - * @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of - * attribute key, value pairs. - */ - const appendOpWithText = (opcode, text, attribs, pool) => { - padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' + - 'use opsFromText() instead.'); - for (const op of opsFromText(opcode, text, attribs, pool)) - append(op); - }; - const toString = () => { - flushPlusMinus(); - flushKeeps(); - return assem.toString(); - }; - const clear = () => { - minusAssem.clear(); - plusAssem.clear(); - keepAssem.clear(); - assem.clear(); - lengthChange = 0; - }; - const endDocument = () => { - keepAssem.endDocument(); - }; - const getLengthChange = () => lengthChange; - return { - append, - toString, - clear, - endDocument, - appendOpWithText, - getLengthChange, - }; + flushKeeps(); + return assem.toString(); + }; + const clear = () => { + minusAssem.clear(); + plusAssem.clear(); + keepAssem.clear(); + assem.clear(); + lengthChange = 0; + }; + const endDocument = () => { + keepAssem.endDocument(); + }; + const getLengthChange = () => lengthChange; + return { + append, + toString, + clear, + endDocument, + appendOpWithText, + getLengthChange, + }; }; export const mergingOpAssembler = () => { - const assem = exports.opAssembler(); - const bufOp = new Op(); - // If we get, for example, insertions [xxx\n,yyy], those don't merge, - // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. - // This variable stores the length of yyy and any other newline-less - // ops immediately after it. - let bufOpAdditionalCharsAfterNewline = 0; - /** - * @param {boolean} [isEndDocument] - */ - const flush = (isEndDocument) => { - if (!bufOp.opcode) - return; - if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { - // final merged keep, leave it implicit - } - else { - assem.append(bufOp); - if (bufOpAdditionalCharsAfterNewline) { - bufOp.chars = bufOpAdditionalCharsAfterNewline; - bufOp.lines = 0; - assem.append(bufOp); - bufOpAdditionalCharsAfterNewline = 0; - } - } - bufOp.opcode = ''; - }; - const append = (op) => { - if (op.chars <= 0) - return; - if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { - if (op.lines > 0) { - // bufOp and additional chars are all mergeable into a multi-line op - bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; - bufOp.lines += op.lines; - bufOpAdditionalCharsAfterNewline = 0; - } - else if (bufOp.lines === 0) { - // both bufOp and op are in-line - bufOp.chars += op.chars; - } - else { - // append in-line text to multi-line bufOp - bufOpAdditionalCharsAfterNewline += op.chars; - } - } - else { - flush(); - copyOp(op, bufOp); - } - }; - const endDocument = () => { - flush(true); - }; - const toString = () => { - flush(); - return assem.toString(); - }; - const clear = () => { - assem.clear(); - clearOp(bufOp); - }; - return { - append, - toString, - clear, - endDocument, - }; + const assem = exports.opAssembler(); + const bufOp = new Op(); + // If we get, for example, insertions [xxx\n,yyy], those don't merge, + // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. + // This variable stores the length of yyy and any other newline-less + // ops immediately after it. + let bufOpAdditionalCharsAfterNewline = 0; + /** + * @param {boolean} [isEndDocument] + */ + const flush = (isEndDocument) => { + if (!bufOp.opcode) + return; + if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) { + // final merged keep, leave it implicit + } + else { + assem.append(bufOp); + if (bufOpAdditionalCharsAfterNewline) { + bufOp.chars = bufOpAdditionalCharsAfterNewline; + bufOp.lines = 0; + assem.append(bufOp); + bufOpAdditionalCharsAfterNewline = 0; + } + } + bufOp.opcode = ''; + }; + const append = (op) => { + if (op.chars <= 0) + return; + if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; + bufOp.lines += op.lines; + bufOpAdditionalCharsAfterNewline = 0; + } + else if (bufOp.lines === 0) { + // both bufOp and op are in-line + bufOp.chars += op.chars; + } + else { + // append in-line text to multi-line bufOp + bufOpAdditionalCharsAfterNewline += op.chars; + } + } + else { + flush(); + copyOp(op, bufOp); + } + }; + const endDocument = () => { + flush(true); + }; + const toString = () => { + flush(); + return assem.toString(); + }; + const clear = () => { + assem.clear(); + clearOp(bufOp); + }; + return { + append, + toString, + clear, + endDocument, + }; }; export const opAssembler = () => { - let serialized = ''; - /** - * @param {Op} op - Operation to add. Ownership remains with the caller. - */ - const append = (op) => { - assert(op instanceof Op, 'argument must be an instance of Op'); - serialized += op.toString(); - }; - const toString = () => serialized; - const clear = () => { - serialized = ''; - }; - return { - append, - toString, - clear, - }; + let serialized = ''; + /** + * @param {Op} op - Operation to add. Ownership remains with the caller. + */ + const append = (op) => { + assert(op instanceof Op, 'argument must be an instance of Op'); + serialized += op.toString(); + }; + const toString = () => serialized; + const clear = () => { + serialized = ''; + }; + return { + append, + toString, + clear, + }; }; export const stringIterator = (str) => { - let curIndex = 0; - // newLines is the number of \n between curIndex and str.length - let newLines = str.split('\n').length - 1; - const getnewLines = () => newLines; - const assertRemaining = (n) => { - assert(n <= remaining(), `!(${n} <= ${remaining()})`); - }; - const take = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - newLines -= s.split('\n').length - 1; - curIndex += n; - return s; - }; - const peek = (n) => { - assertRemaining(n); - const s = str.substr(curIndex, n); - return s; - }; - const skip = (n) => { - assertRemaining(n); - curIndex += n; - }; - const remaining = () => str.length - curIndex; - return { - take, - skip, - remaining, - peek, - newlines: getnewLines, - }; + let curIndex = 0; + // newLines is the number of \n between curIndex and str.length + let newLines = str.split('\n').length - 1; + const getnewLines = () => newLines; + const assertRemaining = (n) => { + assert(n <= remaining(), `!(${n} <= ${remaining()})`); + }; + const take = (n) => { + assertRemaining(n); + const s = str.substr(curIndex, n); + newLines -= s.split('\n').length - 1; + curIndex += n; + return s; + }; + const peek = (n) => { + assertRemaining(n); + const s = str.substr(curIndex, n); + return s; + }; + const skip = (n) => { + assertRemaining(n); + curIndex += n; + }; + const remaining = () => str.length - curIndex; + return { + take, + skip, + remaining, + peek, + newlines: getnewLines, + }; }; export const stringAssembler = () => ({ - _str: '', - clear() { this._str = ''; }, - /** - * @param {string} x - - */ - append(x) { this._str += String(x); }, - toString() { return this._str; }, + _str: '', + clear() { this._str = ''; }, + /** + * @param {string} x - + */ + append(x) { this._str += String(x); }, + toString() { return this._str; }, }); export const unpack = (cs) => { - const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; - const headerMatch = headerRegex.exec(cs); - if ((!headerMatch) || (!headerMatch[0])) - error(`Not a changeset: ${cs}`); - const oldLen = exports.parseNum(headerMatch[1]); - const changeSign = (headerMatch[2] === '>') ? 1 : -1; - const changeMag = exports.parseNum(headerMatch[3]); - const newLen = oldLen + changeSign * changeMag; - const opsStart = headerMatch[0].length; - let opsEnd = cs.indexOf('$'); - if (opsEnd < 0) - opsEnd = cs.length; - return { - oldLen, - newLen, - ops: cs.substring(opsStart, opsEnd), - charBank: cs.substring(opsEnd + 1), - }; + const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; + const headerMatch = headerRegex.exec(cs); + if ((!headerMatch) || (!headerMatch[0])) + error(`Not a changeset: ${cs}`); + const oldLen = exports.parseNum(headerMatch[1]); + const changeSign = (headerMatch[2] === '>') ? 1 : -1; + const changeMag = exports.parseNum(headerMatch[3]); + const newLen = oldLen + changeSign * changeMag; + const opsStart = headerMatch[0].length; + let opsEnd = cs.indexOf('$'); + if (opsEnd < 0) + opsEnd = cs.length; + return { + oldLen, + newLen, + ops: cs.substring(opsStart, opsEnd), + charBank: cs.substring(opsEnd + 1), + }; }; export const pack = (oldLen, newLen, opsStr, bank) => { - const lenDiff = newLen - oldLen; - const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` - : `<${exports.numToString(-lenDiff)}`); - const a = []; - a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); - return a.join(''); + const lenDiff = newLen - oldLen; + const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}` + : `<${exports.numToString(-lenDiff)}`); + const a = []; + a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); + return a.join(''); }; export const applyToText = (cs, str) => { - const unpacked = exports.unpack(cs); - assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); - const bankIter = exports.stringIterator(unpacked.charBank); - const strIter = exports.stringIterator(str); - const assem = exports.stringAssembler(); - for (const op of exports.deserializeOps(unpacked.ops)) { - switch (op.opcode) { - case '+': - // op is + and op.lines 0: no newlines must be in op.chars - // op is + and op.lines >0: op.chars must include op.lines newlines - if (op.lines !== bankIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`); + const unpacked = exports.unpack(cs); + assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`); + const bankIter = exports.stringIterator(unpacked.charBank); + const strIter = exports.stringIterator(str); + const assem = exports.stringAssembler(); + for (const op of exports.deserializeOps(unpacked.ops)) { + switch (op.opcode) { + case '+': + // op is + and op.lines 0: no newlines must be in op.chars + // op is + and op.lines >0: op.chars must include op.lines newlines + if (op.lines !== bankIter.peek(op.chars).split('\n').length - 1) { + throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`); + } + assem.append(bankIter.take(op.chars)); + break; + case '-': + // op is - and op.lines 0: no newlines must be in the deleted string + // op is - and op.lines >0: op.lines newlines must be in the deleted string + if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { + throw new Error(`newline count is wrong in op -; cs:${cs} and text:${str}`); + } + strIter.skip(op.chars); + break; + case '=': + // op is = and op.lines 0: no newlines must be in the copied string + // op is = and op.lines >0: op.lines newlines must be in the copied string + if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { + throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`); + } + assem.append(strIter.take(op.chars)); + break; } - assem.append(bankIter.take(op.chars)); - break; - case '-': - // op is - and op.lines 0: no newlines must be in the deleted string - // op is - and op.lines >0: op.lines newlines must be in the deleted string - if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op -; cs:${cs} and text:${str}`); - } - strIter.skip(op.chars); - break; - case '=': - // op is = and op.lines 0: no newlines must be in the copied string - // op is = and op.lines >0: op.lines newlines must be in the copied string - if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) { - throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`); - } - assem.append(strIter.take(op.chars)); - break; } - } - assem.append(strIter.take(strIter.remaining())); - return assem.toString(); + assem.append(strIter.take(strIter.remaining())); + return assem.toString(); }; export const mutateTextLines = (cs, lines) => { - const unpacked = exports.unpack(cs); - const bankIter = exports.stringIterator(unpacked.charBank); - const mut = new TextLinesMutator(lines); - for (const op of exports.deserializeOps(unpacked.ops)) { - switch (op.opcode) { - case '+': - mut.insert(bankIter.take(op.chars), op.lines); - break; - case '-': - mut.remove(op.chars, op.lines); - break; - case '=': - mut.skip(op.chars, op.lines, (!!op.attribs)); - break; + const unpacked = exports.unpack(cs); + const bankIter = exports.stringIterator(unpacked.charBank); + const mut = new TextLinesMutator(lines); + for (const op of exports.deserializeOps(unpacked.ops)) { + switch (op.opcode) { + case '+': + mut.insert(bankIter.take(op.chars), op.lines); + break; + case '-': + mut.remove(op.chars, op.lines); + break; + case '=': + mut.skip(op.chars, op.lines, (!!op.attribs)); + break; + } } - } - mut.close(); + mut.close(); }; export const composeAttributes = (att1, att2, resultIsMutation, pool) => { - // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. - // Sometimes attribute (key,value) pairs are treated as attribute presence - // information, while other times they are treated as operations that - // mutate a set of attributes, and this affects whether an empty value - // is a deletion or a change. - // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result - // ([], [(bold, )], true) -> [(bold, )] - // ([], [(bold, )], false) -> [] - // ([], [(bold, true)], true) -> [(bold, true)] - // ([], [(bold, true)], false) -> [(bold, true)] - // ([(bold, true)], [(bold, )], true) -> [(bold, )] - // ([(bold, true)], [(bold, )], false) -> [] - // pool can be null if att2 has no attributes. - if ((!att1) && resultIsMutation) { - // In the case of a mutation (i.e. composing two exportss), - // an att2 composed with an empy att1 is just att2. If att1 - // is part of an attribution string, then att2 may remove - // attributes that are already gone, so don't do this optimization. - return att2; - } - if (!att2) - return att1; - return AttributeMap.fromString(att1, pool).updateFromString(att2, !resultIsMutation).toString(); + // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. + // Sometimes attribute (key,value) pairs are treated as attribute presence + // information, while other times they are treated as operations that + // mutate a set of attributes, and this affects whether an empty value + // is a deletion or a change. + // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result + // ([], [(bold, )], true) -> [(bold, )] + // ([], [(bold, )], false) -> [] + // ([], [(bold, true)], true) -> [(bold, true)] + // ([], [(bold, true)], false) -> [(bold, true)] + // ([(bold, true)], [(bold, )], true) -> [(bold, )] + // ([(bold, true)], [(bold, )], false) -> [] + // pool can be null if att2 has no attributes. + if ((!att1) && resultIsMutation) { + // In the case of a mutation (i.e. composing two exportss), + // an att2 composed with an empy att1 is just att2. If att1 + // is part of an attribution string, then att2 may remove + // attributes that are already gone, so don't do this optimization. + return att2; + } + if (!att2) + return att1; + return AttributeMap.fromString(att1, pool).updateFromString(att2, !resultIsMutation).toString(); }; export const applyToAttribution = (cs, astr, pool) => { - const unpacked = exports.unpack(cs); - return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); + const unpacked = exports.unpack(cs); + return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool)); }; export const mutateAttributionLines = (cs, lines, pool) => { - const unpacked = exports.unpack(cs); - const csOps = exports.deserializeOps(unpacked.ops); - let csOpsNext = csOps.next(); - const csBank = unpacked.charBank; - let csBankIndex = 0; - // treat the attribution lines as text lines, mutating a line at a time - const mut = new TextLinesMutator(lines); - /** - * The Ops in the current line from `lines`. - * - * @type {?Generator} - */ - let lineOps = null; - let lineOpsNext = null; - const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; - /** - * Returns false if we are on the last attribute line in `lines` and there is no additional op in - * that line. - * - * @returns {boolean} True if there are more ops to go through. - */ - const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); - /** - * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to - * iterate over the next line, which is consumed from `mut`. If there are no more lines, - * returns a null Op. - */ - const nextMutOp = () => { - if (!lineOpsHasNext() && mut.hasMore()) { - // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is - // still null or there are no more ops in current `lineIter`. - const line = mut.removeLines(1); - lineOps = exports.deserializeOps(line); - lineOpsNext = lineOps.next(); + const unpacked = exports.unpack(cs); + const csOps = exports.deserializeOps(unpacked.ops); + let csOpsNext = csOps.next(); + const csBank = unpacked.charBank; + let csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + const mut = new TextLinesMutator(lines); + /** + * The Ops in the current line from `lines`. + * + * @type {?Generator} + */ + let lineOps = null; + let lineOpsNext = null; + const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done; + /** + * Returns false if we are on the last attribute line in `lines` and there is no additional op in + * that line. + * + * @returns {boolean} True if there are more ops to go through. + */ + const isNextMutOp = () => lineOpsHasNext() || mut.hasMore(); + /** + * @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to + * iterate over the next line, which is consumed from `mut`. If there are no more lines, + * returns a null Op. + */ + const nextMutOp = () => { + if (!lineOpsHasNext() && mut.hasMore()) { + // There are more attribute lines in `lines` to do AND either we just started so `lineIter` is + // still null or there are no more ops in current `lineIter`. + const line = mut.removeLines(1); + lineOps = exports.deserializeOps(line); + lineOpsNext = lineOps.next(); + } + if (!lineOpsHasNext()) + return new Op(); // No more ops and no more lines. + const op = lineOpsNext.value; + lineOpsNext = lineOps.next(); + return op; + }; + let lineAssem = null; + /** + * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the + * `lines` mutator. + */ + const outputMutOp = (op) => { + if (!lineAssem) { + lineAssem = exports.mergingOpAssembler(); + } + lineAssem.append(op); + if (op.lines <= 0) + return; + assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); + // ship it to the mut + mut.insert(lineAssem.toString(), 1); + lineAssem = null; + }; + let csOp = new Op(); + let attOp = new Op(); + while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { + if (!csOp.opcode && !csOpsNext.done) { + // coOp done, but more ops in cs. + csOp = csOpsNext.value; + csOpsNext = csOps.next(); + } + if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { + break; // done + } + else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && + !lineAssem && !lineOpsHasNext()) { + // Skip multiple lines without attributes; this is what makes small changes not order of the + // document size. + mut.skipLines(csOp.lines); + csOp.opcode = ''; + } + else if (csOp.opcode === '+') { + const opOut = copyOp(csOp); + if (csOp.lines > 1) { + // Copy the first line from `csOp` to `opOut`. + const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; + csOp.chars -= firstLineLen; + csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } + else { + // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. + csOp.opcode = ''; + } + outputMutOp(opOut); + csBankIndex += opOut.chars; + } + else { + if (!attOp.opcode && isNextMutOp()) + attOp = nextMutOp(); + const opOut = slicerZipperFunc(attOp, csOp, pool); + if (opOut.opcode) + outputMutOp(opOut); + } } - if (!lineOpsHasNext()) - return new Op(); // No more ops and no more lines. - const op = lineOpsNext.value; - lineOpsNext = lineOps.next(); - return op; - }; - let lineAssem = null; - /** - * Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the - * `lines` mutator. - */ - const outputMutOp = (op) => { - if (!lineAssem) { - lineAssem = exports.mergingOpAssembler(); - } - lineAssem.append(op); - if (op.lines <= 0) - return; - assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`); - // ship it to the mut - mut.insert(lineAssem.toString(), 1); - lineAssem = null; - }; - let csOp = new Op(); - let attOp = new Op(); - while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) { - if (!csOp.opcode && !csOpsNext.done) { - // coOp done, but more ops in cs. - csOp = csOpsNext.value; - csOpsNext = csOps.next(); - } - if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) { - break; // done - } - else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode && - !lineAssem && !lineOpsHasNext()) { - // Skip multiple lines without attributes; this is what makes small changes not order of the - // document size. - mut.skipLines(csOp.lines); - csOp.opcode = ''; - } - else if (csOp.opcode === '+') { - const opOut = copyOp(csOp); - if (csOp.lines > 1) { - // Copy the first line from `csOp` to `opOut`. - const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; - csOp.chars -= firstLineLen; - csOp.lines--; - opOut.lines = 1; - opOut.chars = firstLineLen; - } - else { - // Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`. - csOp.opcode = ''; - } - outputMutOp(opOut); - csBankIndex += opOut.chars; - } - else { - if (!attOp.opcode && isNextMutOp()) - attOp = nextMutOp(); - const opOut = slicerZipperFunc(attOp, csOp, pool); - if (opOut.opcode) - outputMutOp(opOut); - } - } - assert(!lineAssem, `line assembler not finished:${cs}`); - mut.close(); + assert(!lineAssem, `line assembler not finished:${cs}`); + mut.close(); }; export const joinAttributionLines = (theAlines) => { - const assem = exports.mergingOpAssembler(); - for (const aline of theAlines) { - for (const op of exports.deserializeOps(aline)) - assem.append(op); - } - return assem.toString(); + const assem = exports.mergingOpAssembler(); + for (const aline of theAlines) { + for (const op of exports.deserializeOps(aline)) + assem.append(op); + } + return assem.toString(); }; export const splitAttributionLines = (attrOps, text) => { - const assem = exports.mergingOpAssembler(); - const lines = []; - let pos = 0; - const appendOp = (op) => { - assem.append(op); - if (op.lines > 0) { - lines.push(assem.toString()); - assem.clear(); + const assem = exports.mergingOpAssembler(); + const lines = []; + let pos = 0; + const appendOp = (op) => { + assem.append(op); + if (op.lines > 0) { + lines.push(assem.toString()); + assem.clear(); + } + pos += op.chars; + }; + for (const op of exports.deserializeOps(attrOps)) { + let numChars = op.chars; + let numLines = op.lines; + while (numLines > 1) { + const newlineEnd = text.indexOf('\n', pos) + 1; + assert(newlineEnd > 0, 'newlineEnd <= 0 in splitAttributionLines'); + op.chars = newlineEnd - pos; + op.lines = 1; + appendOp(op); + numChars -= op.chars; + numLines -= op.lines; + } + if (numLines === 1) { + op.chars = numChars; + op.lines = 1; + } + appendOp(op); } - pos += op.chars; - }; - for (const op of exports.deserializeOps(attrOps)) { - let numChars = op.chars; - let numLines = op.lines; - while (numLines > 1) { - const newlineEnd = text.indexOf('\n', pos) + 1; - assert(newlineEnd > 0, 'newlineEnd <= 0 in splitAttributionLines'); - op.chars = newlineEnd - pos; - op.lines = 1; - appendOp(op); - numChars -= op.chars; - numLines -= op.lines; - } - if (numLines === 1) { - op.chars = numChars; - op.lines = 1; - } - appendOp(op); - } - return lines; + return lines; }; export const splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g); export const compose = (cs1, cs2, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); - const len1 = unpacked1.oldLen; - const len2 = unpacked1.newLen; - assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); - const len3 = unpacked2.newLen; - const bankIter1 = exports.stringIterator(unpacked1.charBank); - const bankIter2 = exports.stringIterator(unpacked2.charBank); - const bankAssem = exports.stringAssembler(); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { - const op1code = op1.opcode; - const op2code = op2.opcode; - if (op1code === '+' && op2code === '-') { - bankIter1.skip(Math.min(op1.chars, op2.chars)); - } - const opOut = slicerZipperFunc(op1, op2, pool); - if (opOut.opcode === '+') { - if (op2code === '+') { - bankAssem.append(bankIter2.take(opOut.chars)); - } - else { - bankAssem.append(bankIter1.take(opOut.chars)); - } - } - return opOut; - }); - return exports.pack(len1, len3, newOps, bankAssem.toString()); + const unpacked1 = exports.unpack(cs1); + const unpacked2 = exports.unpack(cs2); + const len1 = unpacked1.oldLen; + const len2 = unpacked1.newLen; + assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets'); + const len3 = unpacked2.newLen; + const bankIter1 = exports.stringIterator(unpacked1.charBank); + const bankIter2 = exports.stringIterator(unpacked2.charBank); + const bankAssem = exports.stringAssembler(); + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const op1code = op1.opcode; + const op2code = op2.opcode; + if (op1code === '+' && op2code === '-') { + bankIter1.skip(Math.min(op1.chars, op2.chars)); + } + const opOut = slicerZipperFunc(op1, op2, pool); + if (opOut.opcode === '+') { + if (op2code === '+') { + bankAssem.append(bankIter2.take(opOut.chars)); + } + else { + bankAssem.append(bankIter1.take(opOut.chars)); + } + } + return opOut; + }); + return exports.pack(len1, len3, newOps, bankAssem.toString()); }; export const attributeTester = (attribPair, pool) => { - const never = (attribs) => false; - if (!pool) - return never; - const attribNum = pool.putAttrib(attribPair, true); - if (attribNum < 0) - return never; - const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); - return (attribs) => re.test(attribs); + const never = (attribs) => false; + if (!pool) + return never; + const attribNum = pool.putAttrib(attribPair, true); + if (attribNum < 0) + return never; + const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`); + return (attribs) => re.test(attribs); }; export const identity = (N) => exports.pack(N, N, '', ''); export const makeSplice = (orig, start, ndel, ins, attribs, pool) => { - if (start < 0) - throw new RangeError(`start index must be non-negative (is ${start})`); - if (ndel < 0) - throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); - if (start > orig.length) - start = orig.length; - if (ndel > orig.length - start) - ndel = orig.length - start; - const deleted = orig.substring(start, start + ndel); - const assem = exports.smartOpAssembler(); - const ops = (function* () { - yield* opsFromText('=', orig.substring(0, start)); - yield* opsFromText('-', deleted); - yield* opsFromText('+', ins, attribs, pool); - })(); - for (const op of ops) - assem.append(op); - assem.endDocument(); - return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); + if (start < 0) + throw new RangeError(`start index must be non-negative (is ${start})`); + if (ndel < 0) + throw new RangeError(`characters to delete must be non-negative (is ${ndel})`); + if (start > orig.length) + start = orig.length; + if (ndel > orig.length - start) + ndel = orig.length - start; + const deleted = orig.substring(start, start + ndel); + const assem = exports.smartOpAssembler(); + const ops = (function* () { + yield* opsFromText('=', orig.substring(0, start)); + yield* opsFromText('-', deleted); + yield* opsFromText('+', ins, attribs, pool); + })(); + for (const op of ops) + assem.append(op); + assem.endDocument(); + return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins); }; export const characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => { - let newStartChar = startChar; - let newEndChar = endChar; - let lengthChangeSoFar = 0; - for (const splice of toSplices(cs)) { - const spliceStart = splice[0] + lengthChangeSoFar; - const spliceEnd = splice[1] + lengthChangeSoFar; - const newTextLength = splice[2].length; - const thisLengthChange = newTextLength - (spliceEnd - spliceStart); - if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { - // splice fully replaces/deletes range - // (also case that handles insertion at a collapsed selection) - if (insertionsAfter) { - newStartChar = newEndChar = spliceStart; - } - else { - newStartChar = newEndChar = spliceStart + newTextLength; - } + let newStartChar = startChar; + let newEndChar = endChar; + let lengthChangeSoFar = 0; + for (const splice of toSplices(cs)) { + const spliceStart = splice[0] + lengthChangeSoFar; + const spliceEnd = splice[1] + lengthChangeSoFar; + const newTextLength = splice[2].length; + const thisLengthChange = newTextLength - (spliceEnd - spliceStart); + if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { + // splice fully replaces/deletes range + // (also case that handles insertion at a collapsed selection) + if (insertionsAfter) { + newStartChar = newEndChar = spliceStart; + } + else { + newStartChar = newEndChar = spliceStart + newTextLength; + } + } + else if (spliceEnd <= newStartChar) { + // splice is before range + newStartChar += thisLengthChange; + newEndChar += thisLengthChange; + } + else if (spliceStart >= newEndChar) { + // splice is after range + } + else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { + // splice is inside range + newEndChar += thisLengthChange; + } + else if (spliceEnd < newEndChar) { + // splice overlaps beginning of range + newStartChar = spliceStart + newTextLength; + newEndChar += thisLengthChange; + } + else { + // splice overlaps end of range + newEndChar = spliceStart; + } + lengthChangeSoFar += thisLengthChange; } - else if (spliceEnd <= newStartChar) { - // splice is before range - newStartChar += thisLengthChange; - newEndChar += thisLengthChange; - } - else if (spliceStart >= newEndChar) { - // splice is after range - } - else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { - // splice is inside range - newEndChar += thisLengthChange; - } - else if (spliceEnd < newEndChar) { - // splice overlaps beginning of range - newStartChar = spliceStart + newTextLength; - newEndChar += thisLengthChange; - } - else { - // splice overlaps end of range - newEndChar = spliceStart; - } - lengthChangeSoFar += thisLengthChange; - } - return [newStartChar, newEndChar]; + return [newStartChar, newEndChar]; }; export const moveOpsToNewPool = (cs, oldPool, newPool) => { - // works on exports or attribution string - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); - const fromDollar = cs.substring(dollarPos); - // order of attribs stays the same - return upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - const oldNum = exports.parseNum(a); - const pair = oldPool.getAttrib(oldNum); - // The attribute might not be in the old pool if the user is viewing the current revision in the - // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932 - if (!pair) - return ''; - const newNum = newPool.putAttrib(pair); - return `*${exports.numToString(newNum)}`; - }) + fromDollar; + // works on exports or attribution string + let dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + const upToDollar = cs.substring(0, dollarPos); + const fromDollar = cs.substring(dollarPos); + // order of attribs stays the same + return upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { + const oldNum = exports.parseNum(a); + const pair = oldPool.getAttrib(oldNum); + // The attribute might not be in the old pool if the user is viewing the current revision in the + // timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932 + if (!pair) + return ''; + const newNum = newPool.putAttrib(pair); + return `*${exports.numToString(newNum)}`; + }) + fromDollar; }; export const makeAttribution = (text) => { - const assem = exports.smartOpAssembler(); - for (const op of opsFromText('+', text)) - assem.append(op); - return assem.toString(); + const assem = exports.smartOpAssembler(); + for (const op of opsFromText('+', text)) + assem.append(op); + return assem.toString(); }; export const eachAttribNumber = (cs, func) => { - padutils.warnDeprecated('Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead'); - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); - // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()` - // because that function only works on attribute strings, not serialized operations or changesets. - upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { - func(exports.parseNum(a)); - return ''; - }); + padutils.warnDeprecated('Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead'); + let dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + const upToDollar = cs.substring(0, dollarPos); + // WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()` + // because that function only works on attribute strings, not serialized operations or changesets. + upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => { + func(exports.parseNum(a)); + return ''; + }); }; export const filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filter); export const mapAttribNumbers = (cs, func) => { - let dollarPos = cs.indexOf('$'); - if (dollarPos < 0) { - dollarPos = cs.length; - } - const upToDollar = cs.substring(0, dollarPos); - const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => { - const n = func(exports.parseNum(a)); - if (n === true) { - return s; + let dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; } - else if ((typeof n) === 'number') { - return `*${exports.numToString(n)}`; - } - else { - return ''; - } - }); - return newUpToDollar + cs.substring(dollarPos); + const upToDollar = cs.substring(0, dollarPos); + const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => { + const n = func(exports.parseNum(a)); + if (n === true) { + return s; + } + else if ((typeof n) === 'number') { + return `*${exports.numToString(n)}`; + } + else { + return ''; + } + }); + return newUpToDollar + cs.substring(dollarPos); }; export const makeAText = (text, attribs) => ({ - text, - attribs: (attribs || exports.makeAttribution(text)), + text, + attribs: (attribs || exports.makeAttribution(text)), }); export const applyToAText = (cs, atext, pool) => ({ - text: exports.applyToText(cs, atext.text), - attribs: exports.applyToAttribution(cs, atext.attribs, pool), + text: exports.applyToText(cs, atext.text), + attribs: exports.applyToAttribution(cs, atext.attribs, pool), }); export const cloneAText = (atext) => { - if (!atext) - error('atext is null'); - return { - text: atext.text, - attribs: atext.attribs, - }; + if (!atext) + error('atext is null'); + return { + text: atext.text, + attribs: atext.attribs, + }; }; export const copyAText = (atext1, atext2) => { - atext2.text = atext1.text; - atext2.attribs = atext1.attribs; + atext2.text = atext1.text; + atext2.attribs = atext1.attribs; }; export const opsFromAText = function* (atext) { - // intentionally skips last newline char of atext - let lastOp = null; - for (const op of exports.deserializeOps(atext.attribs)) { - if (lastOp != null) - yield lastOp; - lastOp = op; - } - if (lastOp == null) - return; - // exclude final newline - if (lastOp.lines <= 1) { - lastOp.lines = 0; - lastOp.chars--; - } - else { - const nextToLastNewlineEnd = atext.text.lastIndexOf('\n', atext.text.length - 2) + 1; - const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; - lastOp.lines--; - lastOp.chars -= (lastLineLength + 1); - yield copyOp(lastOp); - lastOp.lines = 0; - lastOp.chars = lastLineLength; - } - if (lastOp.chars) - yield lastOp; + // intentionally skips last newline char of atext + let lastOp = null; + for (const op of exports.deserializeOps(atext.attribs)) { + if (lastOp != null) + yield lastOp; + lastOp = op; + } + if (lastOp == null) + return; + // exclude final newline + if (lastOp.lines <= 1) { + lastOp.lines = 0; + lastOp.chars--; + } + else { + const nextToLastNewlineEnd = atext.text.lastIndexOf('\n', atext.text.length - 2) + 1; + const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; + lastOp.lines--; + lastOp.chars -= (lastLineLength + 1); + yield copyOp(lastOp); + lastOp.lines = 0; + lastOp.chars = lastLineLength; + } + if (lastOp.chars) + yield lastOp; }; export const appendATextToAssembler = (atext, assem) => { - padutils.warnDeprecated('Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead'); - for (const op of exports.opsFromAText(atext)) - assem.append(op); + padutils.warnDeprecated('Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead'); + for (const op of exports.opsFromAText(atext)) + assem.append(op); }; export const prepareForWire = (cs, pool) => { - const newPool = new AttributePool(); - const newCs = exports.moveOpsToNewPool(cs, pool, newPool); - return { - translated: newCs, - pool: newPool, - }; + const newPool = new AttributePool(); + const newCs = exports.moveOpsToNewPool(cs, pool, newPool); + return { + translated: newCs, + pool: newPool, + }; }; export const isIdentity = (cs) => { - const unpacked = exports.unpack(cs); - return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; + const unpacked = exports.unpack(cs); + return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen; }; export const opAttributeValue = (op, key, pool) => { - padutils.warnDeprecated('Changeset.opAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(op.attribs, key, pool); + padutils.warnDeprecated('Changeset.opAttributeValue() is deprecated; use an AttributeMap instead'); + return attribsAttributeValue(op.attribs, key, pool); }; const attribsAttributeValue$0 = (attribs, key, pool) => { - padutils.warnDeprecated('Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead'); - return attribsAttributeValue(attribs, key, pool); + padutils.warnDeprecated('Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead'); + return attribsAttributeValue(attribs, key, pool); }; export const builder = (oldLen) => { - const assem = exports.smartOpAssembler(); - const o = new Op(); - const charBank = exports.stringAssembler(); - const self = { - /** - * @param {number} N - Number of characters to keep. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keep: (N, L, attribs, pool) => { - o.opcode = '='; - o.attribs = typeof attribs === 'string' - ? attribs : new AttributeMap(pool).update(attribs || []).toString(); - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - /** - * @param {string} text - Text to keep. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - keepText: (text, attribs, pool) => { - for (const op of opsFromText('=', text, attribs, pool)) - assem.append(op); - return self; - }, - /** - * @param {string} text - Text to insert. - * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' - * (no pool needed in latter case). - * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of - * attribute key, value pairs. - * @returns {Builder} this - */ - insert: (text, attribs, pool) => { - for (const op of opsFromText('+', text, attribs, pool)) - assem.append(op); - charBank.append(text); - return self; - }, - /** - * @param {number} N - Number of characters to remove. - * @param {number} L - Number of newlines among the `N` characters. If positive, the last - * character must be a newline. - * @returns {Builder} this - */ - remove: (N, L) => { - o.opcode = '-'; - o.attribs = ''; - o.chars = N; - o.lines = (L || 0); - assem.append(o); - return self; - }, - toString: () => { - assem.endDocument(); - const newLen = oldLen + assem.getLengthChange(); - return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); - }, - }; - return self; + const assem = exports.smartOpAssembler(); + const o = new Op(); + const charBank = exports.stringAssembler(); + const self = { + /** + * @param {number} N - Number of characters to keep. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keep: (N, L, attribs, pool) => { + o.opcode = '='; + o.attribs = typeof attribs === 'string' + ? attribs : new AttributeMap(pool).update(attribs || []).toString(); + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + /** + * @param {string} text - Text to keep. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + keepText: (text, attribs, pool) => { + for (const op of opsFromText('=', text, attribs, pool)) + assem.append(op); + return self; + }, + /** + * @param {string} text - Text to insert. + * @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...' + * (no pool needed in latter case). + * @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of + * attribute key, value pairs. + * @returns {Builder} this + */ + insert: (text, attribs, pool) => { + for (const op of opsFromText('+', text, attribs, pool)) + assem.append(op); + charBank.append(text); + return self; + }, + /** + * @param {number} N - Number of characters to remove. + * @param {number} L - Number of newlines among the `N` characters. If positive, the last + * character must be a newline. + * @returns {Builder} this + */ + remove: (N, L) => { + o.opcode = '-'; + o.attribs = ''; + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + toString: () => { + assem.endDocument(); + const newLen = oldLen + assem.getLengthChange(); + return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); + }, + }; + return self; }; export const makeAttribsString = (opcode, attribs, pool) => { - padutils.warnDeprecated('Changeset.makeAttribsString() is deprecated; ' + - 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); - if (!attribs || !['=', '+'].includes(opcode)) - return ''; - if (typeof attribs === 'string') - return attribs; - return new AttributeMap(pool).update(attribs, opcode === '+').toString(); + padutils.warnDeprecated('Changeset.makeAttribsString() is deprecated; ' + + 'use AttributeMap.prototype.toString() or attributes.attribsToString() instead'); + if (!attribs || !['=', '+'].includes(opcode)) + return ''; + if (typeof attribs === 'string') + return attribs; + return new AttributeMap(pool).update(attribs, opcode === '+').toString(); }; export const subattribution = (astr, start, optEnd) => { - const attOps = exports.deserializeOps(astr); - let attOpsNext = attOps.next(); - const assem = exports.smartOpAssembler(); - let attOp = new Op(); - const csOp = new Op(); - const doCsOp = () => { - if (!csOp.chars) - return; - while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { - if (!attOp.opcode) { - attOp = attOpsNext.value; - attOpsNext = attOps.next(); - } - if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && - attOp.lines > 0 && csOp.lines <= 0) { - csOp.lines++; - } - const opOut = slicerZipperFunc(attOp, csOp, null); - if (opOut.opcode) - assem.append(opOut); - } - }; - csOp.opcode = '-'; - csOp.chars = start; - doCsOp(); - if (optEnd === undefined) { - if (attOp.opcode) { - assem.append(attOp); - } - while (!attOpsNext.done) { - assem.append(attOpsNext.value); - attOpsNext = attOps.next(); - } - } - else { - csOp.opcode = '='; - csOp.chars = optEnd - start; + const attOps = exports.deserializeOps(astr); + let attOpsNext = attOps.next(); + const assem = exports.smartOpAssembler(); + let attOp = new Op(); + const csOp = new Op(); + const doCsOp = () => { + if (!csOp.chars) + return; + while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) { + if (!attOp.opcode) { + attOp = attOpsNext.value; + attOpsNext = attOps.next(); + } + if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && + attOp.lines > 0 && csOp.lines <= 0) { + csOp.lines++; + } + const opOut = slicerZipperFunc(attOp, csOp, null); + if (opOut.opcode) + assem.append(opOut); + } + }; + csOp.opcode = '-'; + csOp.chars = start; doCsOp(); - } - return assem.toString(); + if (optEnd === undefined) { + if (attOp.opcode) { + assem.append(attOp); + } + while (!attOpsNext.done) { + assem.append(attOpsNext.value); + attOpsNext = attOps.next(); + } + } + else { + csOp.opcode = '='; + csOp.chars = optEnd - start; + doCsOp(); + } + return assem.toString(); }; export const inverse = (cs, lines, alines, pool) => { - // lines and alines are what the exports is meant to apply to. - // They may be arrays or objects with .get(i) and .length methods. - // They include final newlines on lines. - const linesGet = (idx) => { - if (lines.get) { - return lines.get(idx); - } - else { - return lines[idx]; - } - }; - /** - * @param {number} idx - - * @returns {string} - */ - const alinesGet = (idx) => { - if (alines.get) { - return alines.get(idx); - } - else { - return alines[idx]; - } - }; - let curLine = 0; - let curChar = 0; - let curLineOps = null; - let curLineOpsNext = null; - let curLineOpsLine; - let curLineNextOp = new Op('+'); - const unpacked = exports.unpack(cs); - const builder = exports.builder(unpacked.newLen); - const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { - if (!curLineOps || curLineOpsLine !== curLine) { - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); - curLineOpsLine = curLine; - let indexIntoLine = 0; - while (!curLineOpsNext.done) { - curLineNextOp = curLineOpsNext.value; - curLineOpsNext = curLineOps.next(); - if (indexIntoLine + curLineNextOp.chars >= curChar) { - curLineNextOp.chars -= (curChar - indexIntoLine); - break; - } - indexIntoLine += curLineNextOp.chars; - } - } - while (numChars > 0) { - if (!curLineNextOp.chars && curLineOpsNext.done) { - curLine++; - curChar = 0; - curLineOpsLine = curLine; - curLineNextOp.chars = 0; - curLineOps = exports.deserializeOps(alinesGet(curLine)); - curLineOpsNext = curLineOps.next(); - } - if (!curLineNextOp.chars) { - if (curLineOpsNext.done) { - curLineNextOp = new Op(); + // lines and alines are what the exports is meant to apply to. + // They may be arrays or objects with .get(i) and .length methods. + // They include final newlines on lines. + const linesGet = (idx) => { + if (lines.get) { + return lines.get(idx); } else { - curLineNextOp = curLineOpsNext.value; - curLineOpsNext = curLineOps.next(); + return lines[idx]; } - } - const charsToUse = Math.min(numChars, curLineNextOp.chars); - func(charsToUse, curLineNextOp.attribs, charsToUse === curLineNextOp.chars && - curLineNextOp.lines > 0); - numChars -= charsToUse; - curLineNextOp.chars -= charsToUse; - curChar += charsToUse; - } - if (!curLineNextOp.chars && curLineOpsNext.done) { - curLine++; - curChar = 0; - } - }; - const skip = (N, L) => { - if (L) { - curLine += L; - curChar = 0; - } - else if (curLineOps && curLineOpsLine === curLine) { - consumeAttribRuns(N, () => { }); - } - else { - curChar += N; - } - }; - const nextText = (numChars) => { - let len = 0; - const assem = exports.stringAssembler(); - const firstString = linesGet(curLine).substring(curChar); - len += firstString.length; - assem.append(firstString); - let lineNum = curLine + 1; - while (len < numChars) { - const nextString = linesGet(lineNum); - len += nextString.length; - assem.append(nextString); - lineNum++; - } - return assem.toString().substring(0, numChars); - }; - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; }; - }; - for (const csOp of exports.deserializeOps(unpacked.ops)) { - if (csOp.opcode === '=') { - if (csOp.attribs) { - const attribs = AttributeMap.fromString(csOp.attribs, pool); - const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { - const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); - const backAttribs = new AttributeMap(pool); - for (const [key, value] of attribs) { - const oldValue = oldAttribs.get(key) || ''; - if (oldValue !== value) - backAttribs.set(key, oldValue); - } - // TODO: backAttribs does not restore removed attributes (it is missing attributes that - // are in oldAttribs but not in attribs). I don't know if that is intentional. - return backAttribs.toString(); - }); - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { - builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); - }); - } - else { - skip(csOp.chars, csOp.lines); - builder.keep(csOp.chars, csOp.lines); - } + /** + * @param {number} idx - + * @returns {string} + */ + const alinesGet = (idx) => { + if (alines.get) { + return alines.get(idx); + } + else { + return alines[idx]; + } + }; + let curLine = 0; + let curChar = 0; + let curLineOps = null; + let curLineOpsNext = null; + let curLineOpsLine; + let curLineNextOp = new Op('+'); + const unpacked = exports.unpack(cs); + const builder = exports.builder(unpacked.newLen); + const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => { + if (!curLineOps || curLineOpsLine !== curLine) { + curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps.next(); + curLineOpsLine = curLine; + let indexIntoLine = 0; + while (!curLineOpsNext.done) { + curLineNextOp = curLineOpsNext.value; + curLineOpsNext = curLineOps.next(); + if (indexIntoLine + curLineNextOp.chars >= curChar) { + curLineNextOp.chars -= (curChar - indexIntoLine); + break; + } + indexIntoLine += curLineNextOp.chars; + } + } + while (numChars > 0) { + if (!curLineNextOp.chars && curLineOpsNext.done) { + curLine++; + curChar = 0; + curLineOpsLine = curLine; + curLineNextOp.chars = 0; + curLineOps = exports.deserializeOps(alinesGet(curLine)); + curLineOpsNext = curLineOps.next(); + } + if (!curLineNextOp.chars) { + if (curLineOpsNext.done) { + curLineNextOp = new Op(); + } + else { + curLineNextOp = curLineOpsNext.value; + curLineOpsNext = curLineOps.next(); + } + } + const charsToUse = Math.min(numChars, curLineNextOp.chars); + func(charsToUse, curLineNextOp.attribs, charsToUse === curLineNextOp.chars && + curLineNextOp.lines > 0); + numChars -= charsToUse; + curLineNextOp.chars -= charsToUse; + curChar += charsToUse; + } + if (!curLineNextOp.chars && curLineOpsNext.done) { + curLine++; + curChar = 0; + } + }; + const skip = (N, L) => { + if (L) { + curLine += L; + curChar = 0; + } + else if (curLineOps && curLineOpsLine === curLine) { + consumeAttribRuns(N, () => { }); + } + else { + curChar += N; + } + }; + const nextText = (numChars) => { + let len = 0; + const assem = exports.stringAssembler(); + const firstString = linesGet(curLine).substring(curChar); + len += firstString.length; + assem.append(firstString); + let lineNum = curLine + 1; + while (len < numChars) { + const nextString = linesGet(lineNum); + len += nextString.length; + assem.append(nextString); + lineNum++; + } + return assem.toString().substring(0, numChars); + }; + const cachedStrFunc = (func) => { + const cache = {}; + return (s) => { + if (!cache[s]) { + cache[s] = func(s); + } + return cache[s]; + }; + }; + for (const csOp of exports.deserializeOps(unpacked.ops)) { + if (csOp.opcode === '=') { + if (csOp.attribs) { + const attribs = AttributeMap.fromString(csOp.attribs, pool); + const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => { + const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool); + const backAttribs = new AttributeMap(pool); + for (const [key, value] of attribs) { + const oldValue = oldAttribs.get(key) || ''; + if (oldValue !== value) + backAttribs.set(key, oldValue); + } + // TODO: backAttribs does not restore removed attributes (it is missing attributes that + // are in oldAttribs but not in attribs). I don't know if that is intentional. + return backAttribs.toString(); + }); + consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); + }); + } + else { + skip(csOp.chars, csOp.lines); + builder.keep(csOp.chars, csOp.lines); + } + } + else if (csOp.opcode === '+') { + builder.remove(csOp.chars, csOp.lines); + } + else if (csOp.opcode === '-') { + const textBank = nextText(csOp.chars); + let textBankIndex = 0; + consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { + builder.insert(textBank.substr(textBankIndex, len), attribs); + textBankIndex += len; + }); + } } - else if (csOp.opcode === '+') { - builder.remove(csOp.chars, csOp.lines); - } - else if (csOp.opcode === '-') { - const textBank = nextText(csOp.chars); - let textBankIndex = 0; - consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => { - builder.insert(textBank.substr(textBankIndex, len), attribs); - textBankIndex += len; - }); - } - } - return exports.checkRep(builder.toString()); + return exports.checkRep(builder.toString()); }; export const follow = (cs1, cs2, reverseInsertOrder, pool) => { - const unpacked1 = exports.unpack(cs1); - const unpacked2 = exports.unpack(cs2); - const len1 = unpacked1.oldLen; - const len2 = unpacked2.oldLen; - assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); - const chars1 = exports.stringIterator(unpacked1.charBank); - const chars2 = exports.stringIterator(unpacked2.charBank); - const oldLen = unpacked1.newLen; - let oldPos = 0; - let newLen = 0; - const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); - const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { - const opOut = new Op(); - if (op1.opcode === '+' || op2.opcode === '+') { - let whichToDo; - if (op2.opcode !== '+') { - whichToDo = 1; - } - else if (op1.opcode !== '+') { - whichToDo = 2; - } - else { - // both + - const firstChar1 = chars1.peek(1); - const firstChar2 = chars2.peek(1); - const insertFirst1 = hasInsertFirst(op1.attribs); - const insertFirst2 = hasInsertFirst(op2.attribs); - if (insertFirst1 && !insertFirst2) { - whichToDo = 1; + const unpacked1 = exports.unpack(cs1); + const unpacked2 = exports.unpack(cs2); + const len1 = unpacked1.oldLen; + const len2 = unpacked2.oldLen; + assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2'); + const chars1 = exports.stringIterator(unpacked1.charBank); + const chars2 = exports.stringIterator(unpacked2.charBank); + const oldLen = unpacked1.newLen; + let oldPos = 0; + let newLen = 0; + const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); + const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => { + const opOut = new Op(); + if (op1.opcode === '+' || op2.opcode === '+') { + let whichToDo; + if (op2.opcode !== '+') { + whichToDo = 1; + } + else if (op1.opcode !== '+') { + whichToDo = 2; + } + else { + // both + + const firstChar1 = chars1.peek(1); + const firstChar2 = chars2.peek(1); + const insertFirst1 = hasInsertFirst(op1.attribs); + const insertFirst2 = hasInsertFirst(op2.attribs); + if (insertFirst1 && !insertFirst2) { + whichToDo = 1; + } + else if (insertFirst2 && !insertFirst1) { + whichToDo = 2; + } + else if (firstChar1 === '\n' && firstChar2 !== '\n') { + // insert string that doesn't start with a newline first so as not to break up lines + whichToDo = 2; + } + else if (firstChar1 !== '\n' && firstChar2 === '\n') { + whichToDo = 1; + } + else if (reverseInsertOrder) { + // break symmetry: + whichToDo = 2; + } + else { + whichToDo = 1; + } + } + if (whichToDo === 1) { + chars1.skip(op1.chars); + opOut.opcode = '='; + opOut.lines = op1.lines; + opOut.chars = op1.chars; + opOut.attribs = ''; + op1.opcode = ''; + } + else { + // whichToDo == 2 + chars2.skip(op2.chars); + copyOp(op2, opOut); + op2.opcode = ''; + } } - else if (insertFirst2 && !insertFirst1) { - whichToDo = 2; + else if (op1.opcode === '-') { + if (!op2.opcode) { + op1.opcode = ''; + } + else if (op1.chars <= op2.chars) { + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (!op2.chars) { + op2.opcode = ''; + } + } + else { + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } } - else if (firstChar1 === '\n' && firstChar2 !== '\n') { - // insert string that doesn't start with a newline first so as not to break up lines - whichToDo = 2; + else if (op2.opcode === '-') { + copyOp(op2, opOut); + if (!op1.opcode) { + op2.opcode = ''; + } + else if (op2.chars <= op1.chars) { + // delete part or all of a keep + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + if (!op1.chars) { + op1.opcode = ''; + } + } + else { + // delete all of a keep, and keep going + opOut.lines = op1.lines; + opOut.chars = op1.chars; + op2.lines -= op1.lines; + op2.chars -= op1.chars; + op1.opcode = ''; + } } - else if (firstChar1 !== '\n' && firstChar2 === '\n') { - whichToDo = 1; + else if (!op1.opcode) { + copyOp(op2, opOut); + op2.opcode = ''; } - else if (reverseInsertOrder) { - // break symmetry: - whichToDo = 2; + else if (!op2.opcode) { + // @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here + // in order to prevent attributes from leaking into result changesets. + // copyOp(op1, opOut); + op1.opcode = ''; } else { - whichToDo = 1; + // both keeps + opOut.opcode = '='; + opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool); + if (op1.chars <= op2.chars) { + opOut.chars = op1.chars; + opOut.lines = op1.lines; + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (!op2.chars) { + op2.opcode = ''; + } + } + else { + opOut.chars = op2.chars; + opOut.lines = op2.lines; + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } } - } - if (whichToDo === 1) { - chars1.skip(op1.chars); - opOut.opcode = '='; - opOut.lines = op1.lines; - opOut.chars = op1.chars; - opOut.attribs = ''; - op1.opcode = ''; - } - else { - // whichToDo == 2 - chars2.skip(op2.chars); - copyOp(op2, opOut); - op2.opcode = ''; - } - } - else if (op1.opcode === '-') { - if (!op2.opcode) { - op1.opcode = ''; - } - else if (op1.chars <= op2.chars) { - op2.chars -= op1.chars; - op2.lines -= op1.lines; - op1.opcode = ''; - if (!op2.chars) { - op2.opcode = ''; + switch (opOut.opcode) { + case '=': + oldPos += opOut.chars; + newLen += opOut.chars; + break; + case '-': + oldPos += opOut.chars; + break; + case '+': + newLen += opOut.chars; + break; } - } - else { - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - } - } - else if (op2.opcode === '-') { - copyOp(op2, opOut); - if (!op1.opcode) { - op2.opcode = ''; - } - else if (op2.chars <= op1.chars) { - // delete part or all of a keep - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - if (!op1.chars) { - op1.opcode = ''; - } - } - else { - // delete all of a keep, and keep going - opOut.lines = op1.lines; - opOut.chars = op1.chars; - op2.lines -= op1.lines; - op2.chars -= op1.chars; - op1.opcode = ''; - } - } - else if (!op1.opcode) { - copyOp(op2, opOut); - op2.opcode = ''; - } - else if (!op2.opcode) { - // @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here - // in order to prevent attributes from leaking into result changesets. - // copyOp(op1, opOut); - op1.opcode = ''; - } - else { - // both keeps - opOut.opcode = '='; - opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool); - if (op1.chars <= op2.chars) { - opOut.chars = op1.chars; - opOut.lines = op1.lines; - op2.chars -= op1.chars; - op2.lines -= op1.lines; - op1.opcode = ''; - if (!op2.chars) { - op2.opcode = ''; - } - } - else { - opOut.chars = op2.chars; - opOut.lines = op2.lines; - op1.chars -= op2.chars; - op1.lines -= op2.lines; - op2.opcode = ''; - } - } - switch (opOut.opcode) { - case '=': - oldPos += opOut.chars; - newLen += opOut.chars; - break; - case '-': - oldPos += opOut.chars; - break; - case '+': - newLen += opOut.chars; - break; - } - return opOut; - }); - newLen += oldLen - oldPos; - return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); + return opOut; + }); + newLen += oldLen - oldPos; + return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); }; export const exportedForTestingOnly = { - TextLinesMutator, - followAttributes, - toSplices, + TextLinesMutator, + followAttributes, + toSplices, }; export { Op }; export { attribsAttributeValue$0 as attribsAttributeValue }; diff --git a/src/static/js/ChangesetUtils.js b/src/static/js/ChangesetUtils.js index ef2be2ebe..b63481a51 100644 --- a/src/static/js/ChangesetUtils.js +++ b/src/static/js/ChangesetUtils.js @@ -1,52 +1,28 @@ 'use strict'; - -/** - * This module contains several helper Functions to build Changesets - * based on a SkipList - */ - -/** - * Copyright 2009 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -exports.buildRemoveRange = (rep, builder, start, end) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); - const endLineOffset = rep.lines.offsetOfIndex(end[0]); - - if (end[0] > start[0]) { - builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]); - builder.remove(end[1]); - } else { - builder.remove(end[1] - start[1]); - } +export const buildRemoveRange = (rep, builder, start, end) => { + const startLineOffset = rep.lines.offsetOfIndex(start[0]); + const endLineOffset = rep.lines.offsetOfIndex(end[0]); + if (end[0] > start[0]) { + builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]); + builder.remove(end[1]); + } + else { + builder.remove(end[1] - start[1]); + } }; - -exports.buildKeepRange = (rep, builder, start, end, attribs, pool) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); - const endLineOffset = rep.lines.offsetOfIndex(end[0]); - - if (end[0] > start[0]) { - builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool); - builder.keep(end[1], 0, attribs, pool); - } else { - builder.keep(end[1] - start[1], 0, attribs, pool); - } +export const buildKeepRange = (rep, builder, start, end, attribs, pool) => { + const startLineOffset = rep.lines.offsetOfIndex(start[0]); + const endLineOffset = rep.lines.offsetOfIndex(end[0]); + if (end[0] > start[0]) { + builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool); + builder.keep(end[1], 0, attribs, pool); + } + else { + builder.keep(end[1] - start[1], 0, attribs, pool); + } }; - -exports.buildKeepToStartOfRange = (rep, builder, start) => { - const startLineOffset = rep.lines.offsetOfIndex(start[0]); - - builder.keep(startLineOffset, start[0]); - builder.keep(start[1]); +export const buildKeepToStartOfRange = (rep, builder, start) => { + const startLineOffset = rep.lines.offsetOfIndex(start[0]); + builder.keep(startLineOffset, start[0]); + builder.keep(start[1]); }; diff --git a/src/static/js/ChatMessage.js b/src/static/js/ChatMessage.js index a627f88f9..c9cc87e28 100644 --- a/src/static/js/ChatMessage.js +++ b/src/static/js/ChatMessage.js @@ -1,7 +1,6 @@ +import { padutils } from "./pad_utils.js"; 'use strict'; - -const {padutils: {warnDeprecated}} = require('./pad_utils'); - +const { padutils: { warnDeprecated } } = { padutils }; /** * Represents a chat message stored in the database and transmitted among users. Plugins can extend * the object with additional properties. @@ -9,90 +8,84 @@ const {padutils: {warnDeprecated}} = require('./pad_utils'); * Supports serialization to JSON. */ class ChatMessage { - static fromObject(obj) { - // The userId property was renamed to authorId, and userName was renamed to displayName. Accept - // the old names in case the db record was written by an older version of Etherpad. - obj = Object.assign({}, obj); // Don't mutate the caller's object. - if ('userId' in obj && !('authorId' in obj)) obj.authorId = obj.userId; - delete obj.userId; - if ('userName' in obj && !('displayName' in obj)) obj.displayName = obj.userName; - delete obj.userName; - return Object.assign(new ChatMessage(), obj); - } - - /** - * @param {?string} [text] - Initial value of the `text` property. - * @param {?string} [authorId] - Initial value of the `authorId` property. - * @param {?number} [time] - Initial value of the `time` property. - */ - constructor(text = null, authorId = null, time = null) { + static fromObject(obj) { + // The userId property was renamed to authorId, and userName was renamed to displayName. Accept + // the old names in case the db record was written by an older version of Etherpad. + obj = Object.assign({}, obj); // Don't mutate the caller's object. + if ('userId' in obj && !('authorId' in obj)) + obj.authorId = obj.userId; + delete obj.userId; + if ('userName' in obj && !('displayName' in obj)) + obj.displayName = obj.userName; + delete obj.userName; + return Object.assign(new ChatMessage(), obj); + } /** - * The raw text of the user's chat message (before any rendering or processing). - * - * @type {?string} + * @param {?string} [text] - Initial value of the `text` property. + * @param {?string} [authorId] - Initial value of the `authorId` property. + * @param {?number} [time] - Initial value of the `time` property. */ - this.text = text; - + constructor(text = null, authorId = null, time = null) { + /** + * The raw text of the user's chat message (before any rendering or processing). + * + * @type {?string} + */ + this.text = text; + /** + * The user's author ID. + * + * @type {?string} + */ + this.authorId = authorId; + /** + * The message's timestamp, as milliseconds since epoch. + * + * @type {?number} + */ + this.time = time; + /** + * The user's display name. + * + * @type {?string} + */ + this.displayName = null; + } /** - * The user's author ID. + * Alias of `authorId`, for compatibility with old plugins. * - * @type {?string} + * @deprecated Use `authorId` instead. + * @type {string} */ - this.authorId = authorId; - + get userId() { + warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); + return this.authorId; + } + set userId(val) { + warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); + this.authorId = val; + } /** - * The message's timestamp, as milliseconds since epoch. + * Alias of `displayName`, for compatibility with old plugins. * - * @type {?number} + * @deprecated Use `displayName` instead. + * @type {string} */ - this.time = time; - - /** - * The user's display name. - * - * @type {?string} - */ - this.displayName = null; - } - - /** - * Alias of `authorId`, for compatibility with old plugins. - * - * @deprecated Use `authorId` instead. - * @type {string} - */ - get userId() { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); - return this.authorId; - } - set userId(val) { - warnDeprecated('ChatMessage.userId property is deprecated; use .authorId instead'); - this.authorId = val; - } - - /** - * Alias of `displayName`, for compatibility with old plugins. - * - * @deprecated Use `displayName` instead. - * @type {string} - */ - get userName() { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); - return this.displayName; - } - set userName(val) { - warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); - this.displayName = val; - } - - // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that - // doesn't support authorId and displayName. - toJSON() { - const {authorId, displayName, ...obj} = this; - obj.userId = authorId; - obj.userName = displayName; - return obj; - } + get userName() { + warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); + return this.displayName; + } + set userName(val) { + warnDeprecated('ChatMessage.userName property is deprecated; use .displayName instead'); + this.displayName = val; + } + // TODO: Delete this method once users are unlikely to roll back to a version of Etherpad that + // doesn't support authorId and displayName. + toJSON() { + const { authorId, displayName, ...obj } = this; + obj.userId = authorId; + obj.userName = displayName; + return obj; + } } - -module.exports = ChatMessage; +export default ChatMessage; diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js deleted file mode 100644 index a8b98ac16..000000000 --- a/src/static/js/ace2_inner.js +++ /dev/null @@ -1,3527 +0,0 @@ -'use strict'; - -/** - * Copyright 2009 Google Inc. - * Copyright 2020 John McLear - The Etherpad Foundation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS-IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -let documentAttributeManager; - -const AttributeMap = require('./AttributeMap'); -const browser = require('./vendors/browser'); -const padutils = require('./pad_utils').padutils; -const Ace2Common = require('./ace2_common'); -const $ = require('./rjquery').$; - -const isNodeText = Ace2Common.isNodeText; -const getAssoc = Ace2Common.getAssoc; -const setAssoc = Ace2Common.setAssoc; -const noop = Ace2Common.noop; -const hooks = require('./pluginfw/hooks'); - -function Ace2Inner(editorInfo, cssManagers) { - const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; - const colorutils = require('./colorutils').colorutils; - const makeContentCollector = require('./contentcollector').makeContentCollector; - const domline = require('./domline').domline; - const AttribPool = require('./AttributePool'); - const Changeset = require('./Changeset'); - const ChangesetUtils = require('./ChangesetUtils'); - const linestylefilter = require('./linestylefilter').linestylefilter; - const SkipList = require('./skiplist'); - const undoModule = require('./undomodule').undoModule; - const AttributeManager = require('./AttributeManager'); - const Scroll = require('./scroll'); - const DEBUG = false; - - const THE_TAB = ' '; // 4 - const MAX_LIST_LEVEL = 16; - - const FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough']; - const SELECT_BUTTON_CLASS = 'selected'; - - let thisAuthor = ''; - - let disposed = false; - - const focus = () => { - window.focus(); - }; - - const outerWin = window.parent; - const outerDoc = outerWin.document; - const sideDiv = outerDoc.getElementById('sidediv'); - const lineMetricsDiv = outerDoc.getElementById('linemetricsdiv'); - const sideDivInner = outerDoc.getElementById('sidedivinner'); - const appendNewSideDivLine = () => { - const lineDiv = outerDoc.createElement('div'); - sideDivInner.appendChild(lineDiv); - const lineSpan = outerDoc.createElement('span'); - lineSpan.classList.add('line-number'); - lineSpan.appendChild(outerDoc.createTextNode(sideDivInner.children.length)); - lineDiv.appendChild(lineSpan); - }; - appendNewSideDivLine(); - - const scroll = Scroll.init(outerWin); - - let outsideKeyDown = noop; - let outsideKeyPress = (e) => true; - let outsideNotifyDirty = noop; - - /** - * Document representation. - */ - const rep = { - /** - * The contents of the document. Each entry in this skip list is an object representing a - * line (actually paragraph) of text. The line objects are created by createDomLineEntry(). - */ - lines: new SkipList(), - /** - * Start of the selection. Represented as an array of two non-negative numbers that point to the - * first character of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. Notes: - * - There is an implicit newline character (not actually stored) at the end of every line. - * Because of this, a selection that starts at the end of a line (column number equals the - * number of characters in the line, not including the implicit newline) is not equivalent - * to a selection that starts at the beginning of the next line. The same goes for the - * selection end. - * - If there are N lines, [N, 0] is valid for the start of the selection. [N, 0] indicates - * that the selection starts just after the implicit newline at the end of the document's - * last line (if the document has any lines). The same goes for the end of the selection. - * - If a line starts with a line marker, a selection that starts at the beginning of the line - * may start either immediately before (column = 0) or immediately after (column = 1) the - * line marker, and the two are considered to be semantically equivalent. For safety, all - * code should be written to accept either but only produce selections that start after the - * line marker (the column number should be 1, not 0, when there is a line marker). The same - * goes for the end of the selection. - */ - selStart: null, - /** - * End of the selection. Represented as an array of two non-negative numbers that point to the - * character just after the end of the selection: [zeroBasedLineNumber, zeroBasedColumnNumber]. - * See the above notes for selStart. - */ - selEnd: null, - /** - * Whether the selection extends "backwards", so that the focus point (controlled with the arrow - * keys) is at the beginning. This is not supported in IE, though native IE selections have that - * behavior (which we try not to interfere with). Must be false if selection is collapsed! - */ - selFocusAtStart: false, - alltext: '', - alines: [], - apool: new AttribPool(), - }; - - // lines, alltext, alines, and DOM are set up in init() - if (undoModule.enabled) { - undoModule.apool = rep.apool; - } - - let isEditable = true; - let doesWrap = true; - let hasLineNumbers = true; - let isStyled = true; - - let console = (DEBUG && window.console); - - if (!window.console) { - const names = [ - 'log', - 'debug', - 'info', - 'warn', - 'error', - 'assert', - 'dir', - 'dirxml', - 'group', - 'groupEnd', - 'time', - 'timeEnd', - 'count', - 'trace', - 'profile', - 'profileEnd', - ]; - console = {}; - for (const name of names) console[name] = noop; - } - - const scheduler = parent; // hack for opera required - - const performDocumentReplaceRange = (start, end, newText) => { - if (start === undefined) start = rep.selStart; - if (end === undefined) end = rep.selEnd; - - // start[0]: <--- start[1] --->CCCCCCCCCCC\n - // CCCCCCCCCCCCCCCCCCCC\n - // CCCC\n - // end[0]: -------\n - const builder = Changeset.builder(rep.lines.totalWidth()); - ChangesetUtils.buildKeepToStartOfRange(rep, builder, start); - ChangesetUtils.buildRemoveRange(rep, builder, start, end); - builder.insert(newText, [ - ['author', thisAuthor], - ], rep.apool); - const cs = builder.toString(); - - performDocumentApplyChangeset(cs); - }; - - const changesetTracker = makeChangesetTracker(scheduler, rep.apool, { - withCallbacks: (operationName, f) => { - inCallStackIfNecessary(operationName, () => { - fastIncorp(1); - f( - { - setDocumentAttributedText: (atext) => { - setDocAText(atext); - }, - applyChangesetToDocument: (changeset, preferInsertionAfterCaret) => { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent('nonundoable'); - - performDocumentApplyChangeset(changeset, preferInsertionAfterCaret); - - currentCallStack.startNewEvent(oldEventType); - }, - }); - }); - }, - }); - - const authorInfos = {}; // presence of key determines if author is present in doc - const getAuthorInfos = () => authorInfos; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - - const setAuthorStyle = (author, info) => { - const authorSelector = getAuthorColorClassSelector(getAuthorClassName(author)); - - const authorStyleSet = hooks.callAll('aceSetAuthorStyle', { - dynamicCSS: cssManagers.inner, - outerDynamicCSS: cssManagers.outer, - parentDynamicCSS: cssManagers.parent, - info, - author, - authorSelector, - }); - - // Prevent default behaviour if any hook says so - if (authorStyleSet.some((it) => it)) { - return; - } - - if (!info) { - cssManagers.inner.removeSelectorStyle(authorSelector); - cssManagers.parent.removeSelectorStyle(authorSelector); - } else if (info.bgcolor) { - let bgcolor = info.bgcolor; - if ((typeof info.fade) === 'number') { - bgcolor = fadeColor(bgcolor, info.fade); - } - const textColor = - colorutils.textColorFromBackgroundColor(bgcolor, parent.parent.clientVars.skinName); - const styles = [ - cssManagers.inner.selectorStyle(authorSelector), - cssManagers.parent.selectorStyle(authorSelector), - ]; - for (const style of styles) { - style.backgroundColor = bgcolor; - style.color = textColor; - style['padding-top'] = '3px'; - style['padding-bottom'] = '4px'; - } - } - }; - - const setAuthorInfo = (author, info) => { - if (!author) return; // author ID not set for some reason - if ((typeof author) !== 'string') { - // Potentially caused by: https://github.com/ether/etherpad-lite/issues/2802"); - throw new Error(`setAuthorInfo: author (${author}) is not a string`); - } - if (!info) { - delete authorInfos[author]; - } else { - authorInfos[author] = info; - } - setAuthorStyle(author, info); - }; - - const getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => { - if (c === '.') return '-'; - return `z${c.charCodeAt(0)}z`; - })}`; - - const className2Author = (className) => { - if (className.substring(0, 7) === 'author-') { - return className.substring(7).replace(/[a-y0-9]+|-|z.+?z/g, (cc) => { - if (cc === '-') { return '.'; } else if (cc.charAt(0) === 'z') { - return String.fromCharCode(Number(cc.slice(1, -1))); - } else { - return cc; - } - }); - } - return null; - }; - - const getAuthorColorClassSelector = (oneClassName) => `.authorColors .${oneClassName}`; - - const fadeColor = (colorCSS, fadeFrac) => { - let color = colorutils.css2triple(colorCSS); - color = colorutils.blend(color, [1, 1, 1], fadeFrac); - return colorutils.triple2css(color); - }; - - editorInfo.ace_getRep = () => rep; - - editorInfo.ace_getAuthor = () => thisAuthor; - - const _nonScrollableEditEvents = { - applyChangesToBase: 1, - }; - - for (const eventType of hooks.callAll('aceRegisterNonScrollableEditEvents')) { - _nonScrollableEditEvents[eventType] = 1; - } - - const isScrollableEditEvent = (eventType) => !_nonScrollableEditEvents[eventType]; - - let currentCallStack = null; - - const inCallStack = (type, action) => { - if (disposed) return; - - const newEditEvent = (eventType) => ({ - eventType, - backset: null, - }); - - const submitOldEvent = (evt) => { - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - evt.selStart = selStartChar; - evt.selEnd = selEndChar; - evt.selFocusAtStart = rep.selFocusAtStart; - } - if (undoModule.enabled) { - let undoWorked = false; - try { - if (isPadLoading(evt.eventType)) { - undoModule.clearHistory(); - } else if (evt.eventType === 'nonundoable') { - if (evt.changeset) { - undoModule.reportExternalChange(evt.changeset); - } - } else { - undoModule.reportEvent(evt); - } - undoWorked = true; - } finally { - if (!undoWorked) { - undoModule.enabled = false; // for safety - } - } - } - }; - - const startNewEvent = (eventType, dontSubmitOld) => { - const oldEvent = currentCallStack.editEvent; - if (!dontSubmitOld) { - submitOldEvent(oldEvent); - } - currentCallStack.editEvent = newEditEvent(eventType); - return oldEvent; - }; - - currentCallStack = { - type, - docTextChanged: false, - selectionAffected: false, - userChangedSelection: false, - domClean: false, - isUserChange: false, - // is this a "user change" type of call-stack - repChanged: false, - editEvent: newEditEvent(type), - startNewEvent, - }; - let cleanExit = false; - let result; - try { - result = action(); - - hooks.callAll('aceEditEvent', { - callstack: currentCallStack, - editorInfo, - rep, - documentAttributeManager, - }); - - cleanExit = true; - } finally { - const cs = currentCallStack; - if (cleanExit) { - submitOldEvent(cs.editEvent); - if (cs.domClean && cs.type !== 'setup') { - if (cs.selectionAffected) { - updateBrowserSelectionFromRep(); - } - if ((cs.docTextChanged || cs.userChangedSelection) && isScrollableEditEvent(cs.type)) { - scrollSelectionIntoView(); - } - if (cs.docTextChanged && cs.type.indexOf('importText') < 0) { - outsideNotifyDirty(); - } - } - } else if (currentCallStack.type === 'idleWorkTimer') { - idleWorkTimer.atLeast(1000); - } - currentCallStack = null; - } - return result; - }; - editorInfo.ace_inCallStack = inCallStack; - - const inCallStackIfNecessary = (type, action) => { - if (!currentCallStack) { - inCallStack(type, action); - } else { - action(); - } - }; - editorInfo.ace_inCallStackIfNecessary = inCallStackIfNecessary; - - const dispose = () => { - disposed = true; - if (idleWorkTimer) idleWorkTimer.never(); - teardown(); - }; - - const setWraps = (newVal) => { - doesWrap = newVal; - document.body.classList.toggle('doesWrap', doesWrap); - scheduler.setTimeout(() => { - inCallStackIfNecessary('setWraps', () => { - fastIncorp(7); - recreateDOM(); - fixView(); - }); - }, 0); - }; - - const setStyled = (newVal) => { - const oldVal = isStyled; - isStyled = !!newVal; - - if (newVal !== oldVal) { - if (!newVal) { - // clear styles - inCallStackIfNecessary('setStyled', () => { - fastIncorp(12); - const clearStyles = []; - for (const k of Object.keys(STYLE_ATTRIBS)) { - clearStyles.push([k, '']); - } - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, clearStyles); - }); - } - } - }; - - const setTextFace = (face) => { - document.body.style.fontFamily = face; - lineMetricsDiv.style.fontFamily = face; - }; - - const recreateDOM = () => { - // precond: normalized - recolorLinesInRange(0, rep.alltext.length); - }; - - const setEditable = (newVal) => { - isEditable = newVal; - document.body.contentEditable = isEditable ? 'true' : 'false'; - document.body.classList.toggle('static', !isEditable); - }; - - const enforceEditability = () => setEditable(isEditable); - - const importText = (text, undoable, dontProcess) => { - let lines; - if (dontProcess) { - if (text.charAt(text.length - 1) !== '\n') { - throw new Error('new raw text must end with newline'); - } - if (/[\r\t\xa0]/.exec(text)) { - throw new Error('new raw text must not contain CR, tab, or nbsp'); - } - lines = text.substring(0, text.length - 1).split('\n'); - } else { - lines = text.split('\n').map(textify); - } - let newText = '\n'; - if (lines.length > 0) { - newText = `${lines.join('\n')}\n`; - } - - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocText(newText); - }); - - if (dontProcess && rep.alltext !== text) { - throw new Error('mismatch error setting raw text in importText'); - } - }; - - const importAText = (atext, apoolJsonObj, undoable) => { - atext = Changeset.cloneAText(atext); - if (apoolJsonObj) { - const wireApool = (new AttribPool()).fromJsonable(apoolJsonObj); - atext.attribs = Changeset.moveOpsToNewPool(atext.attribs, wireApool, rep.apool); - } - inCallStackIfNecessary(`importText${undoable ? 'Undoable' : ''}`, () => { - setDocAText(atext); - }); - }; - - const setDocAText = (atext) => { - if (atext.text === '') { - /* - * The server is fine with atext.text being an empty string, but the front - * end is not, and crashes. - * - * It is not clear if this is a problem in the server or in the client - * code, and this is a client-side hack fix. The underlying problem needs - * to be investigated. - * - * See for reference: - * - https://github.com/ether/etherpad-lite/issues/3861 - */ - atext.text = '\n'; - } - - fastIncorp(8); - - const oldLen = rep.lines.totalWidth(); - const numLines = rep.lines.length(); - const upToLastLine = rep.lines.offsetOfIndex(numLines - 1); - const lastLineLength = rep.lines.atIndex(numLines - 1).text.length; - const assem = Changeset.smartOpAssembler(); - const o = new Changeset.Op('-'); - o.chars = upToLastLine; - o.lines = numLines - 1; - assem.append(o); - o.chars = lastLineLength; - o.lines = 0; - assem.append(o); - for (const op of Changeset.opsFromAText(atext)) assem.append(op); - const newLen = oldLen + assem.getLengthChange(); - const changeset = Changeset.checkRep( - Changeset.pack(oldLen, newLen, assem.toString(), atext.text.slice(0, -1))); - performDocumentApplyChangeset(changeset); - - performSelectionChange( - [0, rep.lines.atIndex(0).lineMarker], [0, rep.lines.atIndex(0).lineMarker]); - - idleWorkTimer.atMost(100); - - if (rep.alltext !== atext.text) { - throw new Error('mismatch error setting raw text in setDocAText'); - } - }; - - const setDocText = (text) => { - setDocAText(Changeset.makeAText(text)); - }; - - const getDocText = () => { - const alltext = rep.alltext; - let len = alltext.length; - if (len > 0) len--; // final extra newline - return alltext.substring(0, len); - }; - - const exportText = () => { - if (currentCallStack && !currentCallStack.domClean) { - inCallStackIfNecessary('exportText', () => { - fastIncorp(2); - }); - } - return getDocText(); - }; - - const editorChangedSize = () => fixView(); - - const setOnKeyPress = (handler) => { - outsideKeyPress = handler; - }; - - const setOnKeyDown = (handler) => { - outsideKeyDown = handler; - }; - - const setNotifyDirty = (handler) => { - outsideNotifyDirty = handler; - }; - - const CMDS = { - clearauthorship: (prompt) => { - if ((!(rep.selStart && rep.selEnd)) || isCaret()) { - if (prompt) { - prompt(); - } else { - performDocumentApplyAttributesToCharRange(0, rep.alltext.length, [ - ['author', ''], - ]); - } - } else { - setAttributeOnSelection('author', ''); - } - }, - }; - - const execCommand = (cmd, ...args) => { - cmd = cmd.toLowerCase(); - if (CMDS[cmd]) { - inCallStackIfNecessary(cmd, () => { - fastIncorp(9); - CMDS[cmd](...args); - }); - } - }; - - const replaceRange = (start, end, text) => { - inCallStackIfNecessary('replaceRange', () => { - fastIncorp(9); - performDocumentReplaceRange(start, end, text); - }); - }; - - editorInfo.ace_callWithAce = (fn, callStack, normalize) => { - let wrapper = () => fn(editorInfo); - - if (normalize !== undefined) { - const wrapper1 = wrapper; - wrapper = () => { - editorInfo.ace_fastIncorp(9); - wrapper1(); - }; - } - - if (callStack !== undefined) { - return editorInfo.ace_inCallStack(callStack, wrapper); - } else { - return wrapper(); - } - }; - - /** - * This methed exposes a setter for some ace properties - * @param key the name of the parameter - * @param value the value to set to - */ - editorInfo.ace_setProperty = (key, value) => { - // These properties are exposed - const setters = { - wraps: setWraps, - showsauthorcolors: (val) => document.body.classList.toggle('authorColors', !!val), - showsuserselections: (val) => document.body.classList.toggle('userSelections', !!val), - showslinenumbers: (value) => { - hasLineNumbers = !!value; - sideDiv.parentNode.classList.toggle('line-numbers-hidden', !hasLineNumbers); - fixView(); - }, - userauthor: (value) => { - thisAuthor = String(value); - documentAttributeManager.author = thisAuthor; - }, - styled: setStyled, - textface: setTextFace, - rtlistrue: (value) => { - document.body.classList.toggle('rtl', value); - document.body.classList.toggle('ltr', !value); - document.documentElement.dir = value ? 'rtl' : 'ltr'; - }, - }; - - const setter = setters[key.toLowerCase()]; - - // check if setter is present - if (setter !== undefined) { - setter(value); - } - }; - - editorInfo.ace_setBaseText = (txt) => { - changesetTracker.setBaseText(txt); - }; - editorInfo.ace_setBaseAttributedText = (atxt, apoolJsonObj) => { - changesetTracker.setBaseAttributedText(atxt, apoolJsonObj); - }; - editorInfo.ace_applyChangesToBase = (c, optAuthor, apoolJsonObj) => { - changesetTracker.applyChangesToBase(c, optAuthor, apoolJsonObj); - }; - editorInfo.ace_prepareUserChangeset = () => changesetTracker.prepareUserChangeset(); - editorInfo.ace_applyPreparedChangesetToBase = () => { - changesetTracker.applyPreparedChangesetToBase(); - }; - editorInfo.ace_setUserChangeNotificationCallback = (f) => { - changesetTracker.setUserChangeNotificationCallback(f); - }; - editorInfo.ace_setAuthorInfo = (author, info) => { - setAuthorInfo(author, info); - }; - - editorInfo.ace_getDocument = () => document; - - const now = () => Date.now(); - - const newTimeLimit = (ms) => { - const startTime = now(); - let exceededAlready = false; - let printedTrace = false; - const isTimeUp = () => { - if (exceededAlready) { - if ((!printedTrace)) { - printedTrace = true; - } - return true; - } - const elapsed = now() - startTime; - if (elapsed > ms) { - exceededAlready = true; - return true; - } else { - return false; - } - }; - - isTimeUp.elapsed = () => now() - startTime; - return isTimeUp; - }; - - - const makeIdleAction = (func) => { - let scheduledTimeout = null; - let scheduledTime = 0; - - const unschedule = () => { - if (scheduledTimeout) { - scheduler.clearTimeout(scheduledTimeout); - scheduledTimeout = null; - } - }; - - const reschedule = (time) => { - unschedule(); - scheduledTime = time; - let delay = time - now(); - if (delay < 0) delay = 0; - scheduledTimeout = scheduler.setTimeout(callback, delay); - }; - - const callback = () => { - scheduledTimeout = null; - // func may reschedule the action - func(); - }; - - return { - atMost: (ms) => { - const latestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime > latestTime) { - reschedule(latestTime); - } - }, - // atLeast(ms) will schedule the action if not scheduled yet. - // In other words, "infinity" is replaced by ms, even though - // it is technically larger. - atLeast: (ms) => { - const earliestTime = now() + ms; - if ((!scheduledTimeout) || scheduledTime < earliestTime) { - reschedule(earliestTime); - } - }, - never: () => { - unschedule(); - }, - }; - }; - - const fastIncorp = (n) => { - // normalize but don't do any lexing or anything - incorporateUserChanges(); - }; - editorInfo.ace_fastIncorp = fastIncorp; - - const idleWorkTimer = makeIdleAction(() => { - if (inInternationalComposition) { - // don't do idle input incorporation during international input composition - idleWorkTimer.atLeast(500); - return; - } - - inCallStackIfNecessary('idleWorkTimer', () => { - const isTimeUp = newTimeLimit(250); - - let finishedImportantWork = false; - let finishedWork = false; - - try { - incorporateUserChanges(); - - if (isTimeUp()) return; - - updateLineNumbers(); // update line numbers if any time left - if (isTimeUp()) return; - finishedImportantWork = true; - finishedWork = true; - } finally { - if (finishedWork) { - idleWorkTimer.atMost(1000); - } else if (finishedImportantWork) { - // if we've finished highlighting the view area, - // more highlighting could be counter-productive, - // e.g. if the user just opened a triple-quote and will soon close it. - idleWorkTimer.atMost(500); - } else { - let timeToWait = Math.round(isTimeUp.elapsed() / 2); - if (timeToWait < 100) timeToWait = 100; - idleWorkTimer.atMost(timeToWait); - } - } - }); - }); - - let _nextId = 1; - - const uniqueId = (n) => { - // not actually guaranteed to be unique, e.g. if user copy-pastes - // nodes with ids - const nid = n.id; - if (nid) return nid; - return (n.id = `magicdomid${_nextId++}`); - }; - - - const recolorLinesInRange = (startChar, endChar) => { - if (endChar <= startChar) return; - if (startChar < 0 || startChar >= rep.lines.totalWidth()) return; - let lineEntry = rep.lines.atOffset(startChar); // rounds down to line boundary - let lineStart = rep.lines.offsetOfEntry(lineEntry); - let lineIndex = rep.lines.indexOfEntry(lineEntry); - let selectionNeedsResetting = false; - let firstLine = null; - - // tokenFunc function; accesses current value of lineEntry and curDocChar, - // also mutates curDocChar - const tokenFunc = (tokenText, tokenClass) => { - lineEntry.domInfo.appendSpan(tokenText, tokenClass); - }; - - while (lineEntry && lineStart < endChar) { - const lineEnd = lineStart + lineEntry.width; - lineEntry.domInfo.clearSpans(); - getSpansForLine(lineEntry, tokenFunc, lineStart); - lineEntry.domInfo.finishUpdate(); - - markNodeClean(lineEntry.lineNode); - - if (rep.selStart && rep.selStart[0] === lineIndex || - rep.selEnd && rep.selEnd[0] === lineIndex) { - selectionNeedsResetting = true; - } - - if (firstLine == null) firstLine = lineIndex; - lineStart = lineEnd; - lineEntry = rep.lines.next(lineEntry); - lineIndex++; - } - if (selectionNeedsResetting) { - currentCallStack.selectionAffected = true; - } - }; - - // like getSpansForRange, but for a line, and the func takes (text,class) - // instead of (width,class); excludes the trailing '\n' from - // consideration by func - - - const getSpansForLine = (lineEntry, textAndClassFunc, lineEntryOffsetHint) => { - let lineEntryOffset = lineEntryOffsetHint; - if ((typeof lineEntryOffset) !== 'number') { - lineEntryOffset = rep.lines.offsetOfEntry(lineEntry); - } - const text = lineEntry.text; - if (text.length === 0) { - // allow getLineStyleFilter to set line-div styles - const func = linestylefilter.getLineStyleFilter( - 0, '', textAndClassFunc, rep.apool); - func('', ''); - } else { - let filteredFunc = linestylefilter.getFilterStack(text, textAndClassFunc, browser); - const lineNum = rep.lines.indexOfEntry(lineEntry); - const aline = rep.alines[lineNum]; - filteredFunc = linestylefilter.getLineStyleFilter( - text.length, aline, filteredFunc, rep.apool); - filteredFunc(text, ''); - } - }; - - let observedChanges; - - const clearObservedChanges = () => { - observedChanges = { - cleanNodesNearChanges: {}, - }; - }; - clearObservedChanges(); - - const getCleanNodeByKey = (key) => { - let n = document.getElementById(key); - // copying and pasting can lead to duplicate ids - while (n && isNodeDirty(n)) { - n.id = ''; - n = document.getElementById(key); - } - return n; - }; - - const observeChangesAroundNode = (node) => { - // Around this top-level DOM node, look for changes to the document - // (from how it looks in our representation) and record them in a way - // that can be used to "normalize" the document (apply the changes to our - // representation, and put the DOM in a canonical form). - let cleanNode; - let hasAdjacentDirtyness; - if (!isNodeDirty(node)) { - cleanNode = node; - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - hasAdjacentDirtyness = ((prevSib && isNodeDirty(prevSib)) || - (nextSib && isNodeDirty(nextSib))); - } else { - // node is dirty, look for clean node above - let upNode = node.previousSibling; - while (upNode && isNodeDirty(upNode)) { - upNode = upNode.previousSibling; - } - if (upNode) { - cleanNode = upNode; - } else { - let downNode = node.nextSibling; - while (downNode && isNodeDirty(downNode)) { - downNode = downNode.nextSibling; - } - if (downNode) { - cleanNode = downNode; - } - } - if (!cleanNode) { - // Couldn't find any adjacent clean nodes! - // Since top and bottom of doc is dirty, the dirty area will be detected. - return; - } - hasAdjacentDirtyness = true; - } - - if (hasAdjacentDirtyness) { - // previous or next line is dirty - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } else { - // next and prev lines are clean (if they exist) - const lineKey = uniqueId(cleanNode); - const prevSib = cleanNode.previousSibling; - const nextSib = cleanNode.nextSibling; - const actualPrevKey = ((prevSib && uniqueId(prevSib)) || null); - const actualNextKey = ((nextSib && uniqueId(nextSib)) || null); - const repPrevEntry = rep.lines.prev(rep.lines.atKey(lineKey)); - const repNextEntry = rep.lines.next(rep.lines.atKey(lineKey)); - const repPrevKey = ((repPrevEntry && repPrevEntry.key) || null); - const repNextKey = ((repNextEntry && repNextEntry.key) || null); - if (actualPrevKey !== repPrevKey || actualNextKey !== repNextKey) { - observedChanges.cleanNodesNearChanges[`$${uniqueId(cleanNode)}`] = true; - } - } - }; - - const observeChangesAroundSelection = () => { - if (currentCallStack.observedSelection) return; - currentCallStack.observedSelection = true; - - const selection = getSelection(); - - if (selection) { - const node1 = topLevel(selection.startPoint.node); - const node2 = topLevel(selection.endPoint.node); - if (node1) observeChangesAroundNode(node1); - if (node2 && node1 !== node2) { - observeChangesAroundNode(node2); - } - } - }; - - const observeSuspiciousNodes = () => { - // inspired by Firefox bug #473255, where pasting formatted text - // causes the cursor to jump away, making the new HTML never found. - if (document.body.getElementsByTagName) { - const elts = document.body.getElementsByTagName('style'); - for (const elt of elts) { - const n = topLevel(elt); - if (n && n.parentNode === document.body) { - observeChangesAroundNode(n); - } - } - } - }; - - const incorporateUserChanges = () => { - if (currentCallStack.domClean) return false; - - currentCallStack.isUserChange = true; - - if (DEBUG && window.DONT_INCORP || window.DEBUG_DONT_INCORP) return false; - - // returns true if dom changes were made - if (!document.body.firstChild) { - document.body.innerHTML = '
'; - } - - observeChangesAroundSelection(); - observeSuspiciousNodes(); - let dirtyRanges = getDirtyRanges(); - let dirtyRangesCheckOut = true; - let j = 0; - let a, b; - let scrollToTheLeftNeeded = false; - - while (j < dirtyRanges.length) { - a = dirtyRanges[j][0]; - b = dirtyRanges[j][1]; - if (!((a === 0 || getCleanNodeByKey(rep.lines.atIndex(a - 1).key)) && - (b === rep.lines.length() || getCleanNodeByKey(rep.lines.atIndex(b).key)))) { - dirtyRangesCheckOut = false; - break; - } - j++; - } - if (!dirtyRangesCheckOut) { - for (const bodyNode of document.body.childNodes) { - if ((bodyNode.tagName) && ((!bodyNode.id) || (!rep.lines.containsKey(bodyNode.id)))) { - observeChangesAroundNode(bodyNode); - } - } - dirtyRanges = getDirtyRanges(); - } - - clearObservedChanges(); - - const selection = getSelection(); - - let selStart, selEnd; // each one, if truthy, has [line,char] needed to set selection - let i = 0; - const splicesToDo = []; - let netNumLinesChangeSoFar = 0; - const toDeleteAtEnd = []; - const domInsertsNeeded = []; // each entry is [nodeToInsertAfter, [info1, info2, ...]] - while (i < dirtyRanges.length) { - const range = dirtyRanges[i]; - a = range[0]; - b = range[1]; - let firstDirtyNode = (((a === 0) && document.body.firstChild) || - getCleanNodeByKey(rep.lines.atIndex(a - 1).key).nextSibling); - firstDirtyNode = (firstDirtyNode && isNodeDirty(firstDirtyNode) && firstDirtyNode); - - let lastDirtyNode = (((b === rep.lines.length()) && document.body.lastChild) || - getCleanNodeByKey(rep.lines.atIndex(b).key).previousSibling); - - lastDirtyNode = (lastDirtyNode && isNodeDirty(lastDirtyNode) && lastDirtyNode); - if (firstDirtyNode && lastDirtyNode) { - const cc = makeContentCollector(isStyled, browser, rep.apool, className2Author); - cc.notifySelection(selection); - const dirtyNodes = []; - for (let n = firstDirtyNode; n && - !(n.previousSibling && n.previousSibling === lastDirtyNode); - n = n.nextSibling) { - cc.collectContent(n); - dirtyNodes.push(n); - } - cc.notifyNextNode(lastDirtyNode.nextSibling); - let lines = cc.getLines(); - if ((lines.length <= 1 || lines[lines.length - 1] !== '') && lastDirtyNode.nextSibling) { - // dirty region doesn't currently end a line, even taking the following node - // (or lack of node) into account, so include the following clean node. - // It could be SPAN or a DIV; basically this is any case where the contentCollector - // decides it isn't done. - // Note that this clean node might need to be there for the next dirty range. - b++; - const cleanLine = lastDirtyNode.nextSibling; - cc.collectContent(cleanLine); - toDeleteAtEnd.push(cleanLine); - cc.notifyNextNode(cleanLine.nextSibling); - } - - const ccData = cc.finish(); - const ss = ccData.selStart; - const se = ccData.selEnd; - lines = ccData.lines; - const lineAttribs = ccData.lineAttribs; - const linesWrapped = ccData.linesWrapped; - - if (linesWrapped > 0) { - // Chrome decides in its infinite wisdom that it's okay to put the browser's visisble - // window in the middle of the span. An outcome of this is that the first chars of the - // string are no longer visible to the user.. Yay chrome.. Move the browser's visible area - // to the left hand side of the span. Firefox isn't quite so bad, but it's still pretty - // quirky. - scrollToTheLeftNeeded = true; - } - - if (ss[0] >= 0) selStart = [ss[0] + a + netNumLinesChangeSoFar, ss[1]]; - if (se[0] >= 0) selEnd = [se[0] + a + netNumLinesChangeSoFar, se[1]]; - - const entries = []; - const nodeToAddAfter = lastDirtyNode; - const lineNodeInfos = []; - for (const lineString of lines) { - const newEntry = createDomLineEntry(lineString); - entries.push(newEntry); - lineNodeInfos.push(newEntry.domInfo); - } - domInsertsNeeded.push([nodeToAddAfter, lineNodeInfos]); - for (const n of dirtyNodes) toDeleteAtEnd.push(n); - const spliceHints = {}; - if (selStart) spliceHints.selStart = selStart; - if (selEnd) spliceHints.selEnd = selEnd; - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, entries, lineAttribs, spliceHints]); - netNumLinesChangeSoFar += (lines.length - (b - a)); - } else if (b > a) { - splicesToDo.push([a + netNumLinesChangeSoFar, b - a, [], []]); - } - i++; - } - - const domChanges = (splicesToDo.length > 0); - - for (const splice of splicesToDo) doIncorpLineSplice(...splice); - for (const ins of domInsertsNeeded) insertDomLines(...ins); - for (const n of toDeleteAtEnd) n.remove(); - - // needed to stop chrome from breaking the ui when long strings without spaces are pasted - if (scrollToTheLeftNeeded) { - $('#innerdocbody').scrollLeft(0); - } - - // if the nodes that define the selection weren't encountered during - // content collection, figure out where those nodes are now. - if (selection && !selStart) { - const selStartFromHook = hooks.callAll('aceStartLineAndCharForPoint', { - callstack: currentCallStack, - editorInfo, - rep, - root: document.body, - point: selection.startPoint, - documentAttributeManager, - }); - selStart = (selStartFromHook == null || selStartFromHook.length === 0) - ? getLineAndCharForPoint(selection.startPoint) : selStartFromHook; - } - if (selection && !selEnd) { - const selEndFromHook = hooks.callAll('aceEndLineAndCharForPoint', { - callstack: currentCallStack, - editorInfo, - rep, - root: document.body, - point: selection.endPoint, - documentAttributeManager, - }); - selEnd = (selEndFromHook == null || - selEndFromHook.length === 0) - ? getLineAndCharForPoint(selection.endPoint) : selEndFromHook; - } - - // selection from content collection can, in various ways, extend past final - // BR in firefox DOM, so cap the line - const numLines = rep.lines.length(); - if (selStart && selStart[0] >= numLines) { - selStart[0] = numLines - 1; - selStart[1] = rep.lines.atIndex(selStart[0]).text.length; - } - if (selEnd && selEnd[0] >= numLines) { - selEnd[0] = numLines - 1; - selEnd[1] = rep.lines.atIndex(selEnd[0]).text.length; - } - - // update rep if we have a new selection - // NOTE: IE loses the selection when you click stuff in e.g. the - // editbar, so removing the selection when it's lost is not a good - // idea. - if (selection) repSelectionChange(selStart, selEnd, selection && selection.focusAtStart); - // update browser selection - if (selection && (domChanges || isCaret())) { - // if no DOM changes (not this case), want to treat range selection delicately, - // e.g. in IE not lose which end of the selection is the focus/anchor; - // on the other hand, we may have just noticed a press of PageUp/PageDown - currentCallStack.selectionAffected = true; - } - - currentCallStack.domClean = true; - - fixView(); - - return domChanges; - }; - - const STYLE_ATTRIBS = { - bold: true, - italic: true, - underline: true, - strikethrough: true, - list: true, - }; - - const isStyleAttribute = (aname) => !!STYLE_ATTRIBS[aname]; - - const isDefaultLineAttribute = - (aname) => AttributeManager.DEFAULT_LINE_ATTRIBUTES.indexOf(aname) !== -1; - - const insertDomLines = (nodeToAddAfter, infoStructs) => { - let lastEntry; - let lineStartOffset; - for (const info of infoStructs) { - const node = info.node; - const key = uniqueId(node); - let entry; - if (lastEntry) { - // optimization to avoid recalculation - const next = rep.lines.next(lastEntry); - if (next && next.key === key) { - entry = next; - lineStartOffset += lastEntry.width; - } - } - if (!entry) { - entry = rep.lines.atKey(key); - lineStartOffset = rep.lines.offsetOfKey(key); - } - lastEntry = entry; - getSpansForLine(entry, (tokenText, tokenClass) => { - info.appendSpan(tokenText, tokenClass); - }, lineStartOffset); - info.prepareForAdd(); - entry.lineMarker = info.lineMarker; - if (!nodeToAddAfter) { - document.body.insertBefore(node, document.body.firstChild); - } else { - document.body.insertBefore(node, nodeToAddAfter.nextSibling); - } - nodeToAddAfter = node; - info.notifyAdded(); - markNodeClean(node); - } - }; - - const isCaret = () => (rep.selStart && rep.selEnd && - rep.selStart[0] === rep.selEnd[0] && rep.selStart[1] === rep.selEnd[1]); - editorInfo.ace_isCaret = isCaret; - - // prereq: isCaret() - const caretLine = () => rep.selStart[0]; - - editorInfo.ace_caretLine = caretLine; - - const caretColumn = () => rep.selStart[1]; - - editorInfo.ace_caretColumn = caretColumn; - - const caretDocChar = () => rep.lines.offsetOfIndex(caretLine()) + caretColumn(); - - editorInfo.ace_caretDocChar = caretDocChar; - - const handleReturnIndentation = () => { - // on return, indent to level of previous line - if (isCaret() && caretColumn() === 0 && caretLine() > 0) { - const lineNum = caretLine(); - const thisLine = rep.lines.atIndex(lineNum); - const prevLine = rep.lines.prev(thisLine); - const prevLineText = prevLine.text; - let theIndent = /^ *(?:)/.exec(prevLineText)[0]; - const shouldIndent = parent.parent.clientVars.indentationOnNewLine; - if (shouldIndent && /[[(:{]\s*$/.exec(prevLineText)) { - theIndent += THE_TAB; - } - const cs = Changeset.builder(rep.lines.totalWidth()).keep( - rep.lines.offsetOfIndex(lineNum), lineNum).insert( - theIndent, [ - ['author', thisAuthor], - ], rep.apool).toString(); - performDocumentApplyChangeset(cs); - performSelectionChange([lineNum, theIndent.length], [lineNum, theIndent.length]); - } - }; - - const getPointForLineAndChar = (lineAndChar) => { - const line = lineAndChar[0]; - let charsLeft = lineAndChar[1]; - const lineEntry = rep.lines.atIndex(line); - charsLeft -= lineEntry.lineMarker; - if (charsLeft < 0) { - charsLeft = 0; - } - const lineNode = lineEntry.lineNode; - let n = lineNode; - let after = false; - if (charsLeft === 0) { - return { - node: lineNode, - index: 0, - maxIndex: 1, - }; - } - while (!(n === lineNode && after)) { - if (after) { - if (n.nextSibling) { - n = n.nextSibling; - after = false; - } else { n = n.parentNode; } - } else if (isNodeText(n)) { - const len = n.nodeValue.length; - if (charsLeft <= len) { - return { - node: n, - index: charsLeft, - maxIndex: len, - }; - } - charsLeft -= len; - after = true; - } else if (n.firstChild) { n = n.firstChild; } else { after = true; } - } - return { - node: lineNode, - index: 1, - maxIndex: 1, - }; - }; - - const nodeText = (n) => n.textContent || n.nodeValue || ''; - - const getLineAndCharForPoint = (point) => { - // Turn DOM node selection into [line,char] selection. - // This method has to work when the DOM is not pristine, - // assuming the point is not in a dirty node. - if (point.node === document.body) { - if (point.index === 0) { - return [0, 0]; - } else { - const N = rep.lines.length(); - const ln = rep.lines.atIndex(N - 1); - return [N - 1, ln.text.length]; - } - } else { - let n = point.node; - let col = 0; - // if this part fails, it probably means the selection node - // was dirty, and we didn't see it when collecting dirty nodes. - if (isNodeText(n)) { - col = point.index; - } else if (point.index > 0) { - col = nodeText(n).length; - } - let parNode, prevSib; - while ((parNode = n.parentNode) !== document.body) { - if ((prevSib = n.previousSibling)) { - n = prevSib; - col += nodeText(n).length; - } else { - n = parNode; - } - } - if (n.firstChild && isBlockElement(n.firstChild)) { - col += 1; // lineMarker - } - const lineEntry = rep.lines.atKey(n.id); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, col]; - } - }; - editorInfo.ace_getLineAndCharForPoint = getLineAndCharForPoint; - - const createDomLineEntry = (lineString) => { - const info = doCreateDomLine(lineString.length > 0); - const newNode = info.node; - return { - key: uniqueId(newNode), - text: lineString, - lineNode: newNode, - domInfo: info, - lineMarker: 0, - }; - }; - - const performDocumentApplyChangeset = (changes, insertsAfterSelection) => { - const domAndRepSplice = (startLine, deleteCount, newLineStrings) => { - const keysToDelete = []; - if (deleteCount > 0) { - let entryToDelete = rep.lines.atIndex(startLine); - for (let i = 0; i < deleteCount; i++) { - keysToDelete.push(entryToDelete.key); - entryToDelete = rep.lines.next(entryToDelete); - } - } - - const lineEntries = newLineStrings.map(createDomLineEntry); - - doRepLineSplice(startLine, deleteCount, lineEntries); - - let nodeToAddAfter; - if (startLine > 0) { - nodeToAddAfter = getCleanNodeByKey(rep.lines.atIndex(startLine - 1).key); - } else { nodeToAddAfter = null; } - - insertDomLines(nodeToAddAfter, lineEntries.map((entry) => entry.domInfo)); - - for (const k of keysToDelete) { - const n = document.getElementById(k); - n.parentNode.removeChild(n); - } - - if ( - (rep.selStart && - rep.selStart[0] >= startLine && - rep.selStart[0] <= startLine + deleteCount) || - (rep.selEnd && rep.selEnd[0] >= startLine && rep.selEnd[0] <= startLine + deleteCount)) { - currentCallStack.selectionAffected = true; - } - }; - - doRepApplyChangeset(changes, insertsAfterSelection); - - let requiredSelectionSetting = null; - if (rep.selStart && rep.selEnd) { - const selStartChar = rep.lines.offsetOfIndex(rep.selStart[0]) + rep.selStart[1]; - const selEndChar = rep.lines.offsetOfIndex(rep.selEnd[0]) + rep.selEnd[1]; - const result = - Changeset.characterRangeFollow(changes, selStartChar, selEndChar, insertsAfterSelection); - requiredSelectionSetting = [result[0], result[1], rep.selFocusAtStart]; - } - - const linesMutatee = { - splice: (start, numRemoved, ...args) => { - domAndRepSplice(start, numRemoved, args.map((s) => s.slice(0, -1))); - }, - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }; - - Changeset.mutateTextLines(changes, linesMutatee); - - if (requiredSelectionSetting) { - performSelectionChange( - lineAndColumnFromChar(requiredSelectionSetting[0]), - lineAndColumnFromChar(requiredSelectionSetting[1]), - requiredSelectionSetting[2]); - } - }; - - const doRepApplyChangeset = (changes, insertsAfterSelection) => { - Changeset.checkRep(changes); - - if (Changeset.oldLen(changes) !== rep.alltext.length) { - const errMsg = `${Changeset.oldLen(changes)}/${rep.alltext.length}`; - throw new Error(`doRepApplyChangeset length mismatch: ${errMsg}`); - } - - const editEvent = currentCallStack.editEvent; - if (editEvent.eventType === 'nonundoable') { - if (!editEvent.changeset) { - editEvent.changeset = changes; - } else { - editEvent.changeset = Changeset.compose(editEvent.changeset, changes, rep.apool); - } - } else { - const inverseChangeset = Changeset.inverse(changes, { - get: (i) => `${rep.lines.atIndex(i).text}\n`, - length: () => rep.lines.length(), - }, rep.alines, rep.apool); - - if (!editEvent.backset) { - editEvent.backset = inverseChangeset; - } else { - editEvent.backset = Changeset.compose(inverseChangeset, editEvent.backset, rep.apool); - } - } - - Changeset.mutateAttributionLines(changes, rep.alines, rep.apool); - - if (changesetTracker.isTracking()) { - changesetTracker.composeUserChangeset(changes); - } - }; - - /** - * Converts the position of a char (index in String) into a [row, col] tuple - */ - const lineAndColumnFromChar = (x) => { - const lineEntry = rep.lines.atOffset(x); - const lineStart = rep.lines.offsetOfEntry(lineEntry); - const lineNum = rep.lines.indexOfEntry(lineEntry); - return [lineNum, x - lineStart]; - }; - - const performDocumentReplaceCharRange = (startChar, endChar, newText) => { - if (startChar === endChar && newText.length === 0) { - return; - } - // Requires that the replacement preserve the property that the - // internal document text ends in a newline. Given this, we - // rewrite the splice so that it doesn't touch the very last - // char of the document. - if (endChar === rep.alltext.length) { - if (startChar === endChar) { - // an insert at end - startChar--; - endChar--; - newText = `\n${newText.substring(0, newText.length - 1)}`; - } else if (newText.length === 0) { - // a delete at end - startChar--; - endChar--; - } else { - // a replace at end - endChar--; - newText = newText.substring(0, newText.length - 1); - } - } - performDocumentReplaceRange( - lineAndColumnFromChar(startChar), lineAndColumnFromChar(endChar), newText); - }; - - const performDocumentApplyAttributesToCharRange = (start, end, attribs) => { - end = Math.min(end, rep.alltext.length - 1); - documentAttributeManager.setAttributesOnRange( - lineAndColumnFromChar(start), lineAndColumnFromChar(end), attribs); - }; - - editorInfo.ace_performDocumentApplyAttributesToCharRange = - performDocumentApplyAttributesToCharRange; - - const setAttributeOnSelection = (attributeName, attributeValue) => { - if (!(rep.selStart && rep.selEnd)) return; - - documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ - [attributeName, attributeValue], - ]); - }; - editorInfo.ace_setAttributeOnSelection = setAttributeOnSelection; - - const getAttributeOnSelection = (attributeName, prevChar) => { - if (!(rep.selStart && rep.selEnd)) return; - const isNotSelection = (rep.selStart[0] === rep.selEnd[0] && rep.selEnd[1] === rep.selStart[1]); - if (isNotSelection) { - if (prevChar) { - // If it's not the start of the line - if (rep.selStart[1] !== 0) { - rep.selStart[1]--; - } - } - } - - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - const hasIt = (attribs) => withItRegex.test(attribs); - - const rangeHasAttrib = (selStart, selEnd) => { - // if range is collapsed -> no attribs in range - if (selStart[1] === selEnd[1] && selStart[0] === selEnd[0]) return false; - - if (selStart[0] !== selEnd[0]) { // -> More than one line selected - let hasAttrib = true; - - // from selStart to the end of the first line - hasAttrib = hasAttrib && - rangeHasAttrib(selStart, [selStart[0], rep.lines.atIndex(selStart[0]).text.length]); - - // for all lines in between - for (let n = selStart[0] + 1; n < selEnd[0]; n++) { - hasAttrib = hasAttrib && rangeHasAttrib([n, 0], [n, rep.lines.atIndex(n).text.length]); - } - - // for the last, potentially partial, line - hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]); - - return hasAttrib; - } - - // Logic tells us we now have a range on a single line - - const lineNum = selStart[0]; - const start = selStart[1]; - const end = selEnd[1]; - let hasAttrib = true; - - let indexIntoLine = 0; - for (const op of Changeset.deserializeOps(rep.alines[lineNum])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= start || opStartInLine >= end)) { - // since it's overlapping but hasn't got the attrib -> range hasn't got it - hasAttrib = false; - break; - } - } - indexIntoLine = opEndInLine; - } - - return hasAttrib; - }; - return rangeHasAttrib(rep.selStart, rep.selEnd); - }; - - editorInfo.ace_getAttributeOnSelection = getAttributeOnSelection; - - const toggleAttributeOnSelection = (attributeName) => { - if (!(rep.selStart && rep.selEnd)) return; - - let selectionAllHasIt = true; - const withIt = new AttributeMap(rep.apool).set(attributeName, 'true').toString(); - const withItRegex = new RegExp(`${withIt.replace(/\*/g, '\\*')}(\\*|$)`); - - const hasIt = (attribs) => withItRegex.test(attribs); - - const selStartLine = rep.selStart[0]; - const selEndLine = rep.selEnd[0]; - for (let n = selStartLine; n <= selEndLine; n++) { - let indexIntoLine = 0; - let selectionStartInLine = 0; - if (documentAttributeManager.lineHasMarker(n)) { - selectionStartInLine = 1; // ignore "*" used as line marker - } - let selectionEndInLine = rep.lines.atIndex(n).text.length; // exclude newline - if (n === selStartLine) { - selectionStartInLine = rep.selStart[1]; - } - if (n === selEndLine) { - selectionEndInLine = rep.selEnd[1]; - } - for (const op of Changeset.deserializeOps(rep.alines[n])) { - const opStartInLine = indexIntoLine; - const opEndInLine = opStartInLine + op.chars; - if (!hasIt(op.attribs)) { - // does op overlap selection? - if (!(opEndInLine <= selectionStartInLine || opStartInLine >= selectionEndInLine)) { - selectionAllHasIt = false; - break; - } - } - indexIntoLine = opEndInLine; - } - if (!selectionAllHasIt) { - break; - } - } - - - const attributeValue = selectionAllHasIt ? '' : 'true'; - documentAttributeManager.setAttributesOnRange( - rep.selStart, rep.selEnd, [[attributeName, attributeValue]]); - if (attribIsFormattingStyle(attributeName)) { - updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ... - } - }; - editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection; - - const performDocumentReplaceSelection = (newText) => { - if (!(rep.selStart && rep.selEnd)) return; - performDocumentReplaceRange(rep.selStart, rep.selEnd, newText); - }; - - // Change the abstract representation of the document to have a different set of lines. - // Must be called after rep.alltext is set. - const doRepLineSplice = (startLine, deleteCount, newLineEntries) => { - for (const entry of newLineEntries) entry.width = entry.text.length + 1; - - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - rep.lines.splice(startLine, deleteCount, newLineEntries); - currentCallStack.docTextChanged = true; - currentCallStack.repChanged = true; - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - - rep.alltext = rep.alltext.substring(0, startOldChar) + - newText + rep.alltext.substring(endOldChar, rep.alltext.length); - }; - - const doIncorpLineSplice = (startLine, deleteCount, newLineEntries, lineAttribs, hints) => { - const startOldChar = rep.lines.offsetOfIndex(startLine); - const endOldChar = rep.lines.offsetOfIndex(startLine + deleteCount); - - const oldRegionStart = rep.lines.offsetOfIndex(startLine); - - let selStartHintChar, selEndHintChar; - if (hints && hints.selStart) { - selStartHintChar = - rep.lines.offsetOfIndex(hints.selStart[0]) + hints.selStart[1] - oldRegionStart; - } - if (hints && hints.selEnd) { - selEndHintChar = rep.lines.offsetOfIndex(hints.selEnd[0]) + hints.selEnd[1] - oldRegionStart; - } - - const newText = newLineEntries.map((e) => `${e.text}\n`).join(''); - const oldText = rep.alltext.substring(startOldChar, endOldChar); - const oldAttribs = rep.alines.slice(startLine, startLine + deleteCount).join(''); - const newAttribs = `${lineAttribs.join('|1+1')}|1+1`; // not valid in a changeset - const analysis = - analyzeChange(oldText, newText, oldAttribs, newAttribs, selStartHintChar, selEndHintChar); - const commonStart = analysis[0]; - let commonEnd = analysis[1]; - let shortOldText = oldText.substring(commonStart, oldText.length - commonEnd); - let shortNewText = newText.substring(commonStart, newText.length - commonEnd); - let spliceStart = startOldChar + commonStart; - let spliceEnd = endOldChar - commonEnd; - let shiftFinalNewlineToBeforeNewText = false; - - // adjust the splice to not involve the final newline of the document; - // be very defensive - if (shortOldText.charAt(shortOldText.length - 1) === '\n' && - shortNewText.charAt(shortNewText.length - 1) === '\n') { - // replacing text that ends in newline with text that also ends in newline - // (still, after analysis, somehow) - shortOldText = shortOldText.slice(0, -1); - shortNewText = shortNewText.slice(0, -1); - spliceEnd--; - commonEnd++; - } - if (shortOldText.length === 0 && - spliceStart === rep.alltext.length && - shortNewText.length > 0) { - // inserting after final newline, bad - spliceStart--; - spliceEnd--; - shortNewText = `\n${shortNewText.slice(0, -1)}`; - shiftFinalNewlineToBeforeNewText = true; - } - if (spliceEnd === rep.alltext.length && - shortOldText.length > 0 && - shortNewText.length === 0) { - // deletion at end of rep.alltext - if (rep.alltext.charAt(spliceStart - 1) === '\n') { - // (if not then what the heck? it will definitely lead - // to a rep.alltext without a final newline) - spliceStart--; - spliceEnd--; - } - } - - if (!(shortOldText.length === 0 && shortNewText.length === 0)) { - const oldDocText = rep.alltext; - const oldLen = oldDocText.length; - - const spliceStartLine = rep.lines.indexOfOffset(spliceStart); - const spliceStartLineStart = rep.lines.offsetOfIndex(spliceStartLine); - - const startBuilder = () => { - const builder = Changeset.builder(oldLen); - builder.keep(spliceStartLineStart, spliceStartLine); - builder.keep(spliceStart - spliceStartLineStart); - return builder; - }; - - const eachAttribRun = (attribs, func /* (startInNewText, endInNewText, attribs)*/) => { - let textIndex = 0; - const newTextStart = commonStart; - const newTextEnd = newText.length - commonEnd - (shiftFinalNewlineToBeforeNewText ? 1 : 0); - for (const op of Changeset.deserializeOps(attribs)) { - const nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { - func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); - } - textIndex = nextIndex; - } - }; - - const justApplyStyles = (shortNewText === shortOldText); - let theChangeset; - - if (justApplyStyles) { - // create changeset that clears the incorporated styles on - // the existing text. we compose this with the - // changeset the applies the styles found in the DOM. - // This allows us to incorporate, e.g., Safari's native "unbold". - const incorpedAttribClearer = cachedStrFunc( - (oldAtts) => Changeset.mapAttribNumbers(oldAtts, (n) => { - const k = rep.apool.getAttribKey(n); - if (isStyleAttribute(k)) { - return rep.apool.putAttrib([k, '']); - } - return false; - })); - - const builder1 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder1.keep(1, 1); - } - eachAttribRun(oldAttribs, (start, end, attribs) => { - builder1.keepText(newText.substring(start, end), incorpedAttribClearer(attribs)); - }); - const clearer = builder1.toString(); - - const builder2 = startBuilder(); - if (shiftFinalNewlineToBeforeNewText) { - builder2.keep(1, 1); - } - eachAttribRun(newAttribs, (start, end, attribs) => { - builder2.keepText(newText.substring(start, end), attribs); - }); - const styler = builder2.toString(); - - theChangeset = Changeset.compose(clearer, styler, rep.apool); - } else { - const builder = startBuilder(); - - const spliceEndLine = rep.lines.indexOfOffset(spliceEnd); - const spliceEndLineStart = rep.lines.offsetOfIndex(spliceEndLine); - if (spliceEndLineStart > spliceStart) { - builder.remove(spliceEndLineStart - spliceStart, spliceEndLine - spliceStartLine); - builder.remove(spliceEnd - spliceEndLineStart); - } else { - builder.remove(spliceEnd - spliceStart); - } - - let isNewTextMultiauthor = false; - const authorizer = cachedStrFunc((oldAtts) => { - const attribs = AttributeMap.fromString(oldAtts, rep.apool); - if (!isNewTextMultiauthor || !attribs.has('author')) attribs.set('author', thisAuthor); - return attribs.toString(); - }); - - let foundDomAuthor = ''; - eachAttribRun(newAttribs, (start, end, attribs) => { - const a = AttributeMap.fromString(attribs, rep.apool).get('author'); - if (a && a !== foundDomAuthor) { - if (!foundDomAuthor) { - foundDomAuthor = a; - } else { - isNewTextMultiauthor = true; // multiple authors in DOM! - } - } - }); - - if (shiftFinalNewlineToBeforeNewText) { - builder.insert('\n', authorizer('')); - } - - eachAttribRun(newAttribs, (start, end, attribs) => { - builder.insert(newText.substring(start, end), authorizer(attribs)); - }); - theChangeset = builder.toString(); - } - - doRepApplyChangeset(theChangeset); - } - - // do this no matter what, because we need to get the right - // line keys into the rep. - doRepLineSplice(startLine, deleteCount, newLineEntries); - }; - - const cachedStrFunc = (func) => { - const cache = {}; - return (s) => { - if (!cache[s]) { - cache[s] = func(s); - } - return cache[s]; - }; - }; - - const analyzeChange = ( - oldText, newText, oldAttribs, newAttribs, optSelStartHint, optSelEndHint) => { - // we need to take into account both the styles attributes & attributes defined by - // the plugins, so basically we can ignore only the default line attribs used by - // Etherpad - const incorpedAttribFilter = (anum) => !isDefaultLineAttribute(rep.apool.getAttribKey(anum)); - - const attribRuns = (attribs) => { - const lengs = []; - const atts = []; - for (const op of Changeset.deserializeOps(attribs)) { - lengs.push(op.chars); - atts.push(op.attribs); - } - return [lengs, atts]; - }; - - const attribIterator = (runs, backward) => { - const lengs = runs[0]; - const atts = runs[1]; - let i = (backward ? lengs.length - 1 : 0); - let j = 0; - const next = () => { - while (j >= lengs[i]) { - if (backward) i--; - else i++; - j = 0; - } - const a = atts[i]; - j++; - return a; - }; - return next; - }; - - const oldLen = oldText.length; - const newLen = newText.length; - const minLen = Math.min(oldLen, newLen); - - const oldARuns = attribRuns(Changeset.filterAttribNumbers(oldAttribs, incorpedAttribFilter)); - const newARuns = attribRuns(Changeset.filterAttribNumbers(newAttribs, incorpedAttribFilter)); - - let commonStart = 0; - const oldStartIter = attribIterator(oldARuns, false); - const newStartIter = attribIterator(newARuns, false); - while (commonStart < minLen) { - if (oldText.charAt(commonStart) === newText.charAt(commonStart) && - oldStartIter() === newStartIter()) { - commonStart++; - } else { break; } - } - - let commonEnd = 0; - const oldEndIter = attribIterator(oldARuns, true); - const newEndIter = attribIterator(newARuns, true); - while (commonEnd < minLen) { - if (commonEnd === 0) { - // assume newline in common - oldEndIter(); - newEndIter(); - commonEnd++; - } else if ( - oldText.charAt(oldLen - 1 - commonEnd) === newText.charAt(newLen - 1 - commonEnd) && - oldEndIter() === newEndIter()) { - commonEnd++; - } else { break; } - } - - let hintedCommonEnd = -1; - if ((typeof optSelEndHint) === 'number') { - hintedCommonEnd = newLen - optSelEndHint; - } - - - if (commonStart + commonEnd > oldLen) { - // ambiguous insertion - const minCommonEnd = oldLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = oldLen - commonEnd; - } - if (commonStart + commonEnd > newLen) { - // ambiguous deletion - const minCommonEnd = newLen - commonStart; - const maxCommonEnd = commonEnd; - if (hintedCommonEnd >= minCommonEnd && hintedCommonEnd <= maxCommonEnd) { - commonEnd = hintedCommonEnd; - } else { - commonEnd = minCommonEnd; - } - commonStart = newLen - commonEnd; - } - - return [commonStart, commonEnd]; - }; - - const equalLineAndChars = (a, b) => { - if (!a) return !b; - if (!b) return !a; - return (a[0] === b[0] && a[1] === b[1]); - }; - - const performSelectionChange = (selectStart, selectEnd, focusAtStart) => { - if (repSelectionChange(selectStart, selectEnd, focusAtStart)) { - currentCallStack.selectionAffected = true; - } - }; - editorInfo.ace_performSelectionChange = performSelectionChange; - - // Change the abstract representation of the document to have a different selection. - // Should not rely on the line representation. Should not affect the DOM. - - - const repSelectionChange = (selectStart, selectEnd, focusAtStart) => { - focusAtStart = !!focusAtStart; - - const newSelFocusAtStart = (focusAtStart && ((!selectStart) || - (!selectEnd) || - (selectStart[0] !== selectEnd[0]) || - (selectStart[1] !== selectEnd[1]))); - - if ((!equalLineAndChars(rep.selStart, selectStart)) || - (!equalLineAndChars(rep.selEnd, selectEnd)) || - (rep.selFocusAtStart !== newSelFocusAtStart)) { - rep.selStart = selectStart; - rep.selEnd = selectEnd; - rep.selFocusAtStart = newSelFocusAtStart; - currentCallStack.repChanged = true; - - // select the formatting buttons when there is the style applied on selection - selectFormattingButtonIfLineHasStyleApplied(rep); - - hooks.callAll('aceSelectionChanged', { - rep, - callstack: currentCallStack, - documentAttributeManager, - }); - - // we scroll when user places the caret at the last line of the pad - // when this settings is enabled - const docTextChanged = currentCallStack.docTextChanged; - if (!docTextChanged) { - const isScrollableEvent = !isPadLoading(currentCallStack.type) && - isScrollableEditEvent(currentCallStack.type); - const innerHeight = getInnerHeight(); - scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary( - rep, isScrollableEvent, innerHeight * 2); - } - - return true; - } - return false; - }; - - const isPadLoading = (t) => t === 'setup' || t === 'setBaseText' || t === 'importText'; - - const updateStyleButtonState = (attribName, hasStyleOnRepSelection) => { - const $formattingButton = parent.parent.$(`[data-key="${attribName}"]`).find('a'); - $formattingButton.toggleClass(SELECT_BUTTON_CLASS, hasStyleOnRepSelection); - }; - - const attribIsFormattingStyle = (attribName) => FORMATTING_STYLES.indexOf(attribName) !== -1; - - const selectFormattingButtonIfLineHasStyleApplied = (rep) => { - for (const style of FORMATTING_STYLES) { - const hasStyleOnRepSelection = - documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style); - updateStyleButtonState(style, hasStyleOnRepSelection); - } - }; - - const doCreateDomLine = - (nonEmpty) => domline.createDomLine(nonEmpty, doesWrap, browser, document); - - const textify = - (str) => str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' '); - - const _blockElems = { - div: 1, - p: 1, - pre: 1, - li: 1, - ol: 1, - ul: 1, - }; - - for (const element of hooks.callAll('aceRegisterBlockElements')) _blockElems[element] = 1; - - const isBlockElement = (n) => !!_blockElems[(n.tagName || '').toLowerCase()]; - editorInfo.ace_isBlockElement = isBlockElement; - - const getDirtyRanges = () => { - // based on observedChanges, return a list of ranges of original lines - // that need to be removed or replaced with new user content to incorporate - // the user's changes into the line representation. ranges may be zero-length, - // indicating inserted content. for example, [0,0] means content was inserted - // at the top of the document, while [3,4] means line 3 was deleted, modified, - // or replaced with one or more new lines of content. ranges do not touch. - - const cleanNodeForIndexCache = {}; - const N = rep.lines.length(); // old number of lines - - - const cleanNodeForIndex = (i) => { - // if line (i) in the un-updated line representation maps to a clean node - // in the document, return that node. - // if (i) is out of bounds, return true. else return false. - if (cleanNodeForIndexCache[i] === undefined) { - let result; - if (i < 0 || i >= N) { - result = true; // truthy, but no actual node - } else { - const key = rep.lines.atIndex(i).key; - result = (getCleanNodeByKey(key) || false); - } - cleanNodeForIndexCache[i] = result; - } - return cleanNodeForIndexCache[i]; - }; - const isConsecutiveCache = {}; - - const isConsecutive = (i) => { - if (isConsecutiveCache[i] === undefined) { - isConsecutiveCache[i] = (() => { - // returns whether line (i) and line (i-1), assumed to be map to clean DOM nodes, - // or document boundaries, are consecutive in the changed DOM - const a = cleanNodeForIndex(i - 1); - const b = cleanNodeForIndex(i); - if ((!a) || (!b)) return false; // violates precondition - if ((a === true) && (b === true)) return !document.body.firstChild; - if ((a === true) && b.previousSibling) return false; - if ((b === true) && a.nextSibling) return false; - if ((a === true) || (b === true)) return true; - return a.nextSibling === b; - })(); - } - return isConsecutiveCache[i]; - }; - - // returns whether line (i) in the un-updated representation maps to a clean node, - // or is outside the bounds of the document - const isClean = (i) => !!cleanNodeForIndex(i); - - // list of pairs, each representing a range of lines that is clean and consecutive - // in the changed DOM. lines (-1) and (N) are always clean, but may or may not - // be consecutive with lines in the document. pairs are in sorted order. - const cleanRanges = [ - [-1, N + 1], - ]; - - // returns index of cleanRange containing i, or -1 if none - const rangeForLine = (i) => { - for (const [idx, r] of cleanRanges.entries()) { - if (i < r[0]) return -1; - if (i < r[1]) return idx; - } - return -1; - }; - - const removeLineFromRange = (rng, line) => { - // rng is index into cleanRanges, line is line number - // precond: line is in rng - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - if ((a + 1) === b) cleanRanges.splice(rng, 1); - else if (line === a) cleanRanges[rng][0]++; - else if (line === (b - 1)) cleanRanges[rng][1]--; - else cleanRanges.splice(rng, 1, [a, line], [line + 1, b]); - }; - - const splitRange = (rng, pt) => { - // precond: pt splits cleanRanges[rng] into two non-empty ranges - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - cleanRanges.splice(rng, 1, [a, pt], [pt, b]); - }; - - const correctedLines = {}; - - const correctlyAssignLine = (line) => { - if (correctedLines[line]) return true; - correctedLines[line] = true; - // "line" is an index of a line in the un-updated rep. - // returns whether line was already correctly assigned (i.e. correctly - // clean or dirty, according to cleanRanges, and if clean, correctly - // attached or not attached (i.e. in the same range as) the prev and next lines). - const rng = rangeForLine(line); - const lineClean = isClean(line); - if (rng < 0) { - if (lineClean) { - // somehow lost clean line - } - return true; - } - if (!lineClean) { - // a clean-range includes this dirty line, fix it - removeLineFromRange(rng, line); - return false; - } else { - // line is clean, but could be wrongly connected to a clean line - // above or below - const a = cleanRanges[rng][0]; - const b = cleanRanges[rng][1]; - let didSomething = false; - // we'll leave non-clean adjacent nodes in the clean range for the caller to - // detect and deal with. we deal with whether the range should be split - // just above or just below this line. - if (a < line && isClean(line - 1) && !isConsecutive(line)) { - splitRange(rng, line); - didSomething = true; - } - if (b > (line + 1) && isClean(line + 1) && !isConsecutive(line + 1)) { - splitRange(rng, line + 1); - didSomething = true; - } - return !didSomething; - } - }; - - const detectChangesAroundLine = (line, reqInARow) => { - // make sure cleanRanges is correct about line number "line" and the surrounding - // lines; only stops checking at end of document or after no changes need - // making for several consecutive lines. note that iteration is over old lines, - // so this operation takes time proportional to the number of old lines - // that are changed or missing, not the number of new lines inserted. - let correctInARow = 0; - let currentIndex = line; - while (correctInARow < reqInARow && currentIndex >= 0) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex--; - } - correctInARow = 0; - currentIndex = line; - while (correctInARow < reqInARow && currentIndex < N) { - if (correctlyAssignLine(currentIndex)) { - correctInARow++; - } else { correctInARow = 0; } - currentIndex++; - } - }; - - if (N === 0) { - if (!isConsecutive(0)) { - splitRange(0, 0); - } - } else { - detectChangesAroundLine(0, 1); - detectChangesAroundLine(N - 1, 1); - - for (const k of Object.keys(observedChanges.cleanNodesNearChanges)) { - const key = k.substring(1); - if (rep.lines.containsKey(key)) { - const line = rep.lines.indexOfKey(key); - detectChangesAroundLine(line, 2); - } - } - } - - const dirtyRanges = []; - for (let r = 0; r < cleanRanges.length - 1; r++) { - dirtyRanges.push([cleanRanges[r][1], cleanRanges[r + 1][0]]); - } - - return dirtyRanges; - }; - - const markNodeClean = (n) => { - // clean nodes have knownHTML that matches their innerHTML - setAssoc(n, 'dirtiness', {nodeId: uniqueId(n), knownHTML: n.innerHTML}); - }; - - const isNodeDirty = (n) => { - if (n.parentNode !== document.body) return true; - const data = getAssoc(n, 'dirtiness'); - if (!data) return true; - if (n.id !== data.nodeId) return true; - if (n.innerHTML !== data.knownHTML) return true; - return false; - }; - - const handleClick = (evt) => { - inCallStackIfNecessary('handleClick', () => { - idleWorkTimer.atMost(200); - }); - - const isLink = (n) => (n.tagName || '').toLowerCase() === 'a' && n.href; - - // only want to catch left-click - if ((!evt.ctrlKey) && (evt.button !== 2) && (evt.button !== 3)) { - // find A tag with HREF - let n = evt.target; - while (n && n.parentNode && !isLink(n)) { - n = n.parentNode; - } - if (n && isLink(n)) { - try { - window.open(n.href, '_blank', 'noopener,noreferrer'); - } catch (e) { - // absorb "user canceled" error in IE for certain prompts - } - evt.preventDefault(); - } - } - - hideEditBarDropdowns(); - }; - - const hideEditBarDropdowns = () => { - window.parent.parent.padeditbar.toggleDropDown('none'); - }; - - const renumberList = (lineNum) => { - // 1-check we are in a list - let type = getLineListType(lineNum); - if (!type) { - return null; - } - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') { - return null; - } - - // 2-find the first line of the list - while (lineNum - 1 >= 0 && (type = getLineListType(lineNum - 1))) { - type = /([a-z]+)[0-9]+/.exec(type); - if (type[1] === 'indent') break; - lineNum--; - } - - // 3-renumber every list item of the same level from the beginning, level 1 - // IMPORTANT: never skip a level because there imbrication may be arbitrary - const builder = Changeset.builder(rep.lines.totalWidth()); - let loc = [0, 0]; - const applyNumberList = (line, level) => { - // init - let position = 1; - let curLevel = level; - let listType; - // loop over the lines - while ((listType = getLineListType(line))) { - // apply new num - listType = /([a-z]+)([0-9]+)/.exec(listType); - curLevel = Number(listType[2]); - if (isNaN(curLevel) || listType[0] === 'indent') { - return line; - } else if (curLevel === level) { - ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 0])); - ChangesetUtils.buildKeepRange(rep, builder, loc, (loc = [line, 1]), [ - ['start', position], - ], rep.apool); - - position++; - line++; - } else if (curLevel < level) { - return line;// back to parent - } else { - line = applyNumberList(line, level + 1);// recursive call - } - } - return line; - }; - - applyNumberList(lineNum, 1); - const cs = builder.toString(); - if (!Changeset.isIdentity(cs)) { - performDocumentApplyChangeset(cs); - } - - // 4-apply the modifications - }; - editorInfo.ace_renumberList = renumberList; - - const setLineListType = (lineNum, listType) => { - if (listType === '') { - documentAttributeManager.removeAttributeOnLine(lineNum, listAttributeName); - documentAttributeManager.removeAttributeOnLine(lineNum, 'start'); - } else { - documentAttributeManager.setAttributeOnLine(lineNum, listAttributeName, listType); - } - - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - if (renumberList(lineNum + 1) == null) { - renumberList(lineNum); - } - }; - - const doReturnKey = () => { - if (!(rep.selStart && rep.selEnd)) { - return; - } - - const lineNum = rep.selStart[0]; - let listType = getLineListType(lineNum); - - if (listType) { - const text = rep.lines.atIndex(lineNum).text; - listType = /([a-z]+)([0-9]+)/.exec(listType); - const type = listType[1]; - const level = Number(listType[2]); - - // detect empty list item; exclude indentation - if (text === '*' && type !== 'indent') { - // if not already on the highest level - if (level > 1) { - setLineListType(lineNum, type + (level - 1));// automatically decrease the level - } else { - setLineListType(lineNum, '');// remove the list - renumberList(lineNum + 1);// trigger renumbering of list that may be right after - } - } else if (lineNum + 1 <= rep.lines.length()) { - performDocumentReplaceSelection('\n'); - setLineListType(lineNum + 1, type + level); - } - } else { - performDocumentReplaceSelection('\n'); - handleReturnIndentation(); - } - }; - editorInfo.ace_doReturnKey = doReturnKey; - - const doIndentOutdent = (isOut) => { - if (!((rep.selStart && rep.selEnd) || - (rep.selStart[0] === rep.selEnd[0] && - rep.selStart[1] === rep.selEnd[1] && - rep.selEnd[1] > 1)) && - isOut !== true) { - return false; - } - - const firstLine = rep.selStart[0]; - const lastLine = Math.max(firstLine, rep.selEnd[0] - ((rep.selEnd[1] === 0) ? 1 : 0)); - const mods = []; - for (let n = firstLine; n <= lastLine; n++) { - let listType = getLineListType(n); - let t = 'indent'; - let level = 0; - if (listType) { - listType = /([a-z]+)([0-9]+)/.exec(listType); - if (listType) { - t = listType[1]; - level = Number(listType[2]); - } - } - const newLevel = Math.max(0, Math.min(MAX_LIST_LEVEL, level + (isOut ? -1 : 1))); - if (level !== newLevel) { - mods.push([n, (newLevel > 0) ? t + newLevel : '']); - } - } - - for (const mod of mods) setLineListType(mod[0], mod[1]); - return true; - }; - editorInfo.ace_doIndentOutdent = doIndentOutdent; - - const doTabKey = (shiftDown) => { - if (!doIndentOutdent(shiftDown)) { - performDocumentReplaceSelection(THE_TAB); - } - }; - - const doDeleteKey = (optEvt) => { - const evt = optEvt || {}; - let handled = false; - if (rep.selStart) { - if (isCaret()) { - const lineNum = caretLine(); - const col = caretColumn(); - const lineEntry = rep.lines.atIndex(lineNum); - const lineText = lineEntry.text; - const lineMarker = lineEntry.lineMarker; - if (evt.metaKey && col > lineMarker) { - // cmd-backspace deletes to start of line (if not already at start) - performDocumentReplaceRange([lineNum, lineMarker], [lineNum, col], ''); - handled = true; - } else if (/^ +$/.exec(lineText.substring(lineMarker, col))) { - const col2 = col - lineMarker; - const tabSize = THE_TAB.length; - const toDelete = ((col2 - 1) % tabSize) + 1; - performDocumentReplaceRange([lineNum, col - toDelete], [lineNum, col], ''); - handled = true; - } - } - if (!handled) { - if (isCaret()) { - const theLine = caretLine(); - const lineEntry = rep.lines.atIndex(theLine); - if (caretColumn() <= lineEntry.lineMarker) { - // delete at beginning of line - const prevLineListType = (theLine > 0 ? getLineListType(theLine - 1) : ''); - const thisLineListType = getLineListType(theLine); - const prevLineEntry = (theLine > 0 && rep.lines.atIndex(theLine - 1)); - const prevLineBlank = (prevLineEntry && - prevLineEntry.text.length === prevLineEntry.lineMarker); - - const thisLineHasMarker = documentAttributeManager.lineHasMarker(theLine); - - if (thisLineListType) { - // this line is a list - if (prevLineBlank && !prevLineListType) { - // previous line is blank, remove it - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } else { - // delistify - performDocumentReplaceRange([theLine, 0], [theLine, lineEntry.lineMarker], ''); - } - } else if (thisLineHasMarker && prevLineEntry) { - // If the line has any attributes assigned, remove them by removing the marker '*' - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, lineEntry.lineMarker], ''); - } else if (theLine > 0) { - // remove newline - performDocumentReplaceRange( - [theLine - 1, prevLineEntry.text.length], [theLine, 0], ''); - } - } else { - const docChar = caretDocChar(); - if (docChar > 0) { - if (evt.metaKey || evt.ctrlKey || evt.altKey) { - // delete as many unicode "letters or digits" in a row as possible; - // always delete one char, delete further even if that first char - // isn't actually a word char. - let deleteBackTo = docChar - 1; - while (deleteBackTo > lineEntry.lineMarker && - isWordChar(rep.alltext.charAt(deleteBackTo - 1))) { - deleteBackTo--; - } - performDocumentReplaceCharRange(deleteBackTo, docChar, ''); - } else { - // normal delete - performDocumentReplaceCharRange(docChar - 1, docChar, ''); - } - } - } - } else { - performDocumentReplaceSelection(''); - } - } - } - // if the list has been removed, it is necessary to renumber - // starting from the *next* line because the list may have been - // separated. If it returns null, it means that the list was not cut, try - // from the current one. - const line = caretLine(); - if (line !== -1 && renumberList(line + 1) == null) { - renumberList(line); - } - }; - - const isWordChar = (c) => padutils.wordCharRegex.test(c); - editorInfo.ace_isWordChar = isWordChar; - - const handleKeyEvent = (evt) => { - if (!isEditable) return; - const {type, charCode, keyCode, which, altKey, shiftKey} = evt; - - // Don't take action based on modifier keys going up and down. - // Modifier keys do not generate "keypress" events. - // 224 is the command-key under Mac Firefox. - // 91 is the Windows key in IE; it is ASCII for open-bracket but isn't the keycode for that key - // 20 is capslock in IE. - const isModKey = !charCode && (type === 'keyup' || type === 'keydown') && - (keyCode === 16 || keyCode === 17 || keyCode === 18 || - keyCode === 20 || keyCode === 224 || keyCode === 91); - if (isModKey) return; - - // If the key is a keypress and the browser is opera and the key is enter, - // do nothign at all as this fires twice. - if (keyCode === 13 && browser.opera && type === 'keypress') { - // This stops double enters in Opera but double Tabs still show on single - // tab keypress, adding keyCode == 9 to this doesn't help as the event is fired twice - return; - } - - const isTypeForSpecialKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - const isTypeForCmdKey = browser.safari || browser.chrome || browser.firefox - ? type === 'keydown' : type === 'keypress'; - - let stopped = false; - - inCallStackIfNecessary('handleKeyEvent', function () { - if (type === 'keypress' || (isTypeForSpecialKey && keyCode === 13 /* return*/)) { - // in IE, special keys don't send keypress, the keydown does the action - if (!outsideKeyPress(evt)) { - evt.preventDefault(); - stopped = true; - } - } else if (evt.key === 'Dead') { - // If it's a dead key we don't want to do any Etherpad behavior. - stopped = true; - return true; - } else if (type === 'keydown') { - outsideKeyDown(evt); - } - let specialHandled = false; - if (!stopped) { - const specialHandledInHook = hooks.callAll('aceKeyEvent', { - callstack: currentCallStack, - editorInfo, - rep, - documentAttributeManager, - evt, - }); - - // if any hook returned true, set specialHandled with true - if (specialHandledInHook) { - specialHandled = specialHandledInHook.indexOf(true) !== -1; - } - - const padShortcutEnabled = parent.parent.clientVars.padShortcutEnabled; - if (!specialHandled && isTypeForSpecialKey && - altKey && keyCode === 120 && - padShortcutEnabled.altF9) { - // Alt F9 focuses on the File Menu and/or editbar. - // Note that while most editors use Alt F10 this is not desirable - // As ubuntu cannot use Alt F10.... - // Focus on the editbar. - // -- TODO: Move Focus back to previous state (we know it so we can use it) - const firstEditbarElement = parent.parent.$('#editbar') - .children('ul').first().children().first() - .children().first().children().first(); - $(this).blur(); - firstEditbarElement.focus(); - evt.preventDefault(); - } - if (!specialHandled && type === 'keydown' && - altKey && keyCode === 67 && - padShortcutEnabled.altC) { - // Alt c focuses on the Chat window - $(this).blur(); - parent.parent.chat.show(); - parent.parent.$('#chatinput').focus(); - evt.preventDefault(); - } - if (!specialHandled && type === 'keydown' && - evt.ctrlKey && shiftKey && keyCode === 50 && - padShortcutEnabled.cmdShift2) { - // Control-Shift-2 shows a gritter popup showing a line author - const lineNumber = rep.selEnd[0]; - const alineAttrs = rep.alines[lineNumber]; - const apool = rep.apool; - - // TODO: support selection ranges - // TODO: Still work when authorship colors have been cleared - // TODO: i18n - // TODO: There appears to be a race condition or so. - const authorIds = new Set(); - if (alineAttrs) { - for (const op of Changeset.deserializeOps(alineAttrs)) { - const authorId = AttributeMap.fromString(op.attribs, apool).get('author'); - if (authorId) authorIds.add(authorId); - } - } - const idToName = new Map(parent.parent.pad.userList().map((a) => [a.userId, a.name])); - const myId = parent.parent.clientVars.userId; - const authors = - [...authorIds].map((id) => id === myId ? 'me' : idToName.get(id) || 'unknown'); - - parent.parent.$.gritter.add({ - title: 'Line Authors', - text: - authors.length === 0 ? 'No author information is available' - : authors.length === 1 ? `The author of this line is ${authors[0]}` - : `The authors of this line are ${authors.join(' & ')}`, - sticky: false, - time: '4000', - }); - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 8 && - padShortcutEnabled.delete) { - // "delete" key; in mozilla, if we're at the beginning of a line, normalize now, - // or else deleting a blank line can take two delete presses. - // -- - // we do deletes completely customly now: - // - allows consistent (and better) meta-delete behavior - // - normalizing and then allowing default behavior confused IE - // - probably eliminates a few minor quirks - fastIncorp(3); - evt.preventDefault(); - doDeleteKey(evt); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 13 && - padShortcutEnabled.return) { - // return key, handle specially; - // note that in mozilla we need to do an incorporation for proper return behavior anyway. - fastIncorp(4); - evt.preventDefault(); - doReturnKey(); - scheduler.setTimeout(() => { - outerWin.scrollBy(-100, 0); - }, 0); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - keyCode === 27 && - padShortcutEnabled.esc) { - // prevent esc key; - // in mozilla versions 14-19 avoid reconnecting pad. - - fastIncorp(4); - evt.preventDefault(); - specialHandled = true; - - // close all gritters when the user hits escape key - parent.parent.$.gritter.removeAll(); - } - if (!specialHandled && isTypeForCmdKey && - /* Do a saved revision on ctrl S */ - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 's' && - !evt.altKey && - padShortcutEnabled.cmdS) { - evt.preventDefault(); - const originalBackground = parent.parent.$('#revisionlink').css('background'); - parent.parent.$('#revisionlink').css({background: 'lightyellow'}); - scheduler.setTimeout(() => { - parent.parent.$('#revisionlink').css({background: originalBackground}); - }, 1000); - /* The parent.parent part of this is BAD and I feel bad.. It may break something */ - parent.parent.pad.collabClient.sendMessage({type: 'SAVE_REVISION'}); - specialHandled = true; - } - if (!specialHandled && isTypeForSpecialKey && - // tab - keyCode === 9 && - !(evt.metaKey || evt.ctrlKey) && - padShortcutEnabled.tab) { - fastIncorp(5); - evt.preventDefault(); - doTabKey(evt.shiftKey); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Z (undo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'z' && - !evt.altKey && - padShortcutEnabled.cmdZ) { - fastIncorp(6); - evt.preventDefault(); - if (evt.shiftKey) { - doUndoRedo('redo'); - } else { - doUndoRedo('undo'); - } - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-Y (redo) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'y' && - padShortcutEnabled.cmdY) { - fastIncorp(10); - evt.preventDefault(); - doUndoRedo('redo'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-B (bold) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'b' && - padShortcutEnabled.cmdB) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('bold'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-I (italic) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'i' && - padShortcutEnabled.cmdI) { - fastIncorp(14); - evt.preventDefault(); - toggleAttributeOnSelection('italic'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-U (underline) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'u' && - padShortcutEnabled.cmdU) { - fastIncorp(15); - evt.preventDefault(); - toggleAttributeOnSelection('underline'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-5 (strikethrough) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === '5' && - evt.altKey !== true && - padShortcutEnabled.cmd5) { - fastIncorp(13); - evt.preventDefault(); - toggleAttributeOnSelection('strikethrough'); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-L (unorderedlist) - (evt.metaKey || evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'l' && - evt.shiftKey && - padShortcutEnabled.cmdShiftL) { - fastIncorp(9); - evt.preventDefault(); - doInsertUnorderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-N and cmd-shift-1 (orderedlist) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - ((String.fromCharCode(which).toLowerCase() === 'n' && padShortcutEnabled.cmdShiftN) || - (String.fromCharCode(which) === '1' && padShortcutEnabled.cmdShift1))) { - fastIncorp(9); - evt.preventDefault(); - doInsertOrderedList(); - specialHandled = true; - } - if (!specialHandled && isTypeForCmdKey && - // cmd-shift-C (clearauthorship) - (evt.metaKey || evt.ctrlKey) && evt.shiftKey && - String.fromCharCode(which).toLowerCase() === 'c' && - padShortcutEnabled.cmdShiftC) { - fastIncorp(9); - evt.preventDefault(); - CMDS.clearauthorship(); - } - if (!specialHandled && isTypeForCmdKey && - // cmd-H (backspace) - (evt.ctrlKey) && String.fromCharCode(which).toLowerCase() === 'h' && - padShortcutEnabled.cmdH) { - fastIncorp(20); - evt.preventDefault(); - doDeleteKey(); - specialHandled = true; - } - if (evt.ctrlKey === true && evt.which === 36 && - // Control Home send to Y = 0 - padShortcutEnabled.ctrlHome) { - scroll.setScrollY(0); - } - if ((evt.which === 33 || evt.which === 34) && type === 'keydown' && !evt.ctrlKey) { - // This is required, browsers will try to do normal default behavior on - // page up / down and the default behavior SUCKS - evt.preventDefault(); - const oldVisibleLineRange = scroll.getVisibleLineRange(rep); - let topOffset = rep.selStart[0] - oldVisibleLineRange[0]; - if (topOffset < 0) { - topOffset = 0; - } - - const isPageDown = evt.which === 34; - const isPageUp = evt.which === 33; - - scheduler.setTimeout(() => { - // the visible lines IE 1,10 - const newVisibleLineRange = scroll.getVisibleLineRange(rep); - // total count of lines in pad IE 10 - const linesCount = rep.lines.length(); - // How many lines are in the viewport right now? - const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; - - if (isPageUp && padShortcutEnabled.pageUp) { - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selEnd[0] -= numberOfLinesInViewport; - // move to the bottom line +1 in the viewport (essentially skipping over a page) - rep.selStart[0] -= numberOfLinesInViewport; - } - - // if we hit page down - if (isPageDown && padShortcutEnabled.pageDown) { - // If the new viewpoint position is actually further than where we are right now - if (rep.selEnd[0] >= oldVisibleLineRange[0]) { - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selStart[0] = oldVisibleLineRange[1] - 1; - // dont go further in the page down than what's visible IE go from 0 to 50 - // if 50 is visible on screen but dont go below that else we miss content - rep.selEnd[0] = oldVisibleLineRange[1] - 1; - } - } - - // ensure min and max - if (rep.selEnd[0] < 0) { - rep.selEnd[0] = 0; - } - if (rep.selStart[0] < 0) { - rep.selStart[0] = 0; - } - if (rep.selEnd[0] >= linesCount) { - rep.selEnd[0] = linesCount - 1; - } - updateBrowserSelectionFromRep(); - // get the current caret selection, can't use rep. here because that only gives - // us the start position not the current - const myselection = document.getSelection(); - // get the carets selection offset in px IE 214 - let caretOffsetTop = myselection.focusNode.parentNode.offsetTop || - myselection.focusNode.offsetTop; - - // sometimes the first selection is -1 which causes problems - // (Especially with ep_page_view) - // so use focusNode.offsetTop value. - if (caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop; - // set the scrollY offset of the viewport on the document - scroll.setScrollY(caretOffsetTop); - }, 200); - } - } - - if (type === 'keydown') { - idleWorkTimer.atLeast(500); - } else if (type === 'keypress') { - // OPINION ASKED. What's going on here? :D - if (!specialHandled) { - idleWorkTimer.atMost(0); - } else { - idleWorkTimer.atLeast(500); - } - } else if (type === 'keyup') { - const wait = 0; - idleWorkTimer.atLeast(wait); - idleWorkTimer.atMost(wait); - } - - // Is part of multi-keystroke international character on Firefox Mac - const isFirefoxHalfCharacter = - (browser.firefox && evt.altKey && charCode === 0 && keyCode === 0); - - // Is part of multi-keystroke international character on Safari Mac - const isSafariHalfCharacter = - (browser.safari && evt.altKey && keyCode === 229); - - if (thisKeyDoesntTriggerNormalize || isFirefoxHalfCharacter || isSafariHalfCharacter) { - idleWorkTimer.atLeast(3000); // give user time to type - // if this is a keydown, e.g., the keyup shouldn't trigger a normalize - thisKeyDoesntTriggerNormalize = true; - } - - if (!specialHandled && !thisKeyDoesntTriggerNormalize && !inInternationalComposition && - type !== 'keyup') { - observeChangesAroundSelection(); - } - - if (type === 'keyup') { - thisKeyDoesntTriggerNormalize = false; - } - }); - }; - - let thisKeyDoesntTriggerNormalize = false; - - const doUndoRedo = (which) => { - // precond: normalized DOM - if (undoModule.enabled) { - let whichMethod; - if (which === 'undo') whichMethod = 'performUndo'; - if (which === 'redo') whichMethod = 'performRedo'; - if (whichMethod) { - const oldEventType = currentCallStack.editEvent.eventType; - currentCallStack.startNewEvent(which); - undoModule[whichMethod]((backset, selectionInfo) => { - if (backset) { - performDocumentApplyChangeset(backset); - } - if (selectionInfo) { - performSelectionChange( - lineAndColumnFromChar(selectionInfo.selStart), - lineAndColumnFromChar(selectionInfo.selEnd), - selectionInfo.selFocusAtStart); - } - const oldEvent = currentCallStack.startNewEvent(oldEventType, true); - return oldEvent; - }); - } - } - }; - editorInfo.ace_doUndoRedo = doUndoRedo; - - const setSelection = (selection) => { - const copyPoint = (pt) => ({ - node: pt.node, - index: pt.index, - maxIndex: pt.maxIndex, - }); - let isCollapsed; - - const pointToRangeBound = (pt) => { - const p = copyPoint(pt); - // Make sure Firefox cursor is deep enough; fixes cursor jumping when at top level, - // and also problem where cut/copy of a whole line selected with fake arrow-keys - // copies the next line too. - if (isCollapsed) { - const diveDeep = () => { - while (p.node.childNodes.length > 0) { - if (p.index === 0) { - p.node = p.node.firstChild; - p.maxIndex = nodeMaxIndex(p.node); - } else if (p.index === p.maxIndex) { - p.node = p.node.lastChild; - p.maxIndex = nodeMaxIndex(p.node); - p.index = p.maxIndex; - } else { break; } - } - }; - // now fix problem where cursor at end of text node at end of span-like element - // with background doesn't seem to show up... - if (isNodeText(p.node) && p.index === p.maxIndex) { - let n = p.node; - while (!n.nextSibling && n !== document.body && n.parentNode !== document.body) { - n = n.parentNode; - } - if (n.nextSibling && - !(typeof n.nextSibling.tagName === 'string' && - n.nextSibling.tagName.toLowerCase() === 'br') && - n !== p.node && n !== document.body && n.parentNode !== document.body) { - // found a parent, go to next node and dive in - p.node = n.nextSibling; - p.maxIndex = nodeMaxIndex(p.node); - p.index = 0; - diveDeep(); - } - } - // try to make sure insertion point is styled; - // also fixes other FF problems - if (!isNodeText(p.node)) { - diveDeep(); - } - } - if (isNodeText(p.node)) { - return { - container: p.node, - offset: p.index, - }; - } else { - // p.index in {0,1} - return { - container: p.node.parentNode, - offset: childIndex(p.node) + p.index, - }; - } - }; - const browserSelection = window.getSelection(); - if (browserSelection) { - browserSelection.removeAllRanges(); - if (selection) { - isCollapsed = (selection.startPoint.node === selection.endPoint.node && - selection.startPoint.index === selection.endPoint.index); - const start = pointToRangeBound(selection.startPoint); - const end = pointToRangeBound(selection.endPoint); - - if (!isCollapsed && selection.focusAtStart && - browserSelection.collapse && browserSelection.extend) { - // can handle "backwards"-oriented selection, shift-arrow-keys move start - // of selection - browserSelection.collapse(end.container, end.offset); - browserSelection.extend(start.container, start.offset); - } else { - const range = document.createRange(); - range.setStart(start.container, start.offset); - range.setEnd(end.container, end.offset); - browserSelection.removeAllRanges(); - browserSelection.addRange(range); - } - } - } - }; - - const updateBrowserSelectionFromRep = () => { - // requires normalized DOM! - const selStart = rep.selStart; - const selEnd = rep.selEnd; - - if (!(selStart && selEnd)) { - setSelection(null); - return; - } - - const selection = {}; - - const ss = [selStart[0], selStart[1]]; - selection.startPoint = getPointForLineAndChar(ss); - - const se = [selEnd[0], selEnd[1]]; - selection.endPoint = getPointForLineAndChar(se); - - selection.focusAtStart = !!rep.selFocusAtStart; - setSelection(selection); - }; - editorInfo.ace_updateBrowserSelectionFromRep = updateBrowserSelectionFromRep; - editorInfo.ace_focus = focus; - editorInfo.ace_importText = importText; - editorInfo.ace_importAText = importAText; - editorInfo.ace_exportText = exportText; - editorInfo.ace_editorChangedSize = editorChangedSize; - editorInfo.ace_setOnKeyPress = setOnKeyPress; - editorInfo.ace_setOnKeyDown = setOnKeyDown; - editorInfo.ace_setNotifyDirty = setNotifyDirty; - editorInfo.ace_dispose = dispose; - editorInfo.ace_setEditable = setEditable; - editorInfo.ace_execCommand = execCommand; - editorInfo.ace_replaceRange = replaceRange; - editorInfo.ace_getAuthorInfos = getAuthorInfos; - editorInfo.ace_performDocumentReplaceRange = performDocumentReplaceRange; - editorInfo.ace_performDocumentReplaceCharRange = performDocumentReplaceCharRange; - editorInfo.ace_setSelection = setSelection; - - const nodeMaxIndex = (nd) => { - if (isNodeText(nd)) return nd.nodeValue.length; - else return 1; - }; - - const getSelection = () => { - // returns null, or a structure containing startPoint and endPoint, - // each of which has node (a magicdom node), index, and maxIndex. If the node - // is a text node, maxIndex is the length of the text; else maxIndex is 1. - // index is between 0 and maxIndex, inclusive. - const browserSelection = window.getSelection(); - if (!browserSelection || browserSelection.type === 'None' || - browserSelection.rangeCount === 0) { - return null; - } - const range = browserSelection.getRangeAt(0); - - const isInBody = (n) => { - while (n && !(n.tagName && n.tagName.toLowerCase() === 'body')) { - n = n.parentNode; - } - return !!n; - }; - - const pointFromRangeBound = (container, offset) => { - if (!isInBody(container)) { - // command-click in Firefox selects whole document, HEAD and BODY! - return { - node: document.body, - index: 0, - maxIndex: 1, - }; - } - const n = container; - const childCount = n.childNodes.length; - if (isNodeText(n)) { - return { - node: n, - index: offset, - maxIndex: n.nodeValue.length, - }; - } else if (childCount === 0) { - return { - node: n, - index: 0, - maxIndex: 1, - }; - // treat point between two nodes as BEFORE the second (rather than after the first) - // if possible; this way point at end of a line block-element is treated as - // at beginning of next line - } else if (offset === childCount) { - const nd = n.childNodes.item(childCount - 1); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: max, - maxIndex: max, - }; - } else { - const nd = n.childNodes.item(offset); - const max = nodeMaxIndex(nd); - return { - node: nd, - index: 0, - maxIndex: max, - }; - } - }; - const selection = { - startPoint: pointFromRangeBound(range.startContainer, range.startOffset), - endPoint: pointFromRangeBound(range.endContainer, range.endOffset), - focusAtStart: - (range.startContainer !== range.endContainer || range.startOffset !== range.endOffset) && - browserSelection.anchorNode && - browserSelection.anchorNode === range.endContainer && - browserSelection.anchorOffset === range.endOffset, - }; - - if (selection.startPoint.node.ownerDocument !== window.document) { - return null; - } - - return selection; - }; - - const childIndex = (n) => { - let idx = 0; - while (n.previousSibling) { - idx++; - n = n.previousSibling; - } - return idx; - }; - - const fixView = () => { - // calling this method repeatedly should be fast - if (getInnerWidth() === 0 || getInnerHeight() === 0) { - return; - } - - enforceEditability(); - - $(sideDiv).addClass('sidedivdelayed'); - }; - - const _teardownActions = []; - - const teardown = () => { for (const a of _teardownActions) a(); }; - - let inInternationalComposition = null; - editorInfo.ace_getInInternationalComposition = () => inInternationalComposition; - - const bindTheEventHandlers = () => { - $(document).on('keydown', handleKeyEvent); - $(document).on('keypress', handleKeyEvent); - $(document).on('keyup', handleKeyEvent); - $(document).on('click', handleClick); - // dropdowns on edit bar need to be closed on clicks on both pad inner and pad outer - $(outerDoc).on('click', hideEditBarDropdowns); - - // If non-nullish, pasting on a link should be suppressed. - let suppressPasteOnLink = null; - - $(document.body).on('auxclick', (e) => { - if (e.originalEvent.button === 1 && (e.target.a || e.target.localName === 'a')) { - // The user middle-clicked on a link. Usually users do this to open a link in a new tab, but - // in X11 (Linux) this will instead paste the contents of the primary selection at the mouse - // cursor. Users almost certainly do not want to paste when middle-clicking on a link, so - // tell the 'paste' event handler to suppress the paste. This is done by starting a - // short-lived timer that suppresses paste (when the target is a link) until either the - // paste event arrives or the timer fires. - // - // Why it is implemented this way: - // * Users want to be able to paste on a link via Ctrl-V, the Edit menu, or the context - // menu (https://github.com/ether/etherpad-lite/issues/2775) so we cannot simply - // suppress all paste actions when the target is a link. - // * Non-X11 systems do not paste when the user middle-clicks, so the paste suppression - // must be self-resetting. - // * On non-X11 systems, middle click should continue to open the link in a new tab. - // Suppressing the middle click here in the 'auxclick' handler (via e.preventDefault()) - // would break that behavior. - suppressPasteOnLink = scheduler.setTimeout(() => { suppressPasteOnLink = null; }, 0); - } - }); - - $(document.body).on('paste', (e) => { - if (suppressPasteOnLink != null && (e.target.a || e.target.localName === 'a')) { - scheduler.clearTimeout(suppressPasteOnLink); - suppressPasteOnLink = null; - e.preventDefault(); - return; - } - - // Call paste hook - hooks.callAll('acePaste', { - editorInfo, - rep, - documentAttributeManager, - e, - }); - }); - - // We reference document here, this is because if we don't this will expose a bug - // in Google Chrome. This bug will cause the last character on the last line to - // not fire an event when dropped into.. - $(document).on('drop', (e) => { - if (e.target.a || e.target.localName === 'a') { - e.preventDefault(); - } - - // Bug fix: when user drags some content and drop it far from its origin, we - // need to merge the changes into a single changeset. So mark origin with empty
', - wantHTML: 'empty

', - wantText: 'empty\n\n', - }, - 'indentedListsAreNotBullets': { - description: 'Indented lists are represented with tabs and without bullets', - input: '
  • indent
  • indent
', - wantHTML: '
  • indent
  • indent

', - wantText: '\tindent\n\tindent\n\n', - }, - 'lineWithMultipleSpaces': { - description: 'Multiple spaces should be collapsed', - input: 'Text with more than one space.
', - wantHTML: 'Text with more than one space.

', - wantText: 'Text with more than one space.\n\n', - }, - 'lineWithMultipleNonBreakingAndNormalSpaces': { - // XXX the HTML between "than" and "one" looks strange - description: 'non-breaking space should be preserved, but can be replaced when it', - input: 'Text with  more   than  one space.
', - wantHTML: 'Text with  more   than  one space.

', - wantText: 'Text with more than one space.\n\n', - }, - 'multiplenbsp': { - description: 'Multiple non-breaking space should be preserved', - input: '  
', - wantHTML: '  

', - wantText: ' \n\n', - }, - 'multipleNonBreakingSpaceBetweenWords': { - description: 'A normal space is always inserted before a word', - input: '  word1  word2   word3
', - wantHTML: '  word1  word2   word3

', - wantText: ' word1 word2 word3\n\n', - }, - 'nonBreakingSpacePreceededBySpaceBetweenWords': { - description: 'A non-breaking space preceded by a normal space', - input: '  word1  word2  word3
', - wantHTML: ' word1  word2  word3

', - wantText: ' word1 word2 word3\n\n', - }, - 'nonBreakingSpaceFollowededBySpaceBetweenWords': { - description: 'A non-breaking space followed by a normal space', - input: '  word1  word2  word3
', - wantHTML: '  word1  word2  word3

', - wantText: ' word1 word2 word3\n\n', - }, - 'spacesAfterNewline': { - description: 'Collapse spaces that follow a newline', - input: 'something
something
', - wantHTML: 'something
something

', - wantText: 'something\nsomething\n\n', - }, - 'spacesAfterNewlineP': { - description: 'Collapse spaces that follow a paragraph', - input: 'something

something
', - wantHTML: 'something

something

', - wantText: 'something\n\nsomething\n\n', - }, - 'spacesAtEndOfLine': { - description: 'Collapse spaces that preceed/follow a newline', - input: 'something
something
', - wantHTML: 'something
something

', - wantText: 'something\nsomething\n\n', - }, - 'spacesAtEndOfLineP': { - description: 'Collapse spaces that preceed/follow a paragraph', - input: 'something

something
', - wantHTML: 'something

something

', - wantText: 'something\n\nsomething\n\n', - }, - 'nonBreakingSpacesAfterNewlines': { - description: 'Don\'t collapse non-breaking spaces that follow a newline', - input: 'something
   something
', - wantHTML: 'something
   something

', - wantText: 'something\n something\n\n', - }, - 'nonBreakingSpacesAfterNewlinesP': { - description: 'Don\'t collapse non-breaking spaces that follow a paragraph', - input: 'something

   something
', - wantHTML: 'something

   something

', - wantText: 'something\n\n something\n\n', - }, - 'collapseSpacesInsideElements': { - description: 'Preserve only one space when multiple are present', - input: 'Need more space s !
', - wantHTML: 'Need more space s !

', - wantText: 'Need more space s !\n\n', - }, - 'collapseSpacesAcrossNewlines': { - description: 'Newlines and multiple spaces across newlines should be collapsed', - input: ` + 'malformed': { + input: '
  • wtf', + wantHTML: 'wtf

    ', + wantText: 'wtf\n\n', + disabled: true, + }, + 'nonelistiteminlist #3620': { + input: '
      test
    • FOO
    ', + wantHTML: '
      test
    • FOO

    ', + wantText: '\ttest\n\t* FOO\n\n', + disabled: true, + }, + 'whitespaceinlist #3620': { + input: '
    • FOO
    ', + wantHTML: '
    • FOO

    ', + wantText: '\t* FOO\n\n', + }, + 'prefixcorrectlinenumber': { + input: '
    1. should be 1
    2. should be 2
    ', + wantHTML: '
    1. should be 1
    2. should be 2

    ', + wantText: '\t1. should be 1\n\t2. should be 2\n\n', + }, + 'prefixcorrectlinenumbernested': { + input: '
    1. should be 1
      1. foo
    2. should be 2
    ', + wantHTML: '
    1. should be 1
      1. foo
    2. should be 2

    ', + wantText: '\t1. should be 1\n\t\t1.1. foo\n\t2. should be 2\n\n', + }, + /* + "prefixcorrectlinenumber when introduced none list item - currently not supported see #3450": { + input: '
    1. should be 1
    2. test
    3. should be 2
    ', + wantHTML: '
    1. should be 1
    2. test
    3. should be 2

    ', + wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n', + } + , + "newlinesshouldntresetlinenumber #2194": { + input: '
    1. should be 1
    2. test
    3. should be 2
    ', + wantHTML: '
    1. should be 1
    2. test
    3. should be 2

    ', + wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n', + } + */ + 'ignoreAnyTagsOutsideBody': { + description: 'Content outside body should be ignored', + input: 'titleempty
    ', + wantHTML: 'empty

    ', + wantText: 'empty\n\n', + }, + 'indentedListsAreNotBullets': { + description: 'Indented lists are represented with tabs and without bullets', + input: '
    • indent
    • indent
    ', + wantHTML: '
    • indent
    • indent

    ', + wantText: '\tindent\n\tindent\n\n', + }, + 'lineWithMultipleSpaces': { + description: 'Multiple spaces should be collapsed', + input: 'Text with more than one space.
    ', + wantHTML: 'Text with more than one space.

    ', + wantText: 'Text with more than one space.\n\n', + }, + 'lineWithMultipleNonBreakingAndNormalSpaces': { + // XXX the HTML between "than" and "one" looks strange + description: 'non-breaking space should be preserved, but can be replaced when it', + input: 'Text with  more   than  one space.
    ', + wantHTML: 'Text with  more   than  one space.

    ', + wantText: 'Text with more than one space.\n\n', + }, + 'multiplenbsp': { + description: 'Multiple non-breaking space should be preserved', + input: '  
    ', + wantHTML: '  

    ', + wantText: ' \n\n', + }, + 'multipleNonBreakingSpaceBetweenWords': { + description: 'A normal space is always inserted before a word', + input: '  word1  word2   word3
    ', + wantHTML: '  word1  word2   word3

    ', + wantText: ' word1 word2 word3\n\n', + }, + 'nonBreakingSpacePreceededBySpaceBetweenWords': { + description: 'A non-breaking space preceded by a normal space', + input: '  word1  word2  word3
    ', + wantHTML: ' word1  word2  word3

    ', + wantText: ' word1 word2 word3\n\n', + }, + 'nonBreakingSpaceFollowededBySpaceBetweenWords': { + description: 'A non-breaking space followed by a normal space', + input: '  word1  word2  word3
    ', + wantHTML: '  word1  word2  word3

    ', + wantText: ' word1 word2 word3\n\n', + }, + 'spacesAfterNewline': { + description: 'Collapse spaces that follow a newline', + input: 'something
    something
    ', + wantHTML: 'something
    something

    ', + wantText: 'something\nsomething\n\n', + }, + 'spacesAfterNewlineP': { + description: 'Collapse spaces that follow a paragraph', + input: 'something

    something
    ', + wantHTML: 'something

    something

    ', + wantText: 'something\n\nsomething\n\n', + }, + 'spacesAtEndOfLine': { + description: 'Collapse spaces that preceed/follow a newline', + input: 'something
    something
    ', + wantHTML: 'something
    something

    ', + wantText: 'something\nsomething\n\n', + }, + 'spacesAtEndOfLineP': { + description: 'Collapse spaces that preceed/follow a paragraph', + input: 'something

    something
    ', + wantHTML: 'something

    something

    ', + wantText: 'something\n\nsomething\n\n', + }, + 'nonBreakingSpacesAfterNewlines': { + description: 'Don\'t collapse non-breaking spaces that follow a newline', + input: 'something
       something
    ', + wantHTML: 'something
       something

    ', + wantText: 'something\n something\n\n', + }, + 'nonBreakingSpacesAfterNewlinesP': { + description: 'Don\'t collapse non-breaking spaces that follow a paragraph', + input: 'something

       something
    ', + wantHTML: 'something

       something

    ', + wantText: 'something\n\n something\n\n', + }, + 'collapseSpacesInsideElements': { + description: 'Preserve only one space when multiple are present', + input: 'Need more space s !
    ', + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, + 'collapseSpacesAcrossNewlines': { + description: 'Newlines and multiple spaces across newlines should be collapsed', + input: ` Need more space s !
    `, - wantHTML: 'Need more space s !

    ', - wantText: 'Need more space s !\n\n', - }, - 'multipleNewLinesAtBeginning': { - description: 'Multiple new lines and paragraphs at the beginning should be preserved', - input: '

    first line

    second line
    ', - wantHTML: '



    first line

    second line

    ', - wantText: '\n\n\n\nfirst line\n\nsecond line\n\n', - }, - 'multiLineParagraph': { - description: 'A paragraph with multiple lines should not loose spaces when lines are combined', - input: ` + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, + 'multipleNewLinesAtBeginning': { + description: 'Multiple new lines and paragraphs at the beginning should be preserved', + input: '

    first line

    second line
    ', + wantHTML: '



    first line

    second line

    ', + wantText: '\n\n\n\nfirst line\n\nsecond line\n\n', + }, + 'multiLineParagraph': { + description: 'A paragraph with multiple lines should not loose spaces when lines are combined', + input: `

    а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

    `, - wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

    ', - wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n', - }, - 'multiLineParagraphWithPre': { - // XXX why is there   before "in"? - description: 'lines in preformatted text should be kept intact', - input: ` + wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

    ', + wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n', + }, + 'multiLineParagraphWithPre': { + // XXX why is there   before "in"? + description: 'lines in preformatted text should be kept intact', + input: `

    а б в г ґ д е є ж з и і ї й к л м н о

    multiple
        lines
    @@ -188,97 +184,88 @@ const testImports = {
     

    п р с т у ф х ц ч ш щ ю я ь

    `, - wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о
    multiple
       lines
     in
          pre

    п р с т у ф х ц ч ш щ ю я ь

    ', - wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n', - }, - 'preIntroducesASpace': { - description: 'pre should be on a new line not preceded by a space', - input: `

    + wantHTML: 'а б в г ґ д е є ж з и і ї й к л м н о
    multiple
       lines
     in
          pre

    п р с т у ф х ц ч ш щ ю я ь

    ', + wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n', + }, + 'preIntroducesASpace': { + description: 'pre should be on a new line not preceded by a space', + input: `

    1

    preline
     

    `, - wantHTML: '1
    preline


    ', - wantText: '1\npreline\n\n\n', - }, - 'dontDeleteSpaceInsideElements': { - description: 'Preserve spaces inside elements', - input: 'Need more space s !
    ', - wantHTML: 'Need more space s !

    ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceOutsideElements': { - description: 'Preserve spaces outside elements', - input: 'Need more space s !
    ', - wantHTML: 'Need more space s !

    ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceAtEndOfElement': { - description: 'Preserve spaces at the end of an element', - input: 'Need more space s !
    ', - wantHTML: 'Need more space s !

    ', - wantText: 'Need more space s !\n\n', - }, - 'dontDeleteSpaceAtBeginOfElements': { - description: 'Preserve spaces at the start of an element', - input: 'Need more space s !
    ', - wantHTML: 'Need more space s !

    ', - wantText: 'Need more space s !\n\n', - }, + wantHTML: '1
    preline


    ', + wantText: '1\npreline\n\n\n', + }, + 'dontDeleteSpaceInsideElements': { + description: 'Preserve spaces inside elements', + input: 'Need more space s !
    ', + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, + 'dontDeleteSpaceOutsideElements': { + description: 'Preserve spaces outside elements', + input: 'Need more space s !
    ', + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, + 'dontDeleteSpaceAtEndOfElement': { + description: 'Preserve spaces at the end of an element', + input: 'Need more space s !
    ', + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, + 'dontDeleteSpaceAtBeginOfElements': { + description: 'Preserve spaces at the start of an element', + input: 'Need more space s !
    ', + wantHTML: 'Need more space s !

    ', + wantText: 'Need more space s !\n\n', + }, }; - describe(__filename, function () { - this.timeout(1000); - - before(async function () { agent = await common.init(); }); - - Object.keys(testImports).forEach((testName) => { - describe(testName, function () { - const testPadId = makeid(); - const test = testImports[testName]; - if (test.disabled) { - return xit(`DISABLED: ${testName}`, function (done) { - done(); + this.timeout(1000); + before(async function () { agent = await common.init(); }); + Object.keys(testImports).forEach((testName) => { + describe(testName, function () { + const testPadId = makeid(); + const test = testImports[testName]; + if (test.disabled) { + return xit(`DISABLED: ${testName}`, function (done) { + done(); + }); + } + it('createPad', async function () { + const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) + .expect(200) + .expect('Content-Type', /json/); + assert.equal(res.body.code, 0); + }); + it('setHTML', async function () { + const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` + + `&html=${encodeURIComponent(test.input)}`) + .expect(200) + .expect('Content-Type', /json/); + assert.equal(res.body.code, 0); + }); + it('getHTML', async function () { + const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) + .expect(200) + .expect('Content-Type', /json/); + assert.equal(res.body.data.html, test.wantHTML); + }); + it('getText', async function () { + const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) + .expect(200) + .expect('Content-Type', /json/); + assert.equal(res.body.data.text, test.wantText); + }); }); - } - - it('createPad', async function () { - const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('setHTML', async function () { - const res = await agent.get(`${endPoint('setHTML')}&padID=${testPadId}` + - `&html=${encodeURIComponent(test.input)}`) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.code, 0); - }); - - it('getHTML', async function () { - const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.html, test.wantHTML); - }); - - it('getText', async function () { - const res = await agent.get(`${endPoint('getText')}&padID=${testPadId}`) - .expect(200) - .expect('Content-Type', /json/); - assert.equal(res.body.data.text, test.wantText); - }); }); - }); }); - function makeid() { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - for (let i = 0; i < 5; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 5; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; } diff --git a/src/tests/backend/specs/caching_middleware.js b/src/tests/backend/specs/caching_middleware.js index ebfd65d9f..ca2377d95 100644 --- a/src/tests/backend/specs/caching_middleware.js +++ b/src/tests/backend/specs/caching_middleware.js @@ -1,18 +1,10 @@ +import * as common from "../common.js"; +import assertLegacy from "../assert-legacy.js"; +import queryString from "querystring"; +import * as settings from "../../../node/utils/Settings.js"; 'use strict'; - -/** - * caching_middleware is responsible for serving everything under path `/javascripts/` - * That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code - * - */ - -const common = require('../common'); -const assert = require('../assert-legacy').strict; -const queryString = require('querystring'); -const settings = require('../../../node/utils/Settings'); - +const assert = assertLegacy.strict; let agent; - /** * Hack! Returns true if the resource is not plaintext * The file should start with the callback method, so we need the @@ -23,95 +15,87 @@ let agent; * @returns {boolean} if it is plaintext */ const isPlaintextResponse = (fileContent, resource) => { - // callback=require.define&v=1234 - const query = (new URL(resource, 'http://localhost')).search.slice(1); - // require.define - const jsonp = queryString.parse(query).callback; - - // returns true if the first letters in fileContent equal the content of `jsonp` - return fileContent.substring(0, jsonp.length) === jsonp; + // callback=require.define&v=1234 + const query = (new URL(resource, 'http://localhost')).search.slice(1); + // require.define + const jsonp = queryString.parse(query).callback; + // returns true if the first letters in fileContent equal the content of `jsonp` + return fileContent.substring(0, jsonp.length) === jsonp; }; - /** * A hack to disable `superagent`'s auto unzip functionality * * @param {Request} request */ const disableAutoDeflate = (request) => { - request._shouldUnzip = () => false; + request._shouldUnzip = () => false; }; - describe(__filename, function () { - const backups = {}; - const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved - const packages = [ - '/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define', - '/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define', - ]; - - before(async function () { - agent = await common.init(); - backups.settings = {}; - backups.settings.minify = settings.minify; - }); - after(async function () { - Object.assign(settings, backups.settings); - }); - - for (const minify of [false, true]) { - context(`when minify is ${minify}`, function () { - before(async function () { - settings.minify = minify; - }); - - describe('gets packages uncompressed without Accept-Encoding gzip', function () { - for (const resource of packages) { - it(resource, async function () { - await agent.get(resource) - .set('Accept-Encoding', fantasyEncoding) - .use(disableAutoDeflate) - .expect(200) - .expect('Content-Type', /application\/javascript/) - .expect((res) => { - assert.equal(res.header['content-encoding'], undefined); - assert(isPlaintextResponse(res.text, resource)); - }); - }); - } - }); - - describe('gets packages compressed with Accept-Encoding gzip', function () { - for (const resource of packages) { - it(resource, async function () { - await agent.get(resource) - .set('Accept-Encoding', 'gzip') - .use(disableAutoDeflate) - .expect(200) - .expect('Content-Type', /application\/javascript/) - .expect('Content-Encoding', 'gzip') - .expect((res) => { - assert(!isPlaintextResponse(res.text, resource)); - }); - }); - } - }); - - it('does not cache content-encoding headers', async function () { - await agent.get(packages[0]) - .set('Accept-Encoding', fantasyEncoding) - .expect(200) - .expect((res) => assert.equal(res.header['content-encoding'], undefined)); - await agent.get(packages[0]) - .set('Accept-Encoding', 'gzip') - .expect(200) - .expect('Content-Encoding', 'gzip'); - await agent.get(packages[0]) - .set('Accept-Encoding', fantasyEncoding) - .expect(200) - .expect((res) => assert.equal(res.header['content-encoding'], undefined)); - }); + const backups = {}; + const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved + const packages = [ + '/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define', + '/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define', + '/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define', + '/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define', + ]; + before(async function () { + agent = await common.init(); + backups.settings = {}; + backups.settings.minify = settings.minify; }); - } + after(async function () { + Object.assign(settings, backups.settings); + }); + for (const minify of [false, true]) { + context(`when minify is ${minify}`, function () { + before(async function () { + settings.minify = minify; + }); + describe('gets packages uncompressed without Accept-Encoding gzip', function () { + for (const resource of packages) { + it(resource, async function () { + await agent.get(resource) + .set('Accept-Encoding', fantasyEncoding) + .use(disableAutoDeflate) + .expect(200) + .expect('Content-Type', /application\/javascript/) + .expect((res) => { + assert.equal(res.header['content-encoding'], undefined); + assert(isPlaintextResponse(res.text, resource)); + }); + }); + } + }); + describe('gets packages compressed with Accept-Encoding gzip', function () { + for (const resource of packages) { + it(resource, async function () { + await agent.get(resource) + .set('Accept-Encoding', 'gzip') + .use(disableAutoDeflate) + .expect(200) + .expect('Content-Type', /application\/javascript/) + .expect('Content-Encoding', 'gzip') + .expect((res) => { + assert(!isPlaintextResponse(res.text, resource)); + }); + }); + } + }); + it('does not cache content-encoding headers', async function () { + await agent.get(packages[0]) + .set('Accept-Encoding', fantasyEncoding) + .expect(200) + .expect((res) => assert.equal(res.header['content-encoding'], undefined)); + await agent.get(packages[0]) + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Content-Encoding', 'gzip'); + await agent.get(packages[0]) + .set('Accept-Encoding', fantasyEncoding) + .expect(200) + .expect((res) => assert.equal(res.header['content-encoding'], undefined)); + }); + }); + } }); diff --git a/src/tests/backend/specs/chat.js b/src/tests/backend/specs/chat.js index aefa64183..1bf0c1114 100644 --- a/src/tests/backend/specs/chat.js +++ b/src/tests/backend/specs/chat.js @@ -1,160 +1,153 @@ +import ChatMessage from "../../../static/js/ChatMessage.js"; +import { Pad } from "../../../node/db/Pad.js"; +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as padManager from "../../../node/db/PadManager.js"; +import * as pluginDefs from "../../../static/js/pluginfw/plugin_defs.js"; 'use strict'; - -const ChatMessage = require('../../../static/js/ChatMessage'); -const {Pad} = require('../../../node/db/Pad'); -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const pluginDefs = require('../../../static/js/pluginfw/plugin_defs'); - +const assert = assert$0.strict; const logger = common.logger; - const checkHook = async (hookName, checkFn) => { - if (pluginDefs.hooks[hookName] == null) pluginDefs.hooks[hookName] = []; - await new Promise((resolve, reject) => { - pluginDefs.hooks[hookName].push({ - hook_fn: async (hookName, context) => { - if (checkFn == null) return; - logger.debug(`hook ${hookName} invoked`); - try { - // Make sure checkFn is called only once. - const _checkFn = checkFn; - checkFn = null; - await _checkFn(context); - } catch (err) { - reject(err); - return; - } - resolve(); - }, + if (pluginDefs.hooks[hookName] == null) + pluginDefs.hooks[hookName] = []; + await new Promise((resolve, reject) => { + pluginDefs.hooks[hookName].push({ + hook_fn: async (hookName, context) => { + if (checkFn == null) + return; + logger.debug(`hook ${hookName} invoked`); + try { + // Make sure checkFn is called only once. + const _checkFn = checkFn; + checkFn = null; + await _checkFn(context); + } + catch (err) { + reject(err); + return; + } + resolve(); + }, + }); }); - }); }; - const sendMessage = (socket, data) => { - socket.send({ - type: 'COLLABROOM', - component: 'pad', - data, - }); + socket.send({ + type: 'COLLABROOM', + component: 'pad', + data, + }); }; - -const sendChat = (socket, message) => sendMessage(socket, {type: 'CHAT_MESSAGE', message}); - +const sendChat = (socket, message) => sendMessage(socket, { type: 'CHAT_MESSAGE', message }); describe(__filename, function () { - const padId = 'testChatPad'; - const hooksBackup = {}; - - before(async function () { - for (const [name, defs] of Object.entries(pluginDefs.hooks)) { - if (defs == null) continue; - hooksBackup[name] = defs; - } - }); - - beforeEach(async function () { - for (const [name, defs] of Object.entries(hooksBackup)) pluginDefs.hooks[name] = [...defs]; - for (const name of Object.keys(pluginDefs.hooks)) { - if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; - } - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - }); - - after(async function () { - Object.assign(pluginDefs.hooks, hooksBackup); - for (const name of Object.keys(pluginDefs.hooks)) { - if (hooksBackup[name] == null) delete pluginDefs.hooks[name]; - } - }); - - describe('chatNewMessage hook', function () { - let authorId; - let socket; - + const padId = 'testChatPad'; + const hooksBackup = {}; + before(async function () { + for (const [name, defs] of Object.entries(pluginDefs.hooks)) { + if (defs == null) + continue; + hooksBackup[name] = defs; + } + }); beforeEach(async function () { - socket = await common.connect(); - const {data: clientVars} = await common.handshake(socket, padId); - authorId = clientVars.userId; + for (const [name, defs] of Object.entries(hooksBackup)) + pluginDefs.hooks[name] = [...defs]; + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) + delete pluginDefs.hooks[name]; + } + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } }); - - afterEach(async function () { - socket.close(); + after(async function () { + Object.assign(pluginDefs.hooks, hooksBackup); + for (const name of Object.keys(pluginDefs.hooks)) { + if (hooksBackup[name] == null) + delete pluginDefs.hooks[name]; + } }); - - it('message', async function () { - const start = Date.now(); - await Promise.all([ - checkHook('chatNewMessage', ({message}) => { - assert(message != null); - assert(message instanceof ChatMessage); - assert.equal(message.authorId, authorId); - assert.equal(message.text, this.test.title); - assert(message.time >= start); - assert(message.time <= Date.now()); - }), - sendChat(socket, {text: this.test.title}), - ]); + describe('chatNewMessage hook', function () { + let authorId; + let socket; + beforeEach(async function () { + socket = await common.connect(); + const { data: clientVars } = await common.handshake(socket, padId); + authorId = clientVars.userId; + }); + afterEach(async function () { + socket.close(); + }); + it('message', async function () { + const start = Date.now(); + await Promise.all([ + checkHook('chatNewMessage', ({ message }) => { + assert(message != null); + assert(message instanceof ChatMessage); + assert.equal(message.authorId, authorId); + assert.equal(message.text, this.test.title); + assert(message.time >= start); + assert(message.time <= Date.now()); + }), + sendChat(socket, { text: this.test.title }), + ]); + }); + it('pad', async function () { + await Promise.all([ + checkHook('chatNewMessage', ({ pad }) => { + assert(pad != null); + assert(pad instanceof Pad); + assert.equal(pad.id, padId); + }), + sendChat(socket, { text: this.test.title }), + ]); + }); + it('padId', async function () { + await Promise.all([ + checkHook('chatNewMessage', (context) => { + assert.equal(context.padId, padId); + }), + sendChat(socket, { text: this.test.title }), + ]); + }); + it('mutations propagate', async function () { + const listen = async (type) => await new Promise((resolve) => { + const handler = (msg) => { + if (msg.type !== 'COLLABROOM') + return; + if (msg.data == null || msg.data.type !== type) + return; + resolve(msg.data); + socket.off('message', handler); + }; + socket.on('message', handler); + }); + const modifiedText = `${this.test.title} `; + const customMetadata = { foo: this.test.title }; + await Promise.all([ + checkHook('chatNewMessage', ({ message }) => { + message.text = modifiedText; + message.customMetadata = customMetadata; + }), + (async () => { + const { message } = await listen('CHAT_MESSAGE'); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendChat(socket, { text: this.test.title }), + ]); + // Simulate fetch of historical chat messages when a pad is first loaded. + await Promise.all([ + (async () => { + const { messages: [message] } = await listen('CHAT_MESSAGES'); + assert(message != null); + assert.equal(message.text, modifiedText); + assert.deepEqual(message.customMetadata, customMetadata); + })(), + sendMessage(socket, { type: 'GET_CHAT_MESSAGES', start: 0, end: 0 }), + ]); + }); }); - - it('pad', async function () { - await Promise.all([ - checkHook('chatNewMessage', ({pad}) => { - assert(pad != null); - assert(pad instanceof Pad); - assert.equal(pad.id, padId); - }), - sendChat(socket, {text: this.test.title}), - ]); - }); - - it('padId', async function () { - await Promise.all([ - checkHook('chatNewMessage', (context) => { - assert.equal(context.padId, padId); - }), - sendChat(socket, {text: this.test.title}), - ]); - }); - - it('mutations propagate', async function () { - const listen = async (type) => await new Promise((resolve) => { - const handler = (msg) => { - if (msg.type !== 'COLLABROOM') return; - if (msg.data == null || msg.data.type !== type) return; - resolve(msg.data); - socket.off('message', handler); - }; - socket.on('message', handler); - }); - - const modifiedText = `${this.test.title} `; - const customMetadata = {foo: this.test.title}; - await Promise.all([ - checkHook('chatNewMessage', ({message}) => { - message.text = modifiedText; - message.customMetadata = customMetadata; - }), - (async () => { - const {message} = await listen('CHAT_MESSAGE'); - assert(message != null); - assert.equal(message.text, modifiedText); - assert.deepEqual(message.customMetadata, customMetadata); - })(), - sendChat(socket, {text: this.test.title}), - ]); - // Simulate fetch of historical chat messages when a pad is first loaded. - await Promise.all([ - (async () => { - const {messages: [message]} = await listen('CHAT_MESSAGES'); - assert(message != null); - assert.equal(message.text, modifiedText); - assert.deepEqual(message.customMetadata, customMetadata); - })(), - sendMessage(socket, {type: 'GET_CHAT_MESSAGES', start: 0, end: 0}), - ]); - }); - }); }); diff --git a/src/tests/backend/specs/contentcollector.js b/src/tests/backend/specs/contentcollector.js index a4696307e..5e4ad92d9 100644 --- a/src/tests/backend/specs/contentcollector.js +++ b/src/tests/backend/specs/contentcollector.js @@ -1,284 +1,273 @@ +import AttributePool from "../../../static/js/AttributePool.js"; +import * as Changeset from "../../../static/js/Changeset.js"; +import assert$0 from "assert"; +import * as attributes from "../../../static/js/attributes.js"; +import * as contentcollector from "../../../static/js/contentcollector.js"; +import * as jsdom from "jsdom"; 'use strict'; - -/* - * While importexport tests target the `setHTML` API endpoint, which is nearly identical to what - * happens when a user manually imports a document via the UI, the contentcollector tests here don't - * use rehype to process the document. Rehype removes spaces and newĺines were applicable, so the - * expected results here can differ from importexport.js. - * - * If you add tests here, please also add them to importexport.js - */ - -const AttributePool = require('../../../static/js/AttributePool'); -const Changeset = require('../../../static/js/Changeset'); -const assert = require('assert').strict; -const attributes = require('../../../static/js/attributes'); -const contentcollector = require('../../../static/js/contentcollector'); -const jsdom = require('jsdom'); - +const assert = assert$0.strict; // All test case `wantAlines` values must only refer to attributes in this list so that the // attribute numbers do not change due to changes in pool insertion order. const knownAttribs = [ - ['insertorder', 'first'], - ['italic', 'true'], - ['list', 'bullet1'], - ['list', 'bullet2'], - ['list', 'number1'], - ['list', 'number2'], - ['lmkr', '1'], - ['start', '1'], - ['start', '2'], + ['insertorder', 'first'], + ['italic', 'true'], + ['list', 'bullet1'], + ['list', 'bullet2'], + ['list', 'number1'], + ['list', 'number2'], + ['lmkr', '1'], + ['start', '1'], + ['start', '2'], ]; - const testCases = [ - { - description: 'Simple', - html: '

    foo

    ', - wantAlines: ['+3'], - wantText: ['foo'], - }, - { - description: 'Line starts with asterisk', - html: '

    *foo

    ', - wantAlines: ['+4'], - wantText: ['*foo'], - }, - { - description: 'Complex nested Li', - html: '
    1. one
      1. 1.1
    2. two
    ', - wantAlines: [ - '*0*4*6*7+1+3', - '*0*5*6*8+1+3', - '*0*4*6*8+1+3', - ], - wantText: [ - '*one', '*1.1', '*two', - ], - }, - { - description: 'Complex list of different types', - html: '
    • one
    • two
    • 0
    • 1
    • 2
      • 3
      • 4
    1. item
      1. item1
      2. item2
    ', - wantAlines: [ - '*0*2*6+1+3', - '*0*2*6+1+3', - '*0*2*6+1+1', - '*0*2*6+1+1', - '*0*2*6+1+1', - '*0*3*6+1+1', - '*0*3*6+1+1', - '*0*4*6*7+1+4', - '*0*5*6*8+1+5', - '*0*5*6*8+1+5', - ], - wantText: [ - '*one', - '*two', - '*0', - '*1', - '*2', - '*3', - '*4', - '*item', - '*item1', - '*item2', - ], - }, - { - description: 'Tests if uls properly get attributes', - html: '
    • a
    • b
    div

    foo

    ', - wantAlines: [ - '*0*2*6+1+1', - '*0*2*6+1+1', - '+3', - '+3', - ], - wantText: ['*a', '*b', 'div', 'foo'], - }, - { - description: 'Tests if indented uls properly get attributes', - html: '
    • a
      • b
    • a

    foo

    ', - wantAlines: [ - '*0*2*6+1+1', - '*0*3*6+1+1', - '*0*2*6+1+1', - '+3', - ], - wantText: ['*a', '*b', '*a', 'foo'], - }, - { - description: 'Tests if ols properly get line numbers when in a normal OL', - html: '
    1. a
    2. b
    3. c

    test

    ', - wantAlines: [ - '*0*4*6*7+1+1', - '*0*4*6*7+1+1', - '*0*4*6*7+1+1', - '+4', - ], - wantText: ['*a', '*b', '*c', 'test'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - }, - { - description: 'A single completely empty line break within an ol should reset count if OL is closed off..', - html: '
    1. should be 1

    hello

    1. should be 1
    2. should be 2

    ', - wantAlines: [ - '*0*4*6*7+1+b', - '+5', - '*0*4*6*8+1+b', - '*0*4*6*8+1+b', - '', - ], - wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''], - noteToSelf: "Shouldn't include attribute marker in the

    line", - }, - { - description: 'A single

    should create a new line', - html: '

    ', - wantAlines: ['', ''], - wantText: ['', ''], - noteToSelf: '

    should create a line break but not break numbering', - }, - { - description: 'Tests if ols properly get line numbers when in a normal OL #2', - html: 'a
    1. b
      1. c
    notlist

    foo

    ', - wantAlines: [ - '+1', - '*0*4*6*7+1+1', - '*0*5*6*8+1+1', - '+7', - '+3', - ], - wantText: ['a', '*b', '*c', 'notlist', 'foo'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - }, - { - description: 'First item being an UL then subsequent being OL will fail', - html: '
    • a
      1. b
      2. c
    ', - wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'], - wantText: ['a', '*b', '*c'], - noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', - disabled: true, - }, - { - description: 'A single completely empty line break within an ol should NOT reset count', - html: '
    1. should be 1
    2. should be 2
    3. should be 3

    ', - wantAlines: [], - wantText: ['*should be 1', '*should be 2', '*should be 3'], - noteToSelf: "

    should create a line break but not break numbering -- This is what I can't get working!", - disabled: true, - }, - { - description: 'Content outside body should be ignored', - html: 'titleempty
    ', - wantAlines: ['+5'], - wantText: ['empty'], - }, - { - description: 'Multiple spaces should be preserved', - html: 'Text with more than one space.
    ', - wantAlines: ['+10'], - wantText: ['Text with more than one space.'], - }, - { - description: 'non-breaking and normal space should be preserved', - html: 'Text with  more   than  one space.
    ', - wantAlines: ['+10'], - wantText: ['Text with more than one space.'], - }, - { - description: 'Multiple nbsp should be preserved', - html: '  
    ', - wantAlines: ['+2'], - wantText: [' '], - }, - { - description: 'Multiple nbsp between words ', - html: '  word1  word2   word3
    ', - wantAlines: ['+m'], - wantText: [' word1 word2 word3'], - }, - { - description: 'A non-breaking space preceded by a normal space', - html: '  word1  word2  word3
    ', - wantAlines: ['+l'], - wantText: [' word1 word2 word3'], - }, - { - description: 'A non-breaking space followed by a normal space', - html: '  word1  word2  word3
    ', - wantAlines: ['+l'], - wantText: [' word1 word2 word3'], - }, - { - description: 'Don\'t collapse spaces that follow a newline', - html: 'something
    something
    ', - wantAlines: ['+9', '+m'], - wantText: ['something', ' something'], - }, - { - description: 'Don\'t collapse spaces that follow a empty paragraph', - html: 'something

    something
    ', - wantAlines: ['+9', '', '+m'], - wantText: ['something', '', ' something'], - }, - { - description: 'Don\'t collapse spaces that preceed/follow a newline', - html: 'something
    something
    ', - wantAlines: ['+l', '+m'], - wantText: ['something ', ' something'], - }, - { - description: 'Don\'t collapse spaces that preceed/follow a empty paragraph', - html: 'something

    something
    ', - wantAlines: ['+l', '', '+m'], - wantText: ['something ', '', ' something'], - }, - { - description: 'Don\'t collapse non-breaking spaces that follow a newline', - html: 'something
       something
    ', - wantAlines: ['+9', '+c'], - wantText: ['something', ' something'], - }, - { - description: 'Don\'t collapse non-breaking spaces that follow a paragraph', - html: 'something

       something
    ', - wantAlines: ['+9', '', '+c'], - wantText: ['something', '', ' something'], - }, - { - description: 'Preserve all spaces when multiple are present', - html: 'Need more space s !
    ', - wantAlines: ['+h*1+4+2'], - wantText: ['Need more space s !'], - }, - { - description: 'Newlines and multiple spaces across newlines should be preserved', - html: ` + { + description: 'Simple', + html: '

    foo

    ', + wantAlines: ['+3'], + wantText: ['foo'], + }, + { + description: 'Line starts with asterisk', + html: '

    *foo

    ', + wantAlines: ['+4'], + wantText: ['*foo'], + }, + { + description: 'Complex nested Li', + html: '
    1. one
      1. 1.1
    2. two
    ', + wantAlines: [ + '*0*4*6*7+1+3', + '*0*5*6*8+1+3', + '*0*4*6*8+1+3', + ], + wantText: [ + '*one', '*1.1', '*two', + ], + }, + { + description: 'Complex list of different types', + html: '
    • one
    • two
    • 0
    • 1
    • 2
      • 3
      • 4
    1. item
      1. item1
      2. item2
    ', + wantAlines: [ + '*0*2*6+1+3', + '*0*2*6+1+3', + '*0*2*6+1+1', + '*0*2*6+1+1', + '*0*2*6+1+1', + '*0*3*6+1+1', + '*0*3*6+1+1', + '*0*4*6*7+1+4', + '*0*5*6*8+1+5', + '*0*5*6*8+1+5', + ], + wantText: [ + '*one', + '*two', + '*0', + '*1', + '*2', + '*3', + '*4', + '*item', + '*item1', + '*item2', + ], + }, + { + description: 'Tests if uls properly get attributes', + html: '
    • a
    • b
    div

    foo

    ', + wantAlines: [ + '*0*2*6+1+1', + '*0*2*6+1+1', + '+3', + '+3', + ], + wantText: ['*a', '*b', 'div', 'foo'], + }, + { + description: 'Tests if indented uls properly get attributes', + html: '
    • a
      • b
    • a

    foo

    ', + wantAlines: [ + '*0*2*6+1+1', + '*0*3*6+1+1', + '*0*2*6+1+1', + '+3', + ], + wantText: ['*a', '*b', '*a', 'foo'], + }, + { + description: 'Tests if ols properly get line numbers when in a normal OL', + html: '
    1. a
    2. b
    3. c

    test

    ', + wantAlines: [ + '*0*4*6*7+1+1', + '*0*4*6*7+1+1', + '*0*4*6*7+1+1', + '+4', + ], + wantText: ['*a', '*b', '*c', 'test'], + noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', + }, + { + description: 'A single completely empty line break within an ol should reset count if OL is closed off..', + html: '
    1. should be 1

    hello

    1. should be 1
    2. should be 2

    ', + wantAlines: [ + '*0*4*6*7+1+b', + '+5', + '*0*4*6*8+1+b', + '*0*4*6*8+1+b', + '', + ], + wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''], + noteToSelf: "Shouldn't include attribute marker in the

    line", + }, + { + description: 'A single

    should create a new line', + html: '

    ', + wantAlines: ['', ''], + wantText: ['', ''], + noteToSelf: '

    should create a line break but not break numbering', + }, + { + description: 'Tests if ols properly get line numbers when in a normal OL #2', + html: 'a
    1. b
      1. c
    notlist

    foo

    ', + wantAlines: [ + '+1', + '*0*4*6*7+1+1', + '*0*5*6*8+1+1', + '+7', + '+3', + ], + wantText: ['a', '*b', '*c', 'notlist', 'foo'], + noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', + }, + { + description: 'First item being an UL then subsequent being OL will fail', + html: '
    • a
      1. b
      2. c
    ', + wantAlines: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'], + wantText: ['a', '*b', '*c'], + noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?', + disabled: true, + }, + { + description: 'A single completely empty line break within an ol should NOT reset count', + html: '
    1. should be 1
    2. should be 2
    3. should be 3

    ', + wantAlines: [], + wantText: ['*should be 1', '*should be 2', '*should be 3'], + noteToSelf: "

    should create a line break but not break numbering -- This is what I can't get working!", + disabled: true, + }, + { + description: 'Content outside body should be ignored', + html: 'titleempty
    ', + wantAlines: ['+5'], + wantText: ['empty'], + }, + { + description: 'Multiple spaces should be preserved', + html: 'Text with more than one space.
    ', + wantAlines: ['+10'], + wantText: ['Text with more than one space.'], + }, + { + description: 'non-breaking and normal space should be preserved', + html: 'Text with  more   than  one space.
    ', + wantAlines: ['+10'], + wantText: ['Text with more than one space.'], + }, + { + description: 'Multiple nbsp should be preserved', + html: '  
    ', + wantAlines: ['+2'], + wantText: [' '], + }, + { + description: 'Multiple nbsp between words ', + html: '  word1  word2   word3
    ', + wantAlines: ['+m'], + wantText: [' word1 word2 word3'], + }, + { + description: 'A non-breaking space preceded by a normal space', + html: '  word1  word2  word3
    ', + wantAlines: ['+l'], + wantText: [' word1 word2 word3'], + }, + { + description: 'A non-breaking space followed by a normal space', + html: '  word1  word2  word3
    ', + wantAlines: ['+l'], + wantText: [' word1 word2 word3'], + }, + { + description: 'Don\'t collapse spaces that follow a newline', + html: 'something
    something
    ', + wantAlines: ['+9', '+m'], + wantText: ['something', ' something'], + }, + { + description: 'Don\'t collapse spaces that follow a empty paragraph', + html: 'something

    something
    ', + wantAlines: ['+9', '', '+m'], + wantText: ['something', '', ' something'], + }, + { + description: 'Don\'t collapse spaces that preceed/follow a newline', + html: 'something
    something
    ', + wantAlines: ['+l', '+m'], + wantText: ['something ', ' something'], + }, + { + description: 'Don\'t collapse spaces that preceed/follow a empty paragraph', + html: 'something

    something
    ', + wantAlines: ['+l', '', '+m'], + wantText: ['something ', '', ' something'], + }, + { + description: 'Don\'t collapse non-breaking spaces that follow a newline', + html: 'something
       something
    ', + wantAlines: ['+9', '+c'], + wantText: ['something', ' something'], + }, + { + description: 'Don\'t collapse non-breaking spaces that follow a paragraph', + html: 'something

       something
    ', + wantAlines: ['+9', '', '+c'], + wantText: ['something', '', ' something'], + }, + { + description: 'Preserve all spaces when multiple are present', + html: 'Need more space s !
    ', + wantAlines: ['+h*1+4+2'], + wantText: ['Need more space s !'], + }, + { + description: 'Newlines and multiple spaces across newlines should be preserved', + html: ` Need more space s !
    `, - wantAlines: ['+19*1+4+b'], - wantText: ['Need more space s !'], - }, - { - description: 'Multiple new lines at the beginning should be preserved', - html: '

    first line

    second line
    ', - wantAlines: ['', '', '', '', '+a', '', '+b'], - wantText: ['', '', '', '', 'first line', '', 'second line'], - }, - { - description: 'A paragraph with multiple lines should not loose spaces when lines are combined', - html: `

    + wantAlines: ['+19*1+4+b'], + wantText: ['Need more space s !'], + }, + { + description: 'Multiple new lines at the beginning should be preserved', + html: '

    first line

    second line
    ', + wantAlines: ['', '', '', '', '+a', '', '+b'], + wantText: ['', '', '', '', 'first line', '', 'second line'], + }, + { + description: 'A paragraph with multiple lines should not loose spaces when lines are combined', + html: `

    а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь

    `, - wantAlines: ['+1t'], - wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'], - }, - { - description: 'lines in preformatted text should be kept intact', - html: `

    + wantAlines: ['+1t'], + wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'], + }, + { + description: 'lines in preformatted text should be kept intact', + html: `

    а б в г ґ д е є ж з и і ї й к л м н о

    multiple
     lines
     in
    @@ -286,101 +275,98 @@ pre
     

    п р с т у ф х ц ч ш щ ю я ь

    `, - wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'], - wantText: [ - 'а б в г ґ д е є ж з и і ї й к л м н о', - 'multiple', - 'lines', - 'in', - 'pre', - 'п р с т у ф х ц ч ш щ ю я ь', - ], - }, - { - description: 'pre should be on a new line not preceded by a space', - html: `

    + wantAlines: ['+11', '+8', '+5', '+2', '+3', '+r'], + wantText: [ + 'а б в г ґ д е є ж з и і ї й к л м н о', + 'multiple', + 'lines', + 'in', + 'pre', + 'п р с т у ф х ц ч ш щ ю я ь', + ], + }, + { + description: 'pre should be on a new line not preceded by a space', + html: `

    1

    preline
     
    `, - wantAlines: ['+6', '+7'], - wantText: [' 1 ', 'preline'], - }, - { - description: 'Preserve spaces on the beginning and end of a element', - html: 'Need more space s !
    ', - wantAlines: ['+f*1+3+1'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces outside elements', - html: 'Need more space s !
    ', - wantAlines: ['+g*1+1+2'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces at the end of an element', - html: 'Need more space s !
    ', - wantAlines: ['+g*1+2+1'], - wantText: ['Need more space s !'], - }, - { - description: 'Preserve spaces at the start of an element', - html: 'Need more space s !
    ', - wantAlines: ['+f*1+2+2'], - wantText: ['Need more space s !'], - }, + wantAlines: ['+6', '+7'], + wantText: [' 1 ', 'preline'], + }, + { + description: 'Preserve spaces on the beginning and end of a element', + html: 'Need more space s !
    ', + wantAlines: ['+f*1+3+1'], + wantText: ['Need more space s !'], + }, + { + description: 'Preserve spaces outside elements', + html: 'Need more space s !
    ', + wantAlines: ['+g*1+1+2'], + wantText: ['Need more space s !'], + }, + { + description: 'Preserve spaces at the end of an element', + html: 'Need more space s !
    ', + wantAlines: ['+g*1+2+1'], + wantText: ['Need more space s !'], + }, + { + description: 'Preserve spaces at the start of an element', + html: 'Need more space s !
    ', + wantAlines: ['+f*1+2+2'], + wantText: ['Need more space s !'], + }, ]; - describe(__filename, function () { - for (const tc of testCases) { - describe(tc.description, function () { - let apool; - let result; - - before(async function () { - if (tc.disabled) return this.skip(); - const {window: {document}} = new jsdom.JSDOM(tc.html); - apool = new AttributePool(); - // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all - // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute - // numbers do not change if the attribute processing code changes.) - for (const attrib of knownAttribs) apool.putAttrib(attrib); - for (const aline of tc.wantAlines) { - for (const op of Changeset.deserializeOps(aline)) { - for (const n of attributes.decodeAttribString(op.attribs)) { - assert(n < knownAttribs.length); - } - } - } - const cc = contentcollector.makeContentCollector(true, null, apool); - cc.collectContent(document.body); - result = cc.finish(); - }); - - it('text matches', async function () { - assert.deepEqual(result.lines, tc.wantText); - }); - - it('alines match', async function () { - assert.deepEqual(result.lineAttribs, tc.wantAlines); - }); - - it('attributes are sorted in canonical order', async function () { - const gotAttribs = []; - const wantAttribs = []; - for (const aline of result.lineAttribs) { - const gotAlineAttribs = []; - gotAttribs.push(gotAlineAttribs); - const wantAlineAttribs = []; - wantAttribs.push(wantAlineAttribs); - for (const op of Changeset.deserializeOps(aline)) { - const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)]; - gotAlineAttribs.push(gotOpAttribs); - wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); - } - } - assert.deepEqual(gotAttribs, wantAttribs); - }); - }); - } + for (const tc of testCases) { + describe(tc.description, function () { + let apool; + let result; + before(async function () { + if (tc.disabled) + return this.skip(); + const { window: { document } } = new jsdom.JSDOM(tc.html); + apool = new AttributePool(); + // To reduce test fragility, the attribute pool is seeded with `knownAttribs`, and all + // attributes in `tc.wantAlines` must be in `knownAttribs`. (This guarantees that attribute + // numbers do not change if the attribute processing code changes.) + for (const attrib of knownAttribs) + apool.putAttrib(attrib); + for (const aline of tc.wantAlines) { + for (const op of Changeset.deserializeOps(aline)) { + for (const n of attributes.decodeAttribString(op.attribs)) { + assert(n < knownAttribs.length); + } + } + } + const cc = contentcollector.makeContentCollector(true, null, apool); + cc.collectContent(document.body); + result = cc.finish(); + }); + it('text matches', async function () { + assert.deepEqual(result.lines, tc.wantText); + }); + it('alines match', async function () { + assert.deepEqual(result.lineAttribs, tc.wantAlines); + }); + it('attributes are sorted in canonical order', async function () { + const gotAttribs = []; + const wantAttribs = []; + for (const aline of result.lineAttribs) { + const gotAlineAttribs = []; + gotAttribs.push(gotAlineAttribs); + const wantAlineAttribs = []; + wantAttribs.push(wantAlineAttribs); + for (const op of Changeset.deserializeOps(aline)) { + const gotOpAttribs = [...attributes.attribsFromString(op.attribs, apool)]; + gotAlineAttribs.push(gotOpAttribs); + wantAlineAttribs.push(attributes.sort([...gotOpAttribs])); + } + } + assert.deepEqual(gotAttribs, wantAttribs); + }); + }); + } }); diff --git a/src/tests/backend/specs/export.js b/src/tests/backend/specs/export.js index d2fcde131..ea4a50be0 100644 --- a/src/tests/backend/specs/export.js +++ b/src/tests/backend/specs/export.js @@ -1,26 +1,21 @@ +import * as common from "../common.js"; +import * as padManager from "../../../node/db/PadManager.js"; +import * as settings from "../../../node/utils/Settings.js"; 'use strict'; - -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const settings = require('../../../node/utils/Settings'); - describe(__filename, function () { - let agent; - const settingsBackup = {}; - - before(async function () { - agent = await common.init(); - settingsBackup.soffice = settings.soffice; - await padManager.getPad('testExportPad', 'test content'); - }); - - after(async function () { - Object.assign(settings, settingsBackup); - }); - - it('returns 500 on export error', async function () { - settings.soffice = 'false'; // '/bin/false' doesn't work on Windows - await agent.get('/p/testExportPad/export/doc') - .expect(500); - }); + let agent; + const settingsBackup = {}; + before(async function () { + agent = await common.init(); + settingsBackup.soffice = settings.soffice; + await padManager.getPad('testExportPad', 'test content'); + }); + after(async function () { + Object.assign(settings, settingsBackup); + }); + it('returns 500 on export error', async function () { + settings.soffice = 'false'; // '/bin/false' doesn't work on Windows + await agent.get('/p/testExportPad/export/doc') + .expect(500); + }); }); diff --git a/src/tests/backend/specs/favicon.js b/src/tests/backend/specs/favicon.js index a5e3095de..c69dfb3af 100644 --- a/src/tests/backend/specs/favicon.js +++ b/src/tests/backend/specs/favicon.js @@ -1,91 +1,83 @@ +import assert$0 from "assert"; +import * as common from "../common.js"; +import fs from "fs"; +import path from "path"; +import * as settings from "../../../node/utils/Settings.js"; +import superagent from "superagent"; 'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const fs = require('fs'); +const assert = assert$0.strict; 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)); - }); + 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)); + }); }); diff --git a/src/tests/backend/specs/health.js b/src/tests/backend/specs/health.js index 0090aedbb..bc640af23 100644 --- a/src/tests/backend/specs/health.js +++ b/src/tests/backend/specs/health.js @@ -1,56 +1,48 @@ +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as settings from "../../../node/utils/Settings.js"; +import superagent from "superagent"; 'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const settings = require('../../../node/utils/Settings'); -const superagent = require('superagent'); - +const assert = assert$0.strict; describe(__filename, function () { - let agent; - const backup = {}; - - const getHealth = () => agent.get('/health') - .accept('application/health+json') - .buffer(true) - .parse(superagent.parse['application/json']) - .expect(200) - .expect((res) => assert.equal(res.type, 'application/health+json')); - - before(async function () { - agent = await common.init(); - }); - - beforeEach(async function () { - backup.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization']) { - backup.settings[setting] = settings[setting]; - } - }); - - afterEach(async function () { - Object.assign(settings, backup.settings); - }); - - it('/health works', async function () { - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - assert.equal(res.body.releaseId, settings.getEpVersion()); - }); - - it('auth is not required', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - const res = await getHealth(); - assert.equal(res.body.status, 'pass'); - }); - - // We actually want to test that no express-session state is created, but that is difficult to do - // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a - // cookie means that no express-session state was created (how would express-session look up the - // session state if no ID was returned to the client?). - it('no cookie is returned', async function () { - const res = await getHealth(); - const cookie = res.headers['set-cookie']; - assert(cookie == null, `unexpected Set-Cookie: ${cookie}`); - }); + let agent; + const backup = {}; + const getHealth = () => agent.get('/health') + .accept('application/health+json') + .buffer(true) + .parse(superagent.parse['application/json']) + .expect(200) + .expect((res) => assert.equal(res.type, 'application/health+json')); + before(async function () { + agent = await common.init(); + }); + beforeEach(async function () { + backup.settings = {}; + for (const setting of ['requireAuthentication', 'requireAuthorization']) { + backup.settings[setting] = settings[setting]; + } + }); + afterEach(async function () { + Object.assign(settings, backup.settings); + }); + it('/health works', async function () { + const res = await getHealth(); + assert.equal(res.body.status, 'pass'); + assert.equal(res.body.releaseId, settings.getEpVersion()); + }); + it('auth is not required', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + const res = await getHealth(); + assert.equal(res.body.status, 'pass'); + }); + // We actually want to test that no express-session state is created, but that is difficult to do + // without intrusive changes or unpleasant ueberdb digging. Instead, we assume that the lack of a + // cookie means that no express-session state was created (how would express-session look up the + // session state if no ID was returned to the client?). + it('no cookie is returned', async function () { + const res = await getHealth(); + const cookie = res.headers['set-cookie']; + assert(cookie == null, `unexpected Set-Cookie: ${cookie}`); + }); }); diff --git a/src/tests/backend/specs/hooks.js b/src/tests/backend/specs/hooks.js index 3120911ae..e8972943a 100644 --- a/src/tests/backend/specs/hooks.js +++ b/src/tests/backend/specs/hooks.js @@ -1,1209 +1,1106 @@ +import assertLegacy from "../assert-legacy.js"; +import * as hooks from "../../../static/js/pluginfw/hooks.js"; +import * as plugins from "../../../static/js/pluginfw/plugin_defs.js"; +import sinon from "sinon"; 'use strict'; - -const assert = require('../assert-legacy').strict; -const hooks = require('../../../static/js/pluginfw/hooks'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const sinon = require('sinon'); - +const assert = assertLegacy.strict; describe(__filename, function () { - const hookName = 'testHook'; - const hookFnName = 'testPluginFileName:testHookFunctionName'; - let testHooks; // Convenience shorthand for plugins.hooks[hookName]. - let hook; // Convenience shorthand for plugins.hooks[hookName][0]. - - beforeEach(async function () { - // Make sure these are not already set so that we don't accidentally step on someone else's - // toes: - assert(plugins.hooks[hookName] == null); - assert(hooks.deprecationNotices[hookName] == null); - assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null); - - // Many of the tests only need a single registered hook function. Set that up here to reduce - // boilerplate. - hook = makeHook(); - plugins.hooks[hookName] = [hook]; - testHooks = plugins.hooks[hookName]; - }); - - afterEach(async function () { - sinon.restore(); - delete plugins.hooks[hookName]; - delete hooks.deprecationNotices[hookName]; - delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; - }); - - const makeHook = (ret) => ({ - hook_name: hookName, - // Many tests will likely want to change this. Unfortunately, we can't use a convenience - // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and - // change behavior depending on the number of parameters. - hook_fn: (hn, ctx, cb) => cb(ret), - hook_fn_name: hookFnName, - part: {plugin: 'testPluginName'}, - }); - - // Hook functions that should work for both synchronous and asynchronous hooks. - const supportedSyncHookFunctions = [ - { - name: 'return non-Promise value, with callback parameter', - fn: (hn, ctx, cb) => 'val', - want: 'val', - syncOk: true, - }, - { - name: 'return non-Promise value, without callback parameter', - fn: (hn, ctx) => 'val', - want: 'val', - syncOk: true, - }, - { - name: 'return undefined, without callback parameter', - fn: (hn, ctx) => {}, - want: undefined, - syncOk: true, - }, - { - name: 'pass non-Promise value to callback', - fn: (hn, ctx, cb) => { cb('val'); }, - want: 'val', - syncOk: true, - }, - { - name: 'pass undefined to callback', - fn: (hn, ctx, cb) => { cb(); }, - want: undefined, - syncOk: true, - }, - { - name: 'return the value returned from the callback', - fn: (hn, ctx, cb) => cb('val'), - want: 'val', - syncOk: true, - }, - { - name: 'throw', - fn: (hn, ctx, cb) => { throw new Error('test exception'); }, - wantErr: 'test exception', - syncOk: true, - }, - ]; - - describe('callHookFnSync', function () { - const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand. - - describe('basic behavior', function () { - it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; - callHookFnSync(hook); - }); - - it('passes context', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; - callHookFnSync(hook, val); - } - }); - - it('returns the value provided to the callback', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; - assert.equal(callHookFnSync(hook, val), val); - } - }); - - it('returns the value returned by the hook function', async function () { - for (const val of ['value', null, undefined]) { - // Must not have the cb parameter otherwise returning undefined will error. - hook.hook_fn = (hn, ctx) => ctx; - assert.equal(callHookFnSync(hook, val), val); - } - }); - - it('does not catch exceptions', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - assert.throws(() => callHookFnSync(hook), {message: 'test exception'}); - }); - - it('callback returns undefined', async function () { - hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; - callHookFnSync(hook); - }); - - it('checks for deprecation', async function () { - sinon.stub(console, 'warn'); - hooks.deprecationNotices[hookName] = 'test deprecation'; - callHookFnSync(hook); - assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); - assert.equal(console.warn.callCount, 1); - assert.match(console.warn.getCall(0).args[0], /test deprecation/); - }); + const hookName = 'testHook'; + const hookFnName = 'testPluginFileName:testHookFunctionName'; + let testHooks; // Convenience shorthand for plugins.hooks[hookName]. + let hook; // Convenience shorthand for plugins.hooks[hookName][0]. + beforeEach(async function () { + // Make sure these are not already set so that we don't accidentally step on someone else's + // toes: + assert(plugins.hooks[hookName] == null); + assert(hooks.deprecationNotices[hookName] == null); + assert(hooks.exportedForTestingOnly.deprecationWarned[hookFnName] == null); + // Many of the tests only need a single registered hook function. Set that up here to reduce + // boilerplate. + hook = makeHook(); + plugins.hooks[hookName] = [hook]; + testHooks = plugins.hooks[hookName]; }); - - describe('supported hook function styles', function () { - for (const tc of supportedSyncHookFunctions) { - it(tc.name, async function () { - sinon.stub(console, 'warn'); - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - const call = () => callHookFnSync(hook); - if (tc.wantErr) { - assert.throws(call, {message: tc.wantErr}); - } else { - assert.equal(call(), tc.want); - } - assert.equal(console.warn.callCount, 0); - assert.equal(console.error.callCount, 0); - }); - } + afterEach(async function () { + sinon.restore(); + delete plugins.hooks[hookName]; + delete hooks.deprecationNotices[hookName]; + delete hooks.exportedForTestingOnly.deprecationWarned[hookFnName]; }); - - describe('bad hook function behavior (other than double settle)', function () { - const promise1 = Promise.resolve('val1'); - const promise2 = Promise.resolve('val2'); - - const testCases = [ - { - name: 'never settles -> buggy hook detected', - // Note that returning undefined without calling the callback is permitted if the function - // has 2 or fewer parameters, so this test function must have 3 parameters. - fn: (hn, ctx, cb) => {}, - wantVal: undefined, - wantError: /UNSETTLED FUNCTION BUG/, - }, - { - name: 'returns a Promise -> buggy hook detected', - fn: () => promise1, - wantVal: promise1, - wantError: /PROHIBITED PROMISE BUG/, - }, - { - name: 'passes a Promise to cb -> buggy hook detected', - fn: (hn, ctx, cb) => cb(promise2), - wantVal: promise2, - wantError: /PROHIBITED PROMISE BUG/, - }, - ]; - - for (const tc of testCases) { - it(tc.name, async function () { - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - assert.equal(callHookFnSync(hook), tc.wantVal); - assert.equal(console.error.callCount, tc.wantError ? 1 : 0); - if (tc.wantError) assert.match(console.error.getCall(0).args[0], tc.wantError); - }); - } + const makeHook = (ret) => ({ + hook_name: hookName, + // Many tests will likely want to change this. Unfortunately, we can't use a convenience + // wrapper like `(...args) => hookFn(..args)` because the hooks look at Function.length and + // change behavior depending on the number of parameters. + hook_fn: (hn, ctx, cb) => cb(ret), + hook_fn_name: hookFnName, + part: { plugin: 'testPluginName' }, }); - - // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second - // time, or call the callback and then return a value.) - describe('bad hook function behavior (double settle)', function () { - beforeEach(async function () { - sinon.stub(console, 'error'); - }); - - // Each item in this array codifies a way to settle a synchronous hook function. Each of the - // test cases below combines two of these behaviors in a single hook function and confirms - // that callHookFnSync both (1) returns the result of the first settle attempt, and - // (2) detects the second settle attempt. - const behaviors = [ + // Hook functions that should work for both synchronous and asynchronous hooks. + const supportedSyncHookFunctions = [ { - name: 'throw', - fn: (cb, err, val) => { throw err; }, - rejects: true, + name: 'return non-Promise value, with callback parameter', + fn: (hn, ctx, cb) => 'val', + want: 'val', + syncOk: true, }, { - name: 'return value', - fn: (cb, err, val) => val, + name: 'return non-Promise value, without callback parameter', + fn: (hn, ctx) => 'val', + want: 'val', + syncOk: true, }, { - name: 'immediately call cb(value)', - fn: (cb, err, val) => cb(val), + name: 'return undefined, without callback parameter', + fn: (hn, ctx) => { }, + want: undefined, + syncOk: true, }, { - name: 'defer call to cb(value)', - fn: (cb, err, val) => { process.nextTick(cb, val); }, - async: true, + name: 'pass non-Promise value to callback', + fn: (hn, ctx, cb) => { cb('val'); }, + want: 'val', + syncOk: true, }, - ]; - - for (const step1 of behaviors) { - // There can't be a second step if the first step is to return or throw. - if (step1.name.startsWith('return ') || step1.name === 'throw') continue; - for (const step2 of behaviors) { - // If step1 and step2 are both async then there would be three settle attempts (first an - // erroneous unsettled return, then async step 1, then async step 2). Handling triple - // settle would complicate the tests, and it is sufficient to test only double settles. - if (step1.async && step2.async) continue; - - it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn, ctx, cb) => { - step1.fn(cb, new Error(ctx.ret1), ctx.ret1); - return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); - }; - - // Temporarily remove unhandled error listeners so that the errors we expect to see - // don't trigger a test failure (or terminate node). - const events = ['uncaughtException', 'unhandledRejection']; - const listenerBackups = {}; - for (const event of events) { - listenerBackups[event] = process.rawListeners(event); - process.removeAllListeners(event); - } - - // We should see an asynchronous error (either an unhandled Promise rejection or an - // uncaught exception) if and only if one of the two steps was asynchronous or there was - // a throw (in which case the double settle is deferred so that the caller sees the - // original error). - const wantAsyncErr = step1.async || step2.async || step2.rejects; - let tempListener; - let asyncErr; - try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err) => { - assert.equal(asyncErr, undefined); - asyncErr = err; - resolve(); - }; - if (!wantAsyncErr) resolve(); - }); - events.forEach((event) => process.on(event, tempListener)); - const call = () => callHookFnSync(hook, {ret1: 'val1', ret2: 'val2'}); - if (step2.rejects) { - assert.throws(call, {message: 'val2'}); - } else if (!step1.async && !step2.async) { - assert.throws(call, {message: /DOUBLE SETTLE BUG/}); - } else { - assert.equal(call(), step1.async ? 'val2' : 'val1'); - } - await seenErrPromise; - } finally { - // Restore the original listeners. - for (const event of events) { - process.off(event, tempListener); - for (const listener of listenerBackups[event]) { - process.on(event, listener); + { + name: 'pass undefined to callback', + fn: (hn, ctx, cb) => { cb(); }, + want: undefined, + syncOk: true, + }, + { + name: 'return the value returned from the callback', + fn: (hn, ctx, cb) => cb('val'), + want: 'val', + syncOk: true, + }, + { + name: 'throw', + fn: (hn, ctx, cb) => { throw new Error('test exception'); }, + wantErr: 'test exception', + syncOk: true, + }, + ]; + describe('callHookFnSync', function () { + const callHookFnSync = hooks.exportedForTestingOnly.callHookFnSync; // Convenience shorthand. + describe('basic behavior', function () { + it('passes hook name', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + callHookFnSync(hook); + }); + it('passes context', async function () { + for (const val of ['value', null, undefined]) { + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; + callHookFnSync(hook, val); } - } - } - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - if (wantAsyncErr) { - assert(asyncErr instanceof Error); - assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); - } - }); - - // This next test is the same as the above test, except the second settle attempt is for - // the same outcome. The two outcomes can't be the same if one step throws and the other - // doesn't, so skip those cases. - if (step1.rejects !== step2.rejects) continue; - - it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { - const err = new Error('val'); - hook.hook_fn = (hn, ctx, cb) => { - step1.fn(cb, err, 'val'); - return step2.fn(cb, err, 'val'); - }; - - const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); - const call = () => callHookFnSync(hook); - if (step2.rejects) { - assert.throws(call, {message: 'val'}); - } else { - assert.equal(call(), 'val'); - } - await errorLogged; - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - }); - } - } - }); - }); - - describe('hooks.callAll', function () { - describe('basic behavior', function () { - it('calls all in order', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); - }); - - it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; - hooks.callAll(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - hooks.callAll(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - hooks.callAll(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; - hooks.callAll(hookName, wantContext); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(hooks.callAll(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(hooks.callAll(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); - assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook()); - assert.deepEqual(hooks.callAll(hookName), []); - }); - }); - }); - - describe('hooks.callFirst', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(hooks.callFirst(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(hooks.callFirst(hookName), []); - }); - - it('passes hook name => {}', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; - hooks.callFirst(hookName); - }); - - it('undefined context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - hooks.callFirst(hookName); - }); - - it('null context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - hooks.callFirst(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; - hooks.callFirst(hookName, wantContext); - }); - - it('predicate never satisfied -> calls all in order', async function () { - const gotCalls = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = () => { gotCalls.push(i); }; - testHooks.push(hook); - } - assert.deepEqual(hooks.callFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('stops when predicate is satisfied', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('skips values that do not satisfy predicate (undefined)', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('skips values that do not satisfy predicate (empty list)', async function () { - testHooks.length = 0; - testHooks.push(makeHook([]), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), ['val1']); - }); - - it('null satisifes the predicate', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook('val1')); - assert.deepEqual(hooks.callFirst(hookName), [null]); - }); - - it('non-empty arrays are returned unmodified', async function () { - const want = ['val1']; - testHooks.length = 0; - testHooks.push(makeHook(want), makeHook(['val2'])); - assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual! - }); - - it('value can be passed via callback', async function () { - const want = {}; - hook.hook_fn = (hn, ctx, cb) => { cb(want); }; - const got = hooks.callFirst(hookName); - assert.deepEqual(got, [want]); - assert.equal(got[0], want); // Note: *NOT* deepEqual! - }); - }); - - describe('callHookFnAsync', function () { - const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand. - - describe('basic behavior', function () { - it('passes hook name', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; - await callHookFnAsync(hook); - }); - - it('passes context', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; - await callHookFnAsync(hook, val); - } - }); - - it('returns the value provided to the callback', async function () { - for (const val of ['value', null, undefined]) { - hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; - assert.equal(await callHookFnAsync(hook, val), val); - assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); - } - }); - - it('returns the value returned by the hook function', async function () { - for (const val of ['value', null, undefined]) { - // Must not have the cb parameter otherwise returning undefined will never resolve. - hook.hook_fn = (hn, ctx) => ctx; - assert.equal(await callHookFnAsync(hook, val), val); - assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); - } - }); - - it('rejects if it throws an exception', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('rejects if rejected Promise passed to callback', async function () { - hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception'))); - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('rejects if rejected Promise returned', async function () { - hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception')); - await assert.rejects(callHookFnAsync(hook), {message: 'test exception'}); - }); - - it('callback returns undefined', async function () { - hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; - await callHookFnAsync(hook); - }); - - it('checks for deprecation', async function () { - sinon.stub(console, 'warn'); - hooks.deprecationNotices[hookName] = 'test deprecation'; - await callHookFnAsync(hook); - assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); - assert.equal(console.warn.callCount, 1); - assert.match(console.warn.getCall(0).args[0], /test deprecation/); - }); - }); - - describe('supported hook function styles', function () { - const supportedHookFunctions = supportedSyncHookFunctions.concat([ - { - name: 'legacy async cb', - fn: (hn, ctx, cb) => { process.nextTick(cb, 'val'); }, - want: 'val', - }, - // Already resolved Promises: - { - name: 'return resolved Promise, with callback parameter', - fn: (hn, ctx, cb) => Promise.resolve('val'), - want: 'val', - }, - { - name: 'return resolved Promise, without callback parameter', - fn: (hn, ctx) => Promise.resolve('val'), - want: 'val', - }, - { - name: 'pass resolved Promise to callback', - fn: (hn, ctx, cb) => { cb(Promise.resolve('val')); }, - want: 'val', - }, - // Not yet resolved Promises: - { - name: 'return unresolved Promise, with callback parameter', - fn: (hn, ctx, cb) => new Promise((resolve) => process.nextTick(resolve, 'val')), - want: 'val', - }, - { - name: 'return unresolved Promise, without callback parameter', - fn: (hn, ctx) => new Promise((resolve) => process.nextTick(resolve, 'val')), - want: 'val', - }, - { - name: 'pass unresolved Promise to callback', - fn: (hn, ctx, cb) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, - want: 'val', - }, - // Already rejected Promises: - { - name: 'return rejected Promise, with callback parameter', - fn: (hn, ctx, cb) => Promise.reject(new Error('test rejection')), - wantErr: 'test rejection', - }, - { - name: 'return rejected Promise, without callback parameter', - fn: (hn, ctx) => Promise.reject(new Error('test rejection')), - wantErr: 'test rejection', - }, - { - name: 'pass rejected Promise to callback', - fn: (hn, ctx, cb) => { cb(Promise.reject(new Error('test rejection'))); }, - wantErr: 'test rejection', - }, - // Not yet rejected Promises: - { - name: 'return unrejected Promise, with callback parameter', - fn: (hn, ctx, cb) => new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - }), - wantErr: 'test rejection', - }, - { - name: 'return unrejected Promise, without callback parameter', - fn: (hn, ctx) => new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - }), - wantErr: 'test rejection', - }, - { - name: 'pass unrejected Promise to callback', - fn: (hn, ctx, cb) => { - cb(new Promise((resolve, reject) => { - process.nextTick(reject, new Error('test rejection')); - })); - }, - wantErr: 'test rejection', - }, - ]); - - for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) { - it(tc.name, async function () { - sinon.stub(console, 'warn'); - sinon.stub(console, 'error'); - hook.hook_fn = tc.fn; - const p = callHookFnAsync(hook); - if (tc.wantErr) { - await assert.rejects(p, {message: tc.wantErr}); - } else { - assert.equal(await p, tc.want); - } - assert.equal(console.warn.callCount, 0); - assert.equal(console.error.callCount, 0); + }); + it('returns the value provided to the callback', async function () { + for (const val of ['value', null, undefined]) { + hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; + assert.equal(callHookFnSync(hook, val), val); + } + }); + it('returns the value returned by the hook function', async function () { + for (const val of ['value', null, undefined]) { + // Must not have the cb parameter otherwise returning undefined will error. + hook.hook_fn = (hn, ctx) => ctx; + assert.equal(callHookFnSync(hook, val), val); + } + }); + it('does not catch exceptions', async function () { + hook.hook_fn = () => { throw new Error('test exception'); }; + assert.throws(() => callHookFnSync(hook), { message: 'test exception' }); + }); + it('callback returns undefined', async function () { + hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; + callHookFnSync(hook); + }); + it('checks for deprecation', async function () { + sinon.stub(console, 'warn'); + hooks.deprecationNotices[hookName] = 'test deprecation'; + callHookFnSync(hook); + assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); + assert.equal(console.warn.callCount, 1); + assert.match(console.warn.getCall(0).args[0], /test deprecation/); + }); + }); + describe('supported hook function styles', function () { + for (const tc of supportedSyncHookFunctions) { + it(tc.name, async function () { + sinon.stub(console, 'warn'); + sinon.stub(console, 'error'); + hook.hook_fn = tc.fn; + const call = () => callHookFnSync(hook); + if (tc.wantErr) { + assert.throws(call, { message: tc.wantErr }); + } + else { + assert.equal(call(), tc.want); + } + assert.equal(console.warn.callCount, 0); + assert.equal(console.error.callCount, 0); + }); + } + }); + describe('bad hook function behavior (other than double settle)', function () { + const promise1 = Promise.resolve('val1'); + const promise2 = Promise.resolve('val2'); + const testCases = [ + { + name: 'never settles -> buggy hook detected', + // Note that returning undefined without calling the callback is permitted if the function + // has 2 or fewer parameters, so this test function must have 3 parameters. + fn: (hn, ctx, cb) => { }, + wantVal: undefined, + wantError: /UNSETTLED FUNCTION BUG/, + }, + { + name: 'returns a Promise -> buggy hook detected', + fn: () => promise1, + wantVal: promise1, + wantError: /PROHIBITED PROMISE BUG/, + }, + { + name: 'passes a Promise to cb -> buggy hook detected', + fn: (hn, ctx, cb) => cb(promise2), + wantVal: promise2, + wantError: /PROHIBITED PROMISE BUG/, + }, + ]; + for (const tc of testCases) { + it(tc.name, async function () { + sinon.stub(console, 'error'); + hook.hook_fn = tc.fn; + assert.equal(callHookFnSync(hook), tc.wantVal); + assert.equal(console.error.callCount, tc.wantError ? 1 : 0); + if (tc.wantError) + assert.match(console.error.getCall(0).args[0], tc.wantError); + }); + } + }); + // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second + // time, or call the callback and then return a value.) + describe('bad hook function behavior (double settle)', function () { + beforeEach(async function () { + sinon.stub(console, 'error'); + }); + // Each item in this array codifies a way to settle a synchronous hook function. Each of the + // test cases below combines two of these behaviors in a single hook function and confirms + // that callHookFnSync both (1) returns the result of the first settle attempt, and + // (2) detects the second settle attempt. + const behaviors = [ + { + name: 'throw', + fn: (cb, err, val) => { throw err; }, + rejects: true, + }, + { + name: 'return value', + fn: (cb, err, val) => val, + }, + { + name: 'immediately call cb(value)', + fn: (cb, err, val) => cb(val), + }, + { + name: 'defer call to cb(value)', + fn: (cb, err, val) => { process.nextTick(cb, val); }, + async: true, + }, + ]; + for (const step1 of behaviors) { + // There can't be a second step if the first step is to return or throw. + if (step1.name.startsWith('return ') || step1.name === 'throw') + continue; + for (const step2 of behaviors) { + // If step1 and step2 are both async then there would be three settle attempts (first an + // erroneous unsettled return, then async step 1, then async step 2). Handling triple + // settle would complicate the tests, and it is sufficient to test only double settles. + if (step1.async && step2.async) + continue; + it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { + hook.hook_fn = (hn, ctx, cb) => { + step1.fn(cb, new Error(ctx.ret1), ctx.ret1); + return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); + }; + // Temporarily remove unhandled error listeners so that the errors we expect to see + // don't trigger a test failure (or terminate node). + const events = ['uncaughtException', 'unhandledRejection']; + const listenerBackups = {}; + for (const event of events) { + listenerBackups[event] = process.rawListeners(event); + process.removeAllListeners(event); + } + // We should see an asynchronous error (either an unhandled Promise rejection or an + // uncaught exception) if and only if one of the two steps was asynchronous or there was + // a throw (in which case the double settle is deferred so that the caller sees the + // original error). + const wantAsyncErr = step1.async || step2.async || step2.rejects; + let tempListener; + let asyncErr; + try { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err) => { + assert.equal(asyncErr, undefined); + asyncErr = err; + resolve(); + }; + if (!wantAsyncErr) + resolve(); + }); + events.forEach((event) => process.on(event, tempListener)); + const call = () => callHookFnSync(hook, { ret1: 'val1', ret2: 'val2' }); + if (step2.rejects) { + assert.throws(call, { message: 'val2' }); + } + else if (!step1.async && !step2.async) { + assert.throws(call, { message: /DOUBLE SETTLE BUG/ }); + } + else { + assert.equal(call(), step1.async ? 'val2' : 'val1'); + } + await seenErrPromise; + } + finally { + // Restore the original listeners. + for (const event of events) { + process.off(event, tempListener); + for (const listener of listenerBackups[event]) { + process.on(event, listener); + } + } + } + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + if (wantAsyncErr) { + assert(asyncErr instanceof Error); + assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); + } + }); + // This next test is the same as the above test, except the second settle attempt is for + // the same outcome. The two outcomes can't be the same if one step throws and the other + // doesn't, so skip those cases. + if (step1.rejects !== step2.rejects) + continue; + it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + const err = new Error('val'); + hook.hook_fn = (hn, ctx, cb) => { + step1.fn(cb, err, 'val'); + return step2.fn(cb, err, 'val'); + }; + const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); + const call = () => callHookFnSync(hook); + if (step2.rejects) { + assert.throws(call, { message: 'val' }); + } + else { + assert.equal(call(), 'val'); + } + await errorLogged; + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + }); + } + } }); - } }); - - // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second - // time, or call the callback and then return a value.) - describe('bad hook function behavior (double settle)', function () { - beforeEach(async function () { - sinon.stub(console, 'error'); - }); - - // Each item in this array codifies a way to settle an asynchronous hook function. Each of the - // test cases below combines two of these behaviors in a single hook function and confirms - // that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2) - // detects the second settle attempt. - // - // The 'when' property specifies the relative time that two behaviors will cause the hook - // function to settle: - // * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then - // behavior1 will settle the hook function before behavior2. - // * Otherwise, behavior2 will settle the hook function before behavior1. - const behaviors = [ - { - name: 'throw', - fn: (cb, err, val) => { throw err; }, - rejects: true, - when: 0, - }, - { - name: 'return value', - fn: (cb, err, val) => val, - // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw' - // immediately settles the hook function, whereas the 'return value' case is settled by a - // .then() function attached to a Promise. EcmaScript guarantees that a .then() function - // attached to a Promise is enqueued on the event loop (not executed immediately) when the - // Promise settles. - when: 1, - }, - { - name: 'immediately call cb(value)', - fn: (cb, err, val) => cb(val), - // This behavior has the same relative time as the 'return value' case because it too is - // settled by a .then() function attached to a Promise. - when: 1, - }, - { - name: 'return resolvedPromise', - fn: (cb, err, val) => Promise.resolve(val), - // This behavior has the same relative time as the 'return value' case because the return - // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees - // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value), - // so returning an already resolved Promise vs. returning a non-Promise value are - // equivalent. - when: 1, - }, - { - name: 'immediately call cb(resolvedPromise)', - fn: (cb, err, val) => cb(Promise.resolve(val)), - when: 1, - }, - { - name: 'return rejectedPromise', - fn: (cb, err, val) => Promise.reject(err), - rejects: true, - when: 1, - }, - { - name: 'immediately call cb(rejectedPromise)', - fn: (cb, err, val) => cb(Promise.reject(err)), - rejects: true, - when: 1, - }, - { - name: 'return unresolvedPromise', - fn: (cb, err, val) => new Promise((resolve) => process.nextTick(resolve, val)), - when: 2, - }, - { - name: 'immediately call cb(unresolvedPromise)', - fn: (cb, err, val) => cb(new Promise((resolve) => process.nextTick(resolve, val))), - when: 2, - }, - { - name: 'return unrejectedPromise', - fn: (cb, err, val) => new Promise((resolve, reject) => process.nextTick(reject, err)), - rejects: true, - when: 2, - }, - { - name: 'immediately call cb(unrejectedPromise)', - fn: (cb, err, val) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), - rejects: true, - when: 2, - }, - { - name: 'defer call to cb(value)', - fn: (cb, err, val) => { process.nextTick(cb, val); }, - when: 2, - }, - { - name: 'defer call to cb(resolvedPromise)', - fn: (cb, err, val) => { process.nextTick(cb, Promise.resolve(val)); }, - when: 2, - }, - { - name: 'defer call to cb(rejectedPromise)', - fn: (cb, err, val) => { process.nextTick(cb, Promise.reject(err)); }, - rejects: true, - when: 2, - }, - { - name: 'defer call to cb(unresolvedPromise)', - fn: (cb, err, val) => { - process.nextTick(() => { - cb(new Promise((resolve) => process.nextTick(resolve, val))); + describe('hooks.callAll', function () { + describe('basic behavior', function () { + it('calls all in order', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + assert.deepEqual(hooks.callAll(hookName), [1, 2, 3]); }); - }, - when: 3, - }, - { - name: 'defer call cb(unrejectedPromise)', - fn: (cb, err, val) => { - process.nextTick(() => { - cb(new Promise((resolve, reject) => process.nextTick(reject, err))); + it('passes hook name', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hooks.callAll(hookName); }); - }, - rejects: true, - when: 3, - }, - ]; - - for (const step1 of behaviors) { - // There can't be a second step if the first step is to return or throw. - if (step1.name.startsWith('return ') || step1.name === 'throw') continue; - for (const step2 of behaviors) { - it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { - hook.hook_fn = (hn, ctx, cb) => { - step1.fn(cb, new Error(ctx.ret1), ctx.ret1); - return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); - }; - - // Temporarily remove unhandled Promise rejection listeners so that the unhandled - // rejections we expect to see don't trigger a test failure (or terminate node). - const event = 'unhandledRejection'; - const listenersBackup = process.rawListeners(event); - process.removeAllListeners(event); - - let tempListener; - let asyncErr; - try { - const seenErrPromise = new Promise((resolve) => { - tempListener = (err) => { - assert.equal(asyncErr, undefined); - asyncErr = err; - resolve(); + it('undefined context -> {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callAll(hookName); + }); + it('null context -> {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callAll(hookName, null); + }); + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hooks.callAll(hookName, wantContext); + }); + }); + describe('result processing', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks.testHook; + assert.deepEqual(hooks.callAll(hookName), []); + }); + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(hooks.callAll(hookName), []); + }); + it('flattens one level', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [1, 2, [3]]); + }); + it('filters out undefined', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [2, [3]]); + }); + it('preserves null', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook([2]), makeHook([[3]])); + assert.deepEqual(hooks.callAll(hookName), [null, 2, [3]]); + }); + it('all undefined -> []', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook()); + assert.deepEqual(hooks.callAll(hookName), []); + }); + }); + }); + describe('hooks.callFirst', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks.testHook; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(hooks.callFirst(hookName), []); + }); + it('passes hook name => {}', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + hooks.callFirst(hookName); + }); + it('undefined context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callFirst(hookName); + }); + it('null context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + hooks.callFirst(hookName, null); + }); + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + hooks.callFirst(hookName, wantContext); + }); + it('predicate never satisfied -> calls all in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { gotCalls.push(i); }; + testHooks.push(hook); + } + assert.deepEqual(hooks.callFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + it('stops when predicate is satisfied', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + it('skips values that do not satisfy predicate (undefined)', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + it('skips values that do not satisfy predicate (empty list)', async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), ['val1']); + }); + it('null satisifes the predicate', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook('val1')); + assert.deepEqual(hooks.callFirst(hookName), [null]); + }); + it('non-empty arrays are returned unmodified', async function () { + const want = ['val1']; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(['val2'])); + assert.equal(hooks.callFirst(hookName), want); // Note: *NOT* deepEqual! + }); + it('value can be passed via callback', async function () { + const want = {}; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + const got = hooks.callFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); + }); + describe('callHookFnAsync', function () { + const callHookFnAsync = hooks.exportedForTestingOnly.callHookFnAsync; // Convenience shorthand. + describe('basic behavior', function () { + it('passes hook name', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + await callHookFnAsync(hook); + }); + it('passes context', async function () { + for (const val of ['value', null, undefined]) { + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, val); }; + await callHookFnAsync(hook, val); + } + }); + it('returns the value provided to the callback', async function () { + for (const val of ['value', null, undefined]) { + hook.hook_fn = (hn, ctx, cb) => { cb(ctx); }; + assert.equal(await callHookFnAsync(hook, val), val); + assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); + } + }); + it('returns the value returned by the hook function', async function () { + for (const val of ['value', null, undefined]) { + // Must not have the cb parameter otherwise returning undefined will never resolve. + hook.hook_fn = (hn, ctx) => ctx; + assert.equal(await callHookFnAsync(hook, val), val); + assert.equal(await callHookFnAsync(hook, Promise.resolve(val)), val); + } + }); + it('rejects if it throws an exception', async function () { + hook.hook_fn = () => { throw new Error('test exception'); }; + await assert.rejects(callHookFnAsync(hook), { message: 'test exception' }); + }); + it('rejects if rejected Promise passed to callback', async function () { + hook.hook_fn = (hn, ctx, cb) => cb(Promise.reject(new Error('test exception'))); + await assert.rejects(callHookFnAsync(hook), { message: 'test exception' }); + }); + it('rejects if rejected Promise returned', async function () { + hook.hook_fn = (hn, ctx, cb) => Promise.reject(new Error('test exception')); + await assert.rejects(callHookFnAsync(hook), { message: 'test exception' }); + }); + it('callback returns undefined', async function () { + hook.hook_fn = (hn, ctx, cb) => { assert.equal(cb('foo'), undefined); }; + await callHookFnAsync(hook); + }); + it('checks for deprecation', async function () { + sinon.stub(console, 'warn'); + hooks.deprecationNotices[hookName] = 'test deprecation'; + await callHookFnAsync(hook); + assert.equal(hooks.exportedForTestingOnly.deprecationWarned[hookFnName], true); + assert.equal(console.warn.callCount, 1); + assert.match(console.warn.getCall(0).args[0], /test deprecation/); + }); + }); + describe('supported hook function styles', function () { + const supportedHookFunctions = supportedSyncHookFunctions.concat([ + { + name: 'legacy async cb', + fn: (hn, ctx, cb) => { process.nextTick(cb, 'val'); }, + want: 'val', + }, + // Already resolved Promises: + { + name: 'return resolved Promise, with callback parameter', + fn: (hn, ctx, cb) => Promise.resolve('val'), + want: 'val', + }, + { + name: 'return resolved Promise, without callback parameter', + fn: (hn, ctx) => Promise.resolve('val'), + want: 'val', + }, + { + name: 'pass resolved Promise to callback', + fn: (hn, ctx, cb) => { cb(Promise.resolve('val')); }, + want: 'val', + }, + // Not yet resolved Promises: + { + name: 'return unresolved Promise, with callback parameter', + fn: (hn, ctx, cb) => new Promise((resolve) => process.nextTick(resolve, 'val')), + want: 'val', + }, + { + name: 'return unresolved Promise, without callback parameter', + fn: (hn, ctx) => new Promise((resolve) => process.nextTick(resolve, 'val')), + want: 'val', + }, + { + name: 'pass unresolved Promise to callback', + fn: (hn, ctx, cb) => { cb(new Promise((resolve) => process.nextTick(resolve, 'val'))); }, + want: 'val', + }, + // Already rejected Promises: + { + name: 'return rejected Promise, with callback parameter', + fn: (hn, ctx, cb) => Promise.reject(new Error('test rejection')), + wantErr: 'test rejection', + }, + { + name: 'return rejected Promise, without callback parameter', + fn: (hn, ctx) => Promise.reject(new Error('test rejection')), + wantErr: 'test rejection', + }, + { + name: 'pass rejected Promise to callback', + fn: (hn, ctx, cb) => { cb(Promise.reject(new Error('test rejection'))); }, + wantErr: 'test rejection', + }, + // Not yet rejected Promises: + { + name: 'return unrejected Promise, with callback parameter', + fn: (hn, ctx, cb) => new Promise((resolve, reject) => { + process.nextTick(reject, new Error('test rejection')); + }), + wantErr: 'test rejection', + }, + { + name: 'return unrejected Promise, without callback parameter', + fn: (hn, ctx) => new Promise((resolve, reject) => { + process.nextTick(reject, new Error('test rejection')); + }), + wantErr: 'test rejection', + }, + { + name: 'pass unrejected Promise to callback', + fn: (hn, ctx, cb) => { + cb(new Promise((resolve, reject) => { + process.nextTick(reject, new Error('test rejection')); + })); + }, + wantErr: 'test rejection', + }, + ]); + for (const tc of supportedSyncHookFunctions.concat(supportedHookFunctions)) { + it(tc.name, async function () { + sinon.stub(console, 'warn'); + sinon.stub(console, 'error'); + hook.hook_fn = tc.fn; + const p = callHookFnAsync(hook); + if (tc.wantErr) { + await assert.rejects(p, { message: tc.wantErr }); + } + else { + assert.equal(await p, tc.want); + } + assert.equal(console.warn.callCount, 0); + assert.equal(console.error.callCount, 0); + }); + } + }); + // Test various ways a hook might attempt to settle twice. (Examples: call the callback a second + // time, or call the callback and then return a value.) + describe('bad hook function behavior (double settle)', function () { + beforeEach(async function () { + sinon.stub(console, 'error'); + }); + // Each item in this array codifies a way to settle an asynchronous hook function. Each of the + // test cases below combines two of these behaviors in a single hook function and confirms + // that callHookFnAsync both (1) resolves to the result of the first settle attempt, and (2) + // detects the second settle attempt. + // + // The 'when' property specifies the relative time that two behaviors will cause the hook + // function to settle: + // * If behavior1.when <= behavior2.when and behavior1 is called before behavior2 then + // behavior1 will settle the hook function before behavior2. + // * Otherwise, behavior2 will settle the hook function before behavior1. + const behaviors = [ + { + name: 'throw', + fn: (cb, err, val) => { throw err; }, + rejects: true, + when: 0, + }, + { + name: 'return value', + fn: (cb, err, val) => val, + // This behavior has a later relative settle time vs. the 'throw' behavior because 'throw' + // immediately settles the hook function, whereas the 'return value' case is settled by a + // .then() function attached to a Promise. EcmaScript guarantees that a .then() function + // attached to a Promise is enqueued on the event loop (not executed immediately) when the + // Promise settles. + when: 1, + }, + { + name: 'immediately call cb(value)', + fn: (cb, err, val) => cb(val), + // This behavior has the same relative time as the 'return value' case because it too is + // settled by a .then() function attached to a Promise. + when: 1, + }, + { + name: 'return resolvedPromise', + fn: (cb, err, val) => Promise.resolve(val), + // This behavior has the same relative time as the 'return value' case because the return + // value is wrapped in a Promise via Promise.resolve(). The EcmaScript standard guarantees + // that Promise.resolve(Promise.resolve(value)) is equivalent to Promise.resolve(value), + // so returning an already resolved Promise vs. returning a non-Promise value are + // equivalent. + when: 1, + }, + { + name: 'immediately call cb(resolvedPromise)', + fn: (cb, err, val) => cb(Promise.resolve(val)), + when: 1, + }, + { + name: 'return rejectedPromise', + fn: (cb, err, val) => Promise.reject(err), + rejects: true, + when: 1, + }, + { + name: 'immediately call cb(rejectedPromise)', + fn: (cb, err, val) => cb(Promise.reject(err)), + rejects: true, + when: 1, + }, + { + name: 'return unresolvedPromise', + fn: (cb, err, val) => new Promise((resolve) => process.nextTick(resolve, val)), + when: 2, + }, + { + name: 'immediately call cb(unresolvedPromise)', + fn: (cb, err, val) => cb(new Promise((resolve) => process.nextTick(resolve, val))), + when: 2, + }, + { + name: 'return unrejectedPromise', + fn: (cb, err, val) => new Promise((resolve, reject) => process.nextTick(reject, err)), + rejects: true, + when: 2, + }, + { + name: 'immediately call cb(unrejectedPromise)', + fn: (cb, err, val) => cb(new Promise((resolve, reject) => process.nextTick(reject, err))), + rejects: true, + when: 2, + }, + { + name: 'defer call to cb(value)', + fn: (cb, err, val) => { process.nextTick(cb, val); }, + when: 2, + }, + { + name: 'defer call to cb(resolvedPromise)', + fn: (cb, err, val) => { process.nextTick(cb, Promise.resolve(val)); }, + when: 2, + }, + { + name: 'defer call to cb(rejectedPromise)', + fn: (cb, err, val) => { process.nextTick(cb, Promise.reject(err)); }, + rejects: true, + when: 2, + }, + { + name: 'defer call to cb(unresolvedPromise)', + fn: (cb, err, val) => { + process.nextTick(() => { + cb(new Promise((resolve) => process.nextTick(resolve, val))); + }); + }, + when: 3, + }, + { + name: 'defer call cb(unrejectedPromise)', + fn: (cb, err, val) => { + process.nextTick(() => { + cb(new Promise((resolve, reject) => process.nextTick(reject, err))); + }); + }, + rejects: true, + when: 3, + }, + ]; + for (const step1 of behaviors) { + // There can't be a second step if the first step is to return or throw. + if (step1.name.startsWith('return ') || step1.name === 'throw') + continue; + for (const step2 of behaviors) { + it(`${step1.name} then ${step2.name} (diff. outcomes) -> log+throw`, async function () { + hook.hook_fn = (hn, ctx, cb) => { + step1.fn(cb, new Error(ctx.ret1), ctx.ret1); + return step2.fn(cb, new Error(ctx.ret2), ctx.ret2); + }; + // Temporarily remove unhandled Promise rejection listeners so that the unhandled + // rejections we expect to see don't trigger a test failure (or terminate node). + const event = 'unhandledRejection'; + const listenersBackup = process.rawListeners(event); + process.removeAllListeners(event); + let tempListener; + let asyncErr; + try { + const seenErrPromise = new Promise((resolve) => { + tempListener = (err) => { + assert.equal(asyncErr, undefined); + asyncErr = err; + resolve(); + }; + }); + process.on(event, tempListener); + const step1Wins = step1.when <= step2.when; + const winningStep = step1Wins ? step1 : step2; + const winningVal = step1Wins ? 'val1' : 'val2'; + const p = callHookFnAsync(hook, { ret1: 'val1', ret2: 'val2' }); + if (winningStep.rejects) { + await assert.rejects(p, { message: winningVal }); + } + else { + assert.equal(await p, winningVal); + } + await seenErrPromise; + } + finally { + // Restore the original listeners. + process.off(event, tempListener); + for (const listener of listenersBackup) { + process.on(event, listener); + } + } + assert.equal(console.error.callCount, 1, `Got errors:\n${console.error.getCalls().map((call) => call.args[0]).join('\n')}`); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + assert(asyncErr instanceof Error); + assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); + }); + // This next test is the same as the above test, except the second settle attempt is for + // the same outcome. The two outcomes can't be the same if one step rejects and the other + // doesn't, so skip those cases. + if (step1.rejects !== step2.rejects) + continue; + it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { + const err = new Error('val'); + hook.hook_fn = (hn, ctx, cb) => { + step1.fn(cb, err, 'val'); + return step2.fn(cb, err, 'val'); + }; + const winningStep = (step1.when <= step2.when) ? step1 : step2; + const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); + const p = callHookFnAsync(hook); + if (winningStep.rejects) { + await assert.rejects(p, { message: 'val' }); + } + else { + assert.equal(await p, 'val'); + } + await errorLogged; + assert.equal(console.error.callCount, 1); + assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); + }); + } + } + }); + }); + describe('hooks.aCallAll', function () { + describe('basic behavior', function () { + it('calls all asynchronously, returns values in order', async function () { + testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. + let nextIndex = 0; + const hookPromises = []; + const hookStarted = []; + const hookFinished = []; + const makeHook = () => { + const i = nextIndex++; + const entry = {}; + hookStarted[i] = false; + hookFinished[i] = false; + hookPromises[i] = entry; + entry.promise = new Promise((resolve) => { + entry.resolve = () => { + hookFinished[i] = true; + resolve(i); + }; + }); + return { hook_fn: () => { + hookStarted[i] = true; + return entry.promise; + } }; }; - }); - process.on(event, tempListener); - const step1Wins = step1.when <= step2.when; - const winningStep = step1Wins ? step1 : step2; - const winningVal = step1Wins ? 'val1' : 'val2'; - const p = callHookFnAsync(hook, {ret1: 'val1', ret2: 'val2'}); - if (winningStep.rejects) { - await assert.rejects(p, {message: winningVal}); - } else { - assert.equal(await p, winningVal); - } - await seenErrPromise; - } finally { - // Restore the original listeners. - process.off(event, tempListener); - for (const listener of listenersBackup) { - process.on(event, listener); - } - } - assert.equal(console.error.callCount, 1, - `Got errors:\n${ - console.error.getCalls().map((call) => call.args[0]).join('\n')}`); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - assert(asyncErr instanceof Error); - assert.match(asyncErr.message, /DOUBLE SETTLE BUG/); - }); - - // This next test is the same as the above test, except the second settle attempt is for - // the same outcome. The two outcomes can't be the same if one step rejects and the other - // doesn't, so skip those cases. - if (step1.rejects !== step2.rejects) continue; - - it(`${step1.name} then ${step2.name} (same outcome) -> only log`, async function () { - const err = new Error('val'); - hook.hook_fn = (hn, ctx, cb) => { - step1.fn(cb, err, 'val'); - return step2.fn(cb, err, 'val'); - }; - const winningStep = (step1.when <= step2.when) ? step1 : step2; - const errorLogged = new Promise((resolve) => console.error.callsFake(resolve)); - const p = callHookFnAsync(hook); - if (winningStep.rejects) { - await assert.rejects(p, {message: 'val'}); - } else { - assert.equal(await p, 'val'); - } - await errorLogged; - assert.equal(console.error.callCount, 1); - assert.match(console.error.getCall(0).args[0], /DOUBLE SETTLE BUG/); - }); - } - } - }); - }); - - describe('hooks.aCallAll', function () { - describe('basic behavior', function () { - it('calls all asynchronously, returns values in order', async function () { - testHooks.length = 0; // Delete the boilerplate hook -- this test doesn't use it. - let nextIndex = 0; - const hookPromises = []; - const hookStarted = []; - const hookFinished = []; - const makeHook = () => { - const i = nextIndex++; - const entry = {}; - hookStarted[i] = false; - hookFinished[i] = false; - hookPromises[i] = entry; - entry.promise = new Promise((resolve) => { - entry.resolve = () => { - hookFinished[i] = true; - resolve(i); - }; - }); - return {hook_fn: () => { - hookStarted[i] = true; - return entry.promise; - }}; - }; - testHooks.push(makeHook(), makeHook()); - const p = hooks.aCallAll(hookName); - assert.deepEqual(hookStarted, [true, true]); - assert.deepEqual(hookFinished, [false, false]); - hookPromises[1].resolve(); - await hookPromises[1].promise; - assert.deepEqual(hookFinished, [false, true]); - hookPromises[0].resolve(); - assert.deepEqual(await p, [0, 1]); - }); - - it('passes hook name', async function () { - hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; - await hooks.aCallAll(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallAll(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallAll(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; - await hooks.aCallAll(hookName, wantContext); - }); - }); - - describe('aCallAll callback', function () { - it('exception in callback rejects', async function () { - const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); }); - await assert.rejects(p, {message: 'test exception'}); - }); - - it('propagates error on exception', async function () { - hook.hook_fn = () => { throw new Error('test exception'); }; - await hooks.aCallAll(hookName, {}, (err) => { - assert(err instanceof Error); - assert.equal(err.message, 'test exception'); - }); - }); - - it('propagages null error on success', async function () { - await hooks.aCallAll(hookName, {}, (err) => { - assert(err == null, `got non-null error: ${err}`); - }); - }); - - it('propagages results on success', async function () { - hook.hook_fn = () => 'val'; - await hooks.aCallAll(hookName, {}, (err, results) => { - assert.deepEqual(results, ['val']); - }); - }); - - it('returns callback return value', async function () { - assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val'); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks[hookName]; - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); - assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.aCallAll(hookName), []); - }); - }); - }); - - describe('hooks.callAllSerial', function () { - describe('basic behavior', function () { - it('calls all asynchronously, serially, in order', async function () { - const gotCalls = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = async () => { - gotCalls.push(i); - // Check gotCalls asynchronously to ensure that the next hook function does not start - // executing before this hook function has resolved. - return await new Promise((resolve) => { - setImmediate(() => { - assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); - resolve(i); - }); + testHooks.push(makeHook(), makeHook()); + const p = hooks.aCallAll(hookName); + assert.deepEqual(hookStarted, [true, true]); + assert.deepEqual(hookFinished, [false, false]); + hookPromises[1].resolve(); + await hookPromises[1].promise; + assert.deepEqual(hookFinished, [false, true]); + hookPromises[0].resolve(); + assert.deepEqual(await p, [0, 1]); }); - }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('passes hook name', async function () { - hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; - await hooks.callAllSerial(hookName); - }); - - it('undefined context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.callAllSerial(hookName); - }); - - it('null context -> {}', async function () { - hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.callAllSerial(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; - await hooks.callAllSerial(hookName, wantContext); - }); - }); - - describe('result processing', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks[hookName]; - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - - it('flattens one level', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); - assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); - }); - - it('filters out undefined', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); - }); - - it('preserves null', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); - assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); - }); - - it('all undefined -> []', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook(Promise.resolve())); - assert.deepEqual(await hooks.callAllSerial(hookName), []); - }); - }); - }); - - describe('hooks.aCallFirst', function () { - it('no registered hooks (undefined) -> []', async function () { - delete plugins.hooks.testHook; - assert.deepEqual(await hooks.aCallFirst(hookName), []); - }); - - it('no registered hooks (empty list) -> []', async function () { - testHooks.length = 0; - assert.deepEqual(await hooks.aCallFirst(hookName), []); - }); - - it('passes hook name => {}', async function () { - hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; - await hooks.aCallFirst(hookName); - }); - - it('undefined context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallFirst(hookName); - }); - - it('null context => {}', async function () { - hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; - await hooks.aCallFirst(hookName, null); - }); - - it('context unmodified', async function () { - const wantContext = {}; - hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; - await hooks.aCallFirst(hookName, wantContext); - }); - - it('default predicate: predicate never satisfied -> calls all in order', async function () { - const gotCalls = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = () => { gotCalls.push(i); }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.aCallFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); - }); - - it('calls hook functions serially', async function () { - const gotCalls = []; - testHooks.length = 0; - for (let i = 0; i < 3; i++) { - const hook = makeHook(); - hook.hook_fn = async () => { - gotCalls.push(i); - // Check gotCalls asynchronously to ensure that the next hook function does not start - // executing before this hook function has resolved. - return await new Promise((resolve) => { - setImmediate(() => { - assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); - resolve(); + it('passes hook name', async function () { + hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; + await hooks.aCallAll(hookName); }); - }); - }; - testHooks.push(hook); - } - assert.deepEqual(await hooks.aCallFirst(hookName), []); - assert.deepEqual(gotCalls, [0, 1, 2]); + it('undefined context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallAll(hookName); + }); + it('null context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallAll(hookName, null); + }); + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; + await hooks.aCallAll(hookName, wantContext); + }); + }); + describe('aCallAll callback', function () { + it('exception in callback rejects', async function () { + const p = hooks.aCallAll(hookName, {}, () => { throw new Error('test exception'); }); + await assert.rejects(p, { message: 'test exception' }); + }); + it('propagates error on exception', async function () { + hook.hook_fn = () => { throw new Error('test exception'); }; + await hooks.aCallAll(hookName, {}, (err) => { + assert(err instanceof Error); + assert.equal(err.message, 'test exception'); + }); + }); + it('propagages null error on success', async function () { + await hooks.aCallAll(hookName, {}, (err) => { + assert(err == null, `got non-null error: ${err}`); + }); + }); + it('propagages results on success', async function () { + hook.hook_fn = () => 'val'; + await hooks.aCallAll(hookName, {}, (err, results) => { + assert.deepEqual(results, ['val']); + }); + }); + it('returns callback return value', async function () { + assert.equal(await hooks.aCallAll(hookName, {}, () => 'val'), 'val'); + }); + }); + describe('result processing', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks[hookName]; + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + it('flattens one level', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(await hooks.aCallAll(hookName), [1, 2, [3]]); + }); + it('filters out undefined', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.aCallAll(hookName), [2, [3]]); + }); + it('preserves null', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); + assert.deepEqual(await hooks.aCallAll(hookName), [null, 2, null]); + }); + it('all undefined -> []', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.aCallAll(hookName), []); + }); + }); }); - - it('default predicate: stops when satisfied', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + describe('hooks.callAllSerial', function () { + describe('basic behavior', function () { + it('calls all asynchronously, serially, in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(i); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.callAllSerial(hookName), [0, 1, 2]); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + it('passes hook name', async function () { + hook.hook_fn = async (hn) => { assert.equal(hn, hookName); }; + await hooks.callAllSerial(hookName); + }); + it('undefined context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.callAllSerial(hookName); + }); + it('null context -> {}', async function () { + hook.hook_fn = async (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.callAllSerial(hookName, null); + }); + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = async (hn, ctx) => { assert.equal(ctx, wantContext); }; + await hooks.callAllSerial(hookName, wantContext); + }); + }); + describe('result processing', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks[hookName]; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + it('flattens one level', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook([2]), makeHook([[3]])); + assert.deepEqual(await hooks.callAllSerial(hookName), [1, 2, [3]]); + }); + it('filters out undefined', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook([2]), makeHook([[3]]), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.callAllSerial(hookName), [2, [3]]); + }); + it('preserves null', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook([2]), makeHook(Promise.resolve(null))); + assert.deepEqual(await hooks.callAllSerial(hookName), [null, 2, null]); + }); + it('all undefined -> []', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook(Promise.resolve())); + assert.deepEqual(await hooks.callAllSerial(hookName), []); + }); + }); }); - - it('default predicate: skips values that do not satisfy (undefined)', async function () { - testHooks.length = 0; - testHooks.push(makeHook(), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + describe('hooks.aCallFirst', function () { + it('no registered hooks (undefined) -> []', async function () { + delete plugins.hooks.testHook; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + it('no registered hooks (empty list) -> []', async function () { + testHooks.length = 0; + assert.deepEqual(await hooks.aCallFirst(hookName), []); + }); + it('passes hook name => {}', async function () { + hook.hook_fn = (hn) => { assert.equal(hn, hookName); }; + await hooks.aCallFirst(hookName); + }); + it('undefined context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallFirst(hookName); + }); + it('null context => {}', async function () { + hook.hook_fn = (hn, ctx) => { assert.deepEqual(ctx, {}); }; + await hooks.aCallFirst(hookName, null); + }); + it('context unmodified', async function () { + const wantContext = {}; + hook.hook_fn = (hn, ctx) => { assert.equal(ctx, wantContext); }; + await hooks.aCallFirst(hookName, wantContext); + }); + it('default predicate: predicate never satisfied -> calls all in order', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = () => { gotCalls.push(i); }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + it('calls hook functions serially', async function () { + const gotCalls = []; + testHooks.length = 0; + for (let i = 0; i < 3; i++) { + const hook = makeHook(); + hook.hook_fn = async () => { + gotCalls.push(i); + // Check gotCalls asynchronously to ensure that the next hook function does not start + // executing before this hook function has resolved. + return await new Promise((resolve) => { + setImmediate(() => { + assert.deepEqual(gotCalls, [...Array(i + 1).keys()]); + resolve(); + }); + }); + }; + testHooks.push(hook); + } + assert.deepEqual(await hooks.aCallFirst(hookName), []); + assert.deepEqual(gotCalls, [0, 1, 2]); + }); + it('default predicate: stops when satisfied', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1'), makeHook('val2')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + it('default predicate: skips values that do not satisfy (undefined)', async function () { + testHooks.length = 0; + testHooks.push(makeHook(), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + it('default predicate: skips values that do not satisfy (empty list)', async function () { + testHooks.length = 0; + testHooks.push(makeHook([]), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); + }); + it('default predicate: null satisifes', async function () { + testHooks.length = 0; + testHooks.push(makeHook(null), makeHook('val1')); + assert.deepEqual(await hooks.aCallFirst(hookName), [null]); + }); + it('custom predicate: called for each hook function', async function () { + testHooks.length = 0; + testHooks.push(makeHook(0), makeHook(1), makeHook(2)); + let got = 0; + await hooks.aCallFirst(hookName, null, null, (val) => { ++got; return false; }); + assert.equal(got, 3); + }); + it('custom predicate: boolean false/true continues/stops iteration', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2; + }; + assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); + assert.equal(nCall, 2); + }); + it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { + testHooks.length = 0; + testHooks.push(makeHook(1), makeHook(2), makeHook(3)); + let nCall = 0; + const predicate = (val) => { + assert.deepEqual(val, [++nCall]); + return nCall === 2 ? {} : null; + }; + assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); + assert.equal(nCall, 2); + }); + it('custom predicate: array value passed unmodified to predicate', async function () { + const want = [0]; + hook.hook_fn = () => want; + const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! + await hooks.aCallFirst(hookName, null, null, predicate); + }); + it('custom predicate: normalized value passed to predicate (undefined)', async function () { + const predicate = (got) => { assert.deepEqual(got, []); }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + it('custom predicate: normalized value passed to predicate (null)', async function () { + hook.hook_fn = () => null; + const predicate = (got) => { assert.deepEqual(got, [null]); }; + await hooks.aCallFirst(hookName, null, null, predicate); + }); + it('non-empty arrays are returned unmodified', async function () { + const want = ['val1']; + testHooks.length = 0; + testHooks.push(makeHook(want), makeHook(['val2'])); + assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual! + }); + it('value can be passed via callback', async function () { + const want = {}; + hook.hook_fn = (hn, ctx, cb) => { cb(want); }; + const got = await hooks.aCallFirst(hookName); + assert.deepEqual(got, [want]); + assert.equal(got[0], want); // Note: *NOT* deepEqual! + }); }); - - it('default predicate: skips values that do not satisfy (empty list)', async function () { - testHooks.length = 0; - testHooks.push(makeHook([]), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), ['val1']); - }); - - it('default predicate: null satisifes', async function () { - testHooks.length = 0; - testHooks.push(makeHook(null), makeHook('val1')); - assert.deepEqual(await hooks.aCallFirst(hookName), [null]); - }); - - it('custom predicate: called for each hook function', async function () { - testHooks.length = 0; - testHooks.push(makeHook(0), makeHook(1), makeHook(2)); - let got = 0; - await hooks.aCallFirst(hookName, null, null, (val) => { ++got; return false; }); - assert.equal(got, 3); - }); - - it('custom predicate: boolean false/true continues/stops iteration', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - let nCall = 0; - const predicate = (val) => { - assert.deepEqual(val, [++nCall]); - return nCall === 2; - }; - assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); - assert.equal(nCall, 2); - }); - - it('custom predicate: non-boolean falsy/truthy continues/stops iteration', async function () { - testHooks.length = 0; - testHooks.push(makeHook(1), makeHook(2), makeHook(3)); - let nCall = 0; - const predicate = (val) => { - assert.deepEqual(val, [++nCall]); - return nCall === 2 ? {} : null; - }; - assert.deepEqual(await hooks.aCallFirst(hookName, null, null, predicate), [2]); - assert.equal(nCall, 2); - }); - - it('custom predicate: array value passed unmodified to predicate', async function () { - const want = [0]; - hook.hook_fn = () => want; - const predicate = (got) => { assert.equal(got, want); }; // Note: *NOT* deepEqual! - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('custom predicate: normalized value passed to predicate (undefined)', async function () { - const predicate = (got) => { assert.deepEqual(got, []); }; - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('custom predicate: normalized value passed to predicate (null)', async function () { - hook.hook_fn = () => null; - const predicate = (got) => { assert.deepEqual(got, [null]); }; - await hooks.aCallFirst(hookName, null, null, predicate); - }); - - it('non-empty arrays are returned unmodified', async function () { - const want = ['val1']; - testHooks.length = 0; - testHooks.push(makeHook(want), makeHook(['val2'])); - assert.equal(await hooks.aCallFirst(hookName), want); // Note: *NOT* deepEqual! - }); - - it('value can be passed via callback', async function () { - const want = {}; - hook.hook_fn = (hn, ctx, cb) => { cb(want); }; - const got = await hooks.aCallFirst(hookName); - assert.deepEqual(got, [want]); - assert.equal(got[0], want); // Note: *NOT* deepEqual! - }); - }); }); diff --git a/src/tests/backend/specs/messages.js b/src/tests/backend/specs/messages.js index bccb2584d..e79a7e3d0 100644 --- a/src/tests/backend/specs/messages.js +++ b/src/tests/backend/specs/messages.js @@ -1,171 +1,159 @@ +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as padManager from "../../../node/db/PadManager.js"; +import * as plugins from "../../../static/js/pluginfw/plugin_defs.js"; +import * as readOnlyManager from "../../../node/db/ReadOnlyManager.js"; 'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); - +const assert = assert$0.strict; describe(__filename, function () { - let agent; - let pad; - let padId; - let roPadId; - let rev; - let socket; - let roSocket; - const backups = {}; - - before(async function () { - agent = await common.init(); - }); - - beforeEach(async function () { - backups.hooks = {handleMessageSecurity: plugins.hooks.handleMessageSecurity}; - plugins.hooks.handleMessageSecurity = []; - padId = common.randomString(); - assert(!await padManager.doesPadExist(padId)); - pad = await padManager.getPad(padId, 'dummy text\n'); - await pad.setText('\n'); // Make sure the pad is created. - assert.equal(pad.text(), '\n'); - let res = await agent.get(`/p/${padId}`).expect(200); - socket = await common.connect(res); - const {type, data: clientVars} = await common.handshake(socket, padId); - assert.equal(type, 'CLIENT_VARS'); - rev = clientVars.collab_client_vars.rev; - - roPadId = await readOnlyManager.getReadOnlyId(padId); - res = await agent.get(`/p/${roPadId}`).expect(200); - roSocket = await common.connect(res); - await common.handshake(roSocket, roPadId); - }); - - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - if (socket != null) socket.close(); - socket = null; - if (roSocket != null) roSocket.close(); - roSocket = null; - if (pad != null) await pad.remove(); - pad = null; - }); - - describe('CHANGESET_REQ', function () { - it('users are unable to read changesets from other pads', async function () { - const otherPadId = `${padId}other`; - assert(!await padManager.doesPadExist(otherPadId)); - const otherPad = await padManager.getPad(otherPadId, 'other text\n'); - try { - await otherPad.setText('other text\n'); - const resP = common.waitForSocketEvent(roSocket, 'message'); - await common.sendMessage(roSocket, { - component: 'pad', - padId: otherPadId, // The server should ignore this. - type: 'CHANGESET_REQ', - data: { - granularity: 1, - start: 0, - requestID: 'requestId', - }, + let agent; + let pad; + let padId; + let roPadId; + let rev; + let socket; + let roSocket; + const backups = {}; + before(async function () { + agent = await common.init(); + }); + beforeEach(async function () { + backups.hooks = { handleMessageSecurity: plugins.hooks.handleMessageSecurity }; + plugins.hooks.handleMessageSecurity = []; + padId = common.randomString(); + assert(!await padManager.doesPadExist(padId)); + pad = await padManager.getPad(padId, 'dummy text\n'); + await pad.setText('\n'); // Make sure the pad is created. + assert.equal(pad.text(), '\n'); + let res = await agent.get(`/p/${padId}`).expect(200); + socket = await common.connect(res); + const { type, data: clientVars } = await common.handshake(socket, padId); + assert.equal(type, 'CLIENT_VARS'); + rev = clientVars.collab_client_vars.rev; + roPadId = await readOnlyManager.getReadOnlyId(padId); + res = await agent.get(`/p/${roPadId}`).expect(200); + roSocket = await common.connect(res); + await common.handshake(roSocket, roPadId); + }); + afterEach(async function () { + Object.assign(plugins.hooks, backups.hooks); + if (socket != null) + socket.close(); + socket = null; + if (roSocket != null) + roSocket.close(); + roSocket = null; + if (pad != null) + await pad.remove(); + pad = null; + }); + describe('CHANGESET_REQ', function () { + it('users are unable to read changesets from other pads', async function () { + const otherPadId = `${padId}other`; + assert(!await padManager.doesPadExist(otherPadId)); + const otherPad = await padManager.getPad(otherPadId, 'other text\n'); + try { + await otherPad.setText('other text\n'); + const resP = common.waitForSocketEvent(roSocket, 'message'); + await common.sendMessage(roSocket, { + component: 'pad', + padId: otherPadId, + type: 'CHANGESET_REQ', + data: { + granularity: 1, + start: 0, + requestID: 'requestId', + }, + }); + const res = await resP; + assert.equal(res.type, 'CHANGESET_REQ'); + assert.equal(res.data.requestID, 'requestId'); + // Should match padId's text, not otherPadId's text. + assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/); + } + finally { + await otherPad.remove(); + } }); - const res = await resP; - assert.equal(res.type, 'CHANGESET_REQ'); - assert.equal(res.data.requestID, 'requestId'); - // Should match padId's text, not otherPadId's text. - assert.match(res.data.forwardsChangesets[0], /^[^$]*\$dummy text\n/); - } finally { - await otherPad.remove(); - } }); - }); - - describe('USER_CHANGES', function () { - const sendUserChanges = - async (socket, cs) => await common.sendUserChanges(socket, {baseRev: rev, changeset: cs}); - const assertAccepted = async (socket, wantRev) => { - await common.waitForAcceptCommit(socket, wantRev); - rev = wantRev; - }; - const assertRejected = async (socket) => { - const msg = await common.waitForSocketEvent(socket, 'message'); - assert.deepEqual(msg, {disconnect: 'badChangeset'}); - }; - - it('changes are applied', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - assert.equal(pad.text(), 'hello\n'); + describe('USER_CHANGES', function () { + const sendUserChanges = async (socket, cs) => await common.sendUserChanges(socket, { baseRev: rev, changeset: cs }); + const assertAccepted = async (socket, wantRev) => { + await common.waitForAcceptCommit(socket, wantRev); + rev = wantRev; + }; + const assertRejected = async (socket) => { + const msg = await common.waitForSocketEvent(socket, 'message'); + assert.deepEqual(msg, { disconnect: 'badChangeset' }); + }; + it('changes are applied', async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, 'Z:1>5+5$hello'), + ]); + assert.equal(pad.text(), 'hello\n'); + }); + it('bad changeset is rejected', async function () { + await Promise.all([ + assertRejected(socket), + sendUserChanges(socket, 'this is not a valid changeset'), + ]); + }); + it('retransmission is accepted, has no effect', async function () { + const cs = 'Z:1>5+5$hello'; + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, cs), + ]); + --rev; + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, cs), + ]); + assert.equal(pad.text(), 'hello\n'); + }); + it('identity changeset is accepted, has no effect', async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, 'Z:1>5+5$hello'), + ]); + await Promise.all([ + assertAccepted(socket, rev), + sendUserChanges(socket, 'Z:6>0$'), + ]); + assert.equal(pad.text(), 'hello\n'); + }); + it('non-identity changeset with no net change is accepted, has no effect', async function () { + await Promise.all([ + assertAccepted(socket, rev + 1), + sendUserChanges(socket, 'Z:1>5+5$hello'), + ]); + await Promise.all([ + assertAccepted(socket, rev), + sendUserChanges(socket, 'Z:6>0-5+5$hello'), + ]); + assert.equal(pad.text(), 'hello\n'); + }); + it('handleMessageSecurity can grant one-time write access', async function () { + const cs = 'Z:1>5+5$hello'; + const errRegEx = /write attempt on read-only pad/; + // First try to send a change and verify that it was dropped. + await assert.rejects(sendUserChanges(roSocket, cs), errRegEx); + // sendUserChanges() waits for message ack, so if the message was accepted then head should + // have already incremented by the time we get here. + assert.equal(pad.head, rev); // Not incremented. + // Now allow the change. + plugins.hooks.handleMessageSecurity.push({ hook_fn: () => 'permitOnce' }); + await Promise.all([ + assertAccepted(roSocket, rev + 1), + sendUserChanges(roSocket, cs), + ]); + assert.equal(pad.text(), 'hello\n'); + // The next change should be dropped. + plugins.hooks.handleMessageSecurity = []; + await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx); + assert.equal(pad.head, rev); // Not incremented. + assert.equal(pad.text(), 'hello\n'); + }); }); - - it('bad changeset is rejected', async function () { - await Promise.all([ - assertRejected(socket), - sendUserChanges(socket, 'this is not a valid changeset'), - ]); - }); - - it('retransmission is accepted, has no effect', async function () { - const cs = 'Z:1>5+5$hello'; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - --rev; - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, cs), - ]); - assert.equal(pad.text(), 'hello\n'); - }); - - it('identity changeset is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0$'), - ]); - assert.equal(pad.text(), 'hello\n'); - }); - - it('non-identity changeset with no net change is accepted, has no effect', async function () { - await Promise.all([ - assertAccepted(socket, rev + 1), - sendUserChanges(socket, 'Z:1>5+5$hello'), - ]); - await Promise.all([ - assertAccepted(socket, rev), - sendUserChanges(socket, 'Z:6>0-5+5$hello'), - ]); - assert.equal(pad.text(), 'hello\n'); - }); - - it('handleMessageSecurity can grant one-time write access', async function () { - const cs = 'Z:1>5+5$hello'; - const errRegEx = /write attempt on read-only pad/; - // First try to send a change and verify that it was dropped. - await assert.rejects(sendUserChanges(roSocket, cs), errRegEx); - // sendUserChanges() waits for message ack, so if the message was accepted then head should - // have already incremented by the time we get here. - assert.equal(pad.head, rev); // Not incremented. - - // Now allow the change. - plugins.hooks.handleMessageSecurity.push({hook_fn: () => 'permitOnce'}); - await Promise.all([ - assertAccepted(roSocket, rev + 1), - sendUserChanges(roSocket, cs), - ]); - assert.equal(pad.text(), 'hello\n'); - - // The next change should be dropped. - plugins.hooks.handleMessageSecurity = []; - await assert.rejects(sendUserChanges(roSocket, 'Z:6>6=5+6$ world'), errRegEx); - assert.equal(pad.head, rev); // Not incremented. - assert.equal(pad.text(), 'hello\n'); - }); - }); }); diff --git a/src/tests/backend/specs/pad_utils.js b/src/tests/backend/specs/pad_utils.js index b4e815187..b2adae91c 100644 --- a/src/tests/backend/specs/pad_utils.js +++ b/src/tests/backend/specs/pad_utils.js @@ -1,43 +1,38 @@ +import assert$0 from "assert"; +import { padutils } from "../../../static/js/pad_utils.js"; 'use strict'; - -const assert = require('assert').strict; -const {padutils} = require('../../../static/js/pad_utils'); - +const assert = assert$0.strict; describe(__filename, function () { - describe('warnDeprecated', function () { - const {warnDeprecated} = padutils; - const backups = {}; - - before(async function () { - backups.logger = warnDeprecated.logger; + describe('warnDeprecated', function () { + const { warnDeprecated } = padutils; + const backups = {}; + before(async function () { + backups.logger = warnDeprecated.logger; + }); + afterEach(async function () { + warnDeprecated.logger = backups.logger; + delete warnDeprecated._rl; // Reset internal rate limiter state. + }); + it('includes the stack', async function () { + let got; + warnDeprecated.logger = { warn: (stack) => got = stack }; + warnDeprecated(); + assert(got.includes(__filename)); + }); + it('rate limited', async function () { + let got = 0; + warnDeprecated.logger = { warn: () => ++got }; + warnDeprecated(); // Initialize internal rate limiter state. + const { period } = warnDeprecated._rl; + got = 0; + const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]]; + for (const [now, want] of testCases) { // In a loop so that the stack trace is the same. + warnDeprecated._rl.now = () => now; + warnDeprecated(); + assert.equal(got, want); + } + warnDeprecated(); // Should have a different stack trace. + assert.equal(got, testCases[testCases.length - 1][1] + 1); + }); }); - - afterEach(async function () { - warnDeprecated.logger = backups.logger; - delete warnDeprecated._rl; // Reset internal rate limiter state. - }); - - it('includes the stack', async function () { - let got; - warnDeprecated.logger = {warn: (stack) => got = stack}; - warnDeprecated(); - assert(got.includes(__filename)); - }); - - it('rate limited', async function () { - let got = 0; - warnDeprecated.logger = {warn: () => ++got}; - warnDeprecated(); // Initialize internal rate limiter state. - const {period} = warnDeprecated._rl; - got = 0; - const testCases = [[0, 1], [0, 1], [period - 1, 1], [period, 2]]; - for (const [now, want] of testCases) { // In a loop so that the stack trace is the same. - warnDeprecated._rl.now = () => now; - warnDeprecated(); - assert.equal(got, want); - } - warnDeprecated(); // Should have a different stack trace. - assert.equal(got, testCases[testCases.length - 1][1] + 1); - }); - }); }); diff --git a/src/tests/backend/specs/pads-with-spaces.js b/src/tests/backend/specs/pads-with-spaces.js index 0db99865b..f855340ff 100644 --- a/src/tests/backend/specs/pads-with-spaces.js +++ b/src/tests/backend/specs/pads-with-spaces.js @@ -1,24 +1,20 @@ +import * as common from "../common.js"; +import assertLegacy from "../assert-legacy.js"; 'use strict'; - -const common = require('../common'); -const assert = require('../assert-legacy').strict; - +const assert = assertLegacy.strict; let agent; - describe(__filename, function () { - before(async function () { - agent = await common.init(); - }); - - it('supports pads with spaces, regression test for #4883', async function () { - await agent.get('/p/pads with spaces') - .expect(302) - .expect('location', 'pads_with_spaces'); - }); - - it('supports pads with spaces and query, regression test for #4883', async function () { - await agent.get('/p/pads with spaces?showChat=true&noColors=false') - .expect(302) - .expect('location', 'pads_with_spaces?showChat=true&noColors=false'); - }); + before(async function () { + agent = await common.init(); + }); + it('supports pads with spaces, regression test for #4883', async function () { + await agent.get('/p/pads with spaces') + .expect(302) + .expect('location', 'pads_with_spaces'); + }); + it('supports pads with spaces and query, regression test for #4883', async function () { + await agent.get('/p/pads with spaces?showChat=true&noColors=false') + .expect(302) + .expect('location', 'pads_with_spaces?showChat=true&noColors=false'); + }); }); diff --git a/src/tests/backend/specs/promises.js b/src/tests/backend/specs/promises.js index ad0c1ad92..45fb135cb 100644 --- a/src/tests/backend/specs/promises.js +++ b/src/tests/backend/specs/promises.js @@ -1,85 +1,76 @@ -const assert = require('assert').strict; -const promises = require('../../../node/utils/promises'); - +import assert$0 from "assert"; +import * as promises from "../../../node/utils/promises.js"; +const assert = assert$0.strict; describe(__filename, function () { - describe('promises.timesLimit', function () { - let wantIndex = 0; - const testPromises = []; - const makePromise = (index) => { - // Make sure index increases by one each time. - assert.equal(index, wantIndex++); - // Save the resolve callback (so the test can trigger resolution) - // and the promise itself (to wait for resolve to take effect). - const p = {}; - const promise = new Promise((resolve) => { - p.resolve = resolve; - }); - p.promise = promise; - testPromises.push(p); - return p.promise; - }; - - const total = 11; - const concurrency = 7; - const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); - - it('honors concurrency', async function () { - assert.equal(wantIndex, concurrency); + describe('promises.timesLimit', function () { + let wantIndex = 0; + const testPromises = []; + const makePromise = (index) => { + // Make sure index increases by one each time. + assert.equal(index, wantIndex++); + // Save the resolve callback (so the test can trigger resolution) + // and the promise itself (to wait for resolve to take effect). + const p = {}; + const promise = new Promise((resolve) => { + p.resolve = resolve; + }); + p.promise = promise; + testPromises.push(p); + return p.promise; + }; + const total = 11; + const concurrency = 7; + const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); + it('honors concurrency', async function () { + assert.equal(wantIndex, concurrency); + }); + it('creates another when one completes', async function () { + const { promise, resolve } = testPromises.shift(); + resolve(); + await promise; + assert.equal(wantIndex, concurrency + 1); + }); + it('creates the expected total number of promises', async function () { + while (testPromises.length > 0) { + // Resolve them in random order to ensure that the resolution order doesn't matter. + const i = Math.floor(Math.random() * Math.floor(testPromises.length)); + const { promise, resolve } = testPromises.splice(i, 1)[0]; + resolve(); + await promise; + } + assert.equal(wantIndex, total); + }); + it('resolves', async function () { + await timesLimitPromise; + }); + it('does not create too many promises if total < concurrency', async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + const total = 7; + const concurrency = 11; + const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); + while (testPromises.length > 0) { + const { promise, resolve } = testPromises.pop(); + resolve(); + await promise; + } + await timesLimitPromise; + assert.equal(wantIndex, total); + }); + it('accepts total === 0, concurrency > 0', async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, concurrency, makePromise); + assert.equal(wantIndex, 0); + }); + it('accepts total === 0, concurrency === 0', async function () { + wantIndex = 0; + assert.equal(testPromises.length, 0); + await promises.timesLimit(0, 0, makePromise); + assert.equal(wantIndex, 0); + }); + it('rejects total > 0, concurrency === 0', async function () { + await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError); + }); }); - - it('creates another when one completes', async function () { - const {promise, resolve} = testPromises.shift(); - resolve(); - await promise; - assert.equal(wantIndex, concurrency + 1); - }); - - it('creates the expected total number of promises', async function () { - while (testPromises.length > 0) { - // Resolve them in random order to ensure that the resolution order doesn't matter. - const i = Math.floor(Math.random() * Math.floor(testPromises.length)); - const {promise, resolve} = testPromises.splice(i, 1)[0]; - resolve(); - await promise; - } - assert.equal(wantIndex, total); - }); - - it('resolves', async function () { - await timesLimitPromise; - }); - - it('does not create too many promises if total < concurrency', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - const total = 7; - const concurrency = 11; - const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise); - while (testPromises.length > 0) { - const {promise, resolve} = testPromises.pop(); - resolve(); - await promise; - } - await timesLimitPromise; - assert.equal(wantIndex, total); - }); - - it('accepts total === 0, concurrency > 0', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - await promises.timesLimit(0, concurrency, makePromise); - assert.equal(wantIndex, 0); - }); - - it('accepts total === 0, concurrency === 0', async function () { - wantIndex = 0; - assert.equal(testPromises.length, 0); - await promises.timesLimit(0, 0, makePromise); - assert.equal(wantIndex, 0); - }); - - it('rejects total > 0, concurrency === 0', async function () { - await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError); - }); - }); }); diff --git a/src/tests/backend/specs/regression-db.js b/src/tests/backend/specs/regression-db.js index 388b8346a..aa81bace2 100644 --- a/src/tests/backend/specs/regression-db.js +++ b/src/tests/backend/specs/regression-db.js @@ -1,30 +1,25 @@ +import * as AuthorManager from "../../../node/db/AuthorManager.js"; +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as db from "../../../node/db/DB.js"; 'use strict'; - -const AuthorManager = require('../../../node/db/AuthorManager'); -const assert = require('assert').strict; -const common = require('../common'); -const db = require('../../../node/db/DB'); - +const assert = assert$0.strict; 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 () { - const {authorID} = await AuthorManager.createAuthor(); // Should block until db.set() finishes. - assert(await AuthorManager.doesAuthorExist(authorID)); - }); + 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 () { + const { authorID } = await AuthorManager.createAuthor(); // Should block until db.set() finishes. + assert(await AuthorManager.doesAuthorExist(authorID)); + }); }); diff --git a/src/tests/backend/specs/sanitizePathname.js b/src/tests/backend/specs/sanitizePathname.js index 767221920..c99f3aafe 100644 --- a/src/tests/backend/specs/sanitizePathname.js +++ b/src/tests/backend/specs/sanitizePathname.js @@ -1,96 +1,93 @@ +import assert$0 from "assert"; +import path from "path"; +import sanitizePathname from "../../../node/utils/sanitizePathname.js"; 'use strict'; - -const assert = require('assert').strict; -const path = require('path'); -const sanitizePathname = require('../../../node/utils/sanitizePathname'); - +const assert = assert$0.strict; 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 || ''} -> ${want}`, async function () { - assert.equal(sanitizePathname(p, path[platform]), want); - }); - } - }); - - it('default path API', async function () { - assert.equal(sanitizePathname('foo'), 'foo'); - }); + 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 || ''} -> ${want}`, async function () { + assert.equal(sanitizePathname(p, path[platform]), want); + }); + } + }); + it('default path API', async function () { + assert.equal(sanitizePathname('foo'), 'foo'); + }); }); diff --git a/src/tests/backend/specs/settings.js b/src/tests/backend/specs/settings.js index e737f4f34..6de6fae41 100644 --- a/src/tests/backend/specs/settings.js +++ b/src/tests/backend/specs/settings.js @@ -1,61 +1,60 @@ +import assert$0 from "assert"; +import { exportedForTestingOnly } from "../../../node/utils/Settings.js"; +import path from "path"; +import process from "process"; 'use strict'; - -const assert = require('assert').strict; -const {parseSettings} = require('../../../node/utils/Settings').exportedForTestingOnly; -const path = require('path'); -const process = require('process'); - +const assert = assert$0.strict; +const { parseSettings } = { exportedForTestingOnly }.exportedForTestingOnly; 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); + 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); + } + }); + } + }); }); - - 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); - } - }); - } - }); }); - }); }); diff --git a/src/tests/backend/specs/socketio.js b/src/tests/backend/specs/socketio.js index 15f561774..7f95d648a 100644 --- a/src/tests/backend/specs/socketio.js +++ b/src/tests/backend/specs/socketio.js @@ -1,426 +1,405 @@ +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as padManager from "../../../node/db/PadManager.js"; +import * as plugins from "../../../static/js/pluginfw/plugin_defs.js"; +import * as readOnlyManager from "../../../node/db/ReadOnlyManager.js"; +import * as settings from "../../../node/utils/Settings.js"; +import * as socketIoRouter from "../../../node/handler/SocketIORouter.js"; 'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const padManager = require('../../../node/db/PadManager'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const readOnlyManager = require('../../../node/db/ReadOnlyManager'); -const settings = require('../../../node/utils/Settings'); -const socketIoRouter = require('../../../node/handler/SocketIORouter'); - +const assert = assert$0.strict; describe(__filename, function () { - this.timeout(30000); - let agent; - let authorize; - const backups = {}; - const cleanUpPads = async () => { - const padIds = ['pad', 'other-pad', 'päd']; - await Promise.all(padIds.map(async (padId) => { - if (await padManager.doesPadExist(padId)) { - const pad = await padManager.getPad(padId); - await pad.remove(); - } - })); - }; - let socket; - - before(async function () { agent = await common.init(); }); - beforeEach(async function () { - backups.hooks = {}; - for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) { - backups.hooks[hookName] = plugins.hooks[hookName]; - plugins.hooks[hookName] = []; - } - backups.settings = {}; - for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) { - backups.settings[setting] = settings[setting]; - } - settings.editOnly = false; - settings.requireAuthentication = false; - settings.requireAuthorization = false; - settings.users = { - admin: {password: 'admin-password', is_admin: true}, - user: {password: 'user-password'}, + this.timeout(30000); + let agent; + let authorize; + const backups = {}; + const cleanUpPads = async () => { + const padIds = ['pad', 'other-pad', 'päd']; + await Promise.all(padIds.map(async (padId) => { + if (await padManager.doesPadExist(padId)) { + const pad = await padManager.getPad(padId); + await pad.remove(); + } + })); }; - assert(socket == null); - authorize = () => true; - plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}]; - await cleanUpPads(); - }); - afterEach(async function () { - if (socket) socket.close(); - socket = null; - await cleanUpPads(); - Object.assign(plugins.hooks, backups.hooks); - Object.assign(settings, backups.settings); - }); - - describe('Normal accesses', function () { - it('!authn anonymous cookie /p/pad -> 200, ok', async function () { - const res = await agent.get('/p/pad').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('!authn !cookie -> ok', async function () { - socket = await common.connect(null); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('!authn user /p/pad -> 200, ok', async function () { - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - it('authn user /p/pad -> 200, ok', async function () { - settings.requireAuthentication = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - - for (const authn of [false, true]) { - const desc = authn ? 'authn user' : '!authn anonymous'; - it(`${desc} read-only /p/pad -> 200, ok`, async function () { - const get = (ep) => { - let res = agent.get(ep); - if (authn) res = res.auth('user', 'user-password'); - return res.expect(200); + let socket; + before(async function () { agent = await common.init(); }); + beforeEach(async function () { + backups.hooks = {}; + for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) { + backups.hooks[hookName] = plugins.hooks[hookName]; + plugins.hooks[hookName] = []; + } + backups.settings = {}; + for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) { + backups.settings[setting] = settings[setting]; + } + settings.editOnly = false; + settings.requireAuthentication = false; + settings.requireAuthorization = false; + settings.users = { + admin: { password: 'admin-password', is_admin: true }, + user: { password: 'user-password' }, }; - settings.requireAuthentication = authn; - let res = await get('/p/pad'); - socket = await common.connect(res); - let clientVars = await common.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 common.connect(res); - clientVars = await common.handshake(socket, readOnlyId); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - } - - it('authz user /p/pad -> 200, ok', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); + assert(socket == null); + authorize = () => true; + plugins.hooks.authorize = [{ hook_fn: (hookName, { req }, cb) => cb([authorize(req)]) }]; + await cleanUpPads(); }); - it('supports pad names with characters that must be percent-encoded', async function () { - settings.requireAuthentication = true; - // requireAuthorization is set to true here to guarantee that the user's padAuthorizations - // object is populated. Technically this isn't necessary because the user's padAuthorizations - // is currently populated even if requireAuthorization is false, but setting this to true - // ensures the test remains useful if the implementation ever changes. - settings.requireAuthorization = true; - const encodedPadId = encodeURIComponent('päd'); - const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'päd'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - }); - }); - - describe('Abnormal access attempts', function () { - it('authn anonymous /p/pad -> 401, error', async function () { - settings.requireAuthentication = true; - const res = await agent.get('/p/pad').expect(401); - // Despite the 401, try to create the pad via a socket.io connection anyway. - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - - it('authn anonymous read-only /p/pad -> 401, error', async function () { - settings.requireAuthentication = true; - let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.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 common.connect(res); - const message = await common.handshake(socket, readOnlyId); - assert.equal(message.accessStatus, 'deny'); - }); - - it('authn !cookie -> error', async function () { - settings.requireAuthentication = true; - socket = await common.connect(null); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('authorization bypass attempt -> error', async function () { - // Only allowed to access /p/pad. - authorize = (req) => req.path === '/p/pad'; - settings.requireAuthentication = true; - settings.requireAuthorization = true; - // First authenticate and establish a session. - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. - const message = await common.handshake(socket, 'other-pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); - - describe('Authorization levels via authorize hook', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); - - it("level='create' -> can create", async function () { - authorize = () => 'create'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('level=true -> can create', async function () { - authorize = () => true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it("level='modify' -> can modify", async function () { - await padManager.getPad('pad'); // Create the pad. - authorize = () => 'modify'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it("level='create' settings.editOnly=true -> unable to create", async function () { - authorize = () => 'create'; - settings.editOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='modify' settings.editOnly=false -> unable to create", async function () { - authorize = () => 'modify'; - settings.editOnly = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='readOnly' -> unable to create", async function () { - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it("level='readOnly' -> unable to modify", async function () { - await padManager.getPad('pad'); // Create the pad. - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - }); - - describe('Authorization levels via user settings', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - }); - - it('user.canCreate = true -> can create and modify', async function () { - settings.users.user.canCreate = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('user.canCreate = false -> unable to create', async function () { - settings.users.user.canCreate = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user.readOnly = true -> unable to create', async function () { - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user.readOnly = true -> unable to modify', async function () { - await padManager.getPad('pad'); // Create the pad. - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, true); - }); - it('user.readOnly = false -> can create and modify', async function () { - settings.users.user.readOnly = false; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const clientVars = await common.handshake(socket, 'pad'); - assert.equal(clientVars.type, 'CLIENT_VARS'); - assert.equal(clientVars.data.readonly, false); - }); - it('user.readOnly = true, user.canCreate = true -> unable to create', async function () { - settings.users.user.canCreate = true; - settings.users.user.readOnly = true; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); - - describe('Authorization level interaction between authorize hook and user settings', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); - - it('authorize hook does not elevate level from user settings', async function () { - settings.users.user.readOnly = true; - authorize = () => 'create'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - it('user settings does not elevate level from authorize hook', async function () { - settings.users.user.readOnly = false; - settings.users.user.canCreate = true; - authorize = () => 'readOnly'; - const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); - socket = await common.connect(res); - const message = await common.handshake(socket, 'pad'); - assert.equal(message.accessStatus, 'deny'); - }); - }); - - describe('SocketIORouter.js', function () { - const Module = class { - setSocketIO(io) {} - handleConnect(socket) {} - handleDisconnect(socket) {} - handleMessage(socket, message) {} - }; - afterEach(async function () { - socketIoRouter.deleteComponent(this.test.fullTitle()); - socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`); + if (socket) + socket.close(); + socket = null; + await cleanUpPads(); + Object.assign(plugins.hooks, backups.hooks); + Object.assign(settings, backups.settings); }); - - it('setSocketIO', async function () { - let ioServer; - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - setSocketIO(io) { ioServer = io; } - }()); - assert(ioServer != null); - }); - - it('handleConnect', async function () { - let serverSocket; - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleConnect(socket) { serverSocket = socket; } - }()); - socket = await common.connect(); - assert(serverSocket != null); - }); - - it('handleDisconnect', async function () { - let resolveConnected; - const connected = new Promise((resolve) => resolveConnected = resolve); - let resolveDisconnected; - const disconnected = new Promise((resolve) => resolveDisconnected = resolve); - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleConnect(socket) { - this._socket = socket; - resolveConnected(); + describe('Normal accesses', function () { + it('!authn anonymous cookie /p/pad -> 200, ok', async function () { + const res = await agent.get('/p/pad').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); + it('!authn !cookie -> ok', async function () { + socket = await common.connect(null); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); + it('!authn user /p/pad -> 200, ok', async function () { + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); + it('authn user /p/pad -> 200, ok', async function () { + settings.requireAuthentication = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); + for (const authn of [false, true]) { + const desc = authn ? 'authn user' : '!authn anonymous'; + it(`${desc} read-only /p/pad -> 200, ok`, async function () { + 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 common.connect(res); + let clientVars = await common.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 common.connect(res); + clientVars = await common.handshake(socket, readOnlyId); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, true); + }); } - handleDisconnect(socket) { - assert(socket != null); - // There might be lingering disconnect events from sockets created by other tests. - if (this._socket == null || socket.id !== this._socket.id) return; - assert.equal(socket, this._socket); - resolveDisconnected(); - } - }()); - socket = await common.connect(); - await connected; - socket.close(); - socket = null; - await disconnected; + it('authz user /p/pad -> 200, ok', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); + it('supports pad names with characters that must be percent-encoded', async function () { + settings.requireAuthentication = true; + // requireAuthorization is set to true here to guarantee that the user's padAuthorizations + // object is populated. Technically this isn't necessary because the user's padAuthorizations + // is currently populated even if requireAuthorization is false, but setting this to true + // ensures the test remains useful if the implementation ever changes. + settings.requireAuthorization = true; + const encodedPadId = encodeURIComponent('päd'); + const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'päd'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + }); }); - - it('handleMessage (success)', async function () { - let serverSocket; - const want = { - component: this.test.fullTitle(), - foo: {bar: 'asdf'}, - }; - let rx; - const got = new Promise((resolve) => { rx = resolve; }); - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleConnect(socket) { serverSocket = socket; } - handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); } - }()); - socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module { - handleMessage(socket, message) { assert.fail('wrong handler called'); } - }()); - socket = await common.connect(); - socket.send(want); - assert.deepEqual(await got, want); + describe('Abnormal access attempts', function () { + it('authn anonymous /p/pad -> 401, error', async function () { + settings.requireAuthentication = true; + const res = await agent.get('/p/pad').expect(401); + // Despite the 401, try to create the pad via a socket.io connection anyway. + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it('authn anonymous read-only /p/pad -> 401, error', async function () { + settings.requireAuthentication = true; + let res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.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 common.connect(res); + const message = await common.handshake(socket, readOnlyId); + assert.equal(message.accessStatus, 'deny'); + }); + it('authn !cookie -> error', async function () { + settings.requireAuthentication = true; + socket = await common.connect(null); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it('authorization bypass attempt -> error', async function () { + // Only allowed to access /p/pad. + authorize = (req) => req.path === '/p/pad'; + settings.requireAuthentication = true; + settings.requireAuthorization = true; + // First authenticate and establish a session. + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + // Accessing /p/other-pad should fail, despite the successful fetch of /p/pad. + const message = await common.handshake(socket, 'other-pad'); + assert.equal(message.accessStatus, 'deny'); + }); }); - - const tx = async (socket, message = {}) => await new Promise((resolve, reject) => { - const AckErr = class extends Error { - constructor(name, ...args) { super(...args); this.name = name; } - }; - socket.send(message, - (errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); + describe('Authorization levels via authorize hook', function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); + it("level='create' -> can create", async function () { + authorize = () => 'create'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + }); + it('level=true -> can create', async function () { + authorize = () => true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + }); + it("level='modify' -> can modify", async function () { + await padManager.getPad('pad'); // Create the pad. + authorize = () => 'modify'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + }); + it("level='create' settings.editOnly=true -> unable to create", async function () { + authorize = () => 'create'; + settings.editOnly = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it("level='modify' settings.editOnly=false -> unable to create", async function () { + authorize = () => 'modify'; + settings.editOnly = false; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it("level='readOnly' -> unable to create", async function () { + authorize = () => 'readOnly'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it("level='readOnly' -> unable to modify", async function () { + await padManager.getPad('pad'); // Create the pad. + authorize = () => 'readOnly'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, true); + }); }); - - it('handleMessage with ack (success)', async function () { - const want = 'value'; - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleMessage(socket, msg) { return want; } - }()); - socket = await common.connect(); - const got = await tx(socket, {component: this.test.fullTitle()}); - assert.equal(got, want); + describe('Authorization levels via user settings', function () { + beforeEach(async function () { + settings.requireAuthentication = true; + }); + it('user.canCreate = true -> can create and modify', async function () { + settings.users.user.canCreate = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + }); + it('user.canCreate = false -> unable to create', async function () { + settings.users.user.canCreate = false; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it('user.readOnly = true -> unable to create', async function () { + settings.users.user.readOnly = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it('user.readOnly = true -> unable to modify', async function () { + await padManager.getPad('pad'); // Create the pad. + settings.users.user.readOnly = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, true); + }); + it('user.readOnly = false -> can create and modify', async function () { + settings.users.user.readOnly = false; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const clientVars = await common.handshake(socket, 'pad'); + assert.equal(clientVars.type, 'CLIENT_VARS'); + assert.equal(clientVars.data.readonly, false); + }); + it('user.readOnly = true, user.canCreate = true -> unable to create', async function () { + settings.users.user.canCreate = true; + settings.users.user.readOnly = true; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); }); - - it('handleMessage with ack (error)', async function () { - const InjectedError = class extends Error { - constructor() { super('injected test error'); this.name = 'InjectedError'; } - }; - socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { - handleMessage(socket, msg) { throw new InjectedError(); } - }()); - socket = await common.connect(); - await assert.rejects(tx(socket, {component: this.test.fullTitle()}), new InjectedError()); + describe('Authorization level interaction between authorize hook and user settings', function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); + it('authorize hook does not elevate level from user settings', async function () { + settings.users.user.readOnly = true; + authorize = () => 'create'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + it('user settings does not elevate level from authorize hook', async function () { + settings.users.user.readOnly = false; + settings.users.user.canCreate = true; + authorize = () => 'readOnly'; + const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200); + socket = await common.connect(res); + const message = await common.handshake(socket, 'pad'); + assert.equal(message.accessStatus, 'deny'); + }); + }); + describe('SocketIORouter.js', function () { + const Module = class { + setSocketIO(io) { } + handleConnect(socket) { } + handleDisconnect(socket) { } + handleMessage(socket, message) { } + }; + afterEach(async function () { + socketIoRouter.deleteComponent(this.test.fullTitle()); + socketIoRouter.deleteComponent(`${this.test.fullTitle()} #2`); + }); + it('setSocketIO', async function () { + let ioServer; + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + setSocketIO(io) { ioServer = io; } + }()); + assert(ioServer != null); + }); + it('handleConnect', async function () { + let serverSocket; + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + handleConnect(socket) { serverSocket = socket; } + }()); + socket = await common.connect(); + assert(serverSocket != null); + }); + it('handleDisconnect', async function () { + let resolveConnected; + const connected = new Promise((resolve) => resolveConnected = resolve); + let resolveDisconnected; + const disconnected = new Promise((resolve) => resolveDisconnected = resolve); + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + handleConnect(socket) { + this._socket = socket; + resolveConnected(); + } + handleDisconnect(socket) { + assert(socket != null); + // There might be lingering disconnect events from sockets created by other tests. + if (this._socket == null || socket.id !== this._socket.id) + return; + assert.equal(socket, this._socket); + resolveDisconnected(); + } + }()); + socket = await common.connect(); + await connected; + socket.close(); + socket = null; + await disconnected; + }); + it('handleMessage (success)', async function () { + let serverSocket; + const want = { + component: this.test.fullTitle(), + foo: { bar: 'asdf' }, + }; + let rx; + const got = new Promise((resolve) => { rx = resolve; }); + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + handleConnect(socket) { serverSocket = socket; } + handleMessage(socket, message) { assert.equal(socket, serverSocket); rx(message); } + }()); + socketIoRouter.addComponent(`${this.test.fullTitle()} #2`, new class extends Module { + handleMessage(socket, message) { assert.fail('wrong handler called'); } + }()); + socket = await common.connect(); + socket.send(want); + assert.deepEqual(await got, want); + }); + const tx = async (socket, message = {}) => await new Promise((resolve, reject) => { + const AckErr = class extends Error { + constructor(name, ...args) { super(...args); this.name = name; } + }; + socket.send(message, (errj, val) => errj != null ? reject(new AckErr(errj.name, errj.message)) : resolve(val)); + }); + it('handleMessage with ack (success)', async function () { + const want = 'value'; + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + handleMessage(socket, msg) { return want; } + }()); + socket = await common.connect(); + const got = await tx(socket, { component: this.test.fullTitle() }); + assert.equal(got, want); + }); + it('handleMessage with ack (error)', async function () { + const InjectedError = class extends Error { + constructor() { super('injected test error'); this.name = 'InjectedError'; } + }; + socketIoRouter.addComponent(this.test.fullTitle(), new class extends Module { + handleMessage(socket, msg) { throw new InjectedError(); } + }()); + socket = await common.connect(); + await assert.rejects(tx(socket, { component: this.test.fullTitle() }), new InjectedError()); + }); }); - }); }); diff --git a/src/tests/backend/specs/specialpages.js b/src/tests/backend/specs/specialpages.js index 93c8b3bc4..8be1b00fb 100644 --- a/src/tests/backend/specs/specialpages.js +++ b/src/tests/backend/specs/specialpages.js @@ -1,28 +1,25 @@ +import * as common from "../common.js"; +import * as settings from "../../../node/utils/Settings.js"; 'use strict'; - -const common = require('../common'); -const settings = require('../../../node/utils/Settings'); - describe(__filename, function () { - this.timeout(30000); - let agent; - const backups = {}; - before(async function () { agent = await common.init(); }); - beforeEach(async function () { - backups.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization']) { - backups.settings[setting] = settings[setting]; - } - settings.requireAuthentication = false; - settings.requireAuthorization = false; - }); - afterEach(async function () { - Object.assign(settings, backups.settings); - }); - - describe('/javascript', function () { - it('/javascript -> 200', async function () { - await agent.get('/javascript').expect(200); + this.timeout(30000); + let agent; + const backups = {}; + before(async function () { agent = await common.init(); }); + beforeEach(async function () { + backups.settings = {}; + for (const setting of ['requireAuthentication', 'requireAuthorization']) { + backups.settings[setting] = settings[setting]; + } + settings.requireAuthentication = false; + settings.requireAuthorization = false; + }); + afterEach(async function () { + Object.assign(settings, backups.settings); + }); + describe('/javascript', function () { + it('/javascript -> 200', async function () { + await agent.get('/javascript').expect(200); + }); }); - }); }); diff --git a/src/tests/backend/specs/webaccess.js b/src/tests/backend/specs/webaccess.js index 23cd2d889..ced776ca4 100644 --- a/src/tests/backend/specs/webaccess.js +++ b/src/tests/backend/specs/webaccess.js @@ -1,494 +1,478 @@ +import assert$0 from "assert"; +import * as common from "../common.js"; +import * as plugins from "../../../static/js/pluginfw/plugin_defs.js"; +import * as settings from "../../../node/utils/Settings.js"; 'use strict'; - -const assert = require('assert').strict; -const common = require('../common'); -const plugins = require('../../../static/js/pluginfw/plugin_defs'); -const settings = require('../../../node/utils/Settings'); - +const assert = assert$0.strict; describe(__filename, function () { - this.timeout(30000); - let agent; - const backups = {}; - const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; - const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; - const makeHook = (hookName, hookFn) => ({ - hook_fn: hookFn, - hook_fn_name: `fake_plugin/${hookName}`, - hook_name: hookName, - part: {plugin: 'fake_plugin'}, - }); - - before(async function () { agent = await common.init(); }); - beforeEach(async function () { - backups.hooks = {}; - for (const hookName of authHookNames.concat(failHookNames)) { - backups.hooks[hookName] = plugins.hooks[hookName]; - plugins.hooks[hookName] = []; - } - backups.settings = {}; - for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) { - backups.settings[setting] = settings[setting]; - } - settings.requireAuthentication = false; - settings.requireAuthorization = false; - settings.users = { - admin: {password: 'admin-password', is_admin: true}, - user: {password: 'user-password'}, - }; - }); - afterEach(async function () { - Object.assign(plugins.hooks, backups.hooks); - Object.assign(settings, backups.settings); - }); - - describe('webaccess: without plugins', function () { - it('!authn !authz anonymous / -> 200', async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - await agent.get('/').expect(200); + this.timeout(30000); + let agent; + const backups = {}; + const authHookNames = ['preAuthorize', 'authenticate', 'authorize']; + const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure']; + const makeHook = (hookName, hookFn) => ({ + hook_fn: hookFn, + hook_fn_name: `fake_plugin/${hookName}`, + hook_name: hookName, + part: { plugin: 'fake_plugin' }, }); - it('!authn !authz anonymous /admin/ -> 401', async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - await agent.get('/admin/').expect(401); - }); - it('authn !authz anonymous / -> 401', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').expect(401); - }); - it('authn !authz user / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').auth('user', 'user-password').expect(200); - }); - it('authn !authz user /admin/ -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/admin/').auth('user', 'user-password').expect(403); - }); - it('authn !authz admin / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/').auth('admin', 'admin-password').expect(200); - }); - it('authn !authz admin /admin/ -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); - }); - it('authn authz anonymous /robots.txt -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/robots.txt').expect(200); - }); - it('authn authz user / -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/').auth('user', 'user-password').expect(403); - }); - it('authn authz user /admin/ -> 403', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/admin/').auth('user', 'user-password').expect(403); - }); - it('authn authz admin / -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/').auth('admin', 'admin-password').expect(200); - }); - it('authn authz admin /admin/ -> 200', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); - }); - - describe('login fails if password is nullish', function () { - for (const adminPassword of [undefined, null]) { - // https://tools.ietf.org/html/rfc7617 says that the username and password are sent as - // base64(username + ':' + password), but there's nothing stopping a malicious user from - // sending just base64(username) (no colon). The lack of colon could throw off credential - // parsing, resulting in successful comparisons against a null or undefined password. - for (const creds of ['admin', 'admin:']) { - it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { - settings.users.admin.password = adminPassword; - const encCreds = Buffer.from(creds).toString('base64'); - await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401); - }); - } - } - }); - }); - - describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () { - let callOrder; - const Handler = class { - constructor(hookName, suffix) { - this.called = false; - this.hookName = hookName; - this.innerHandle = () => []; - this.id = hookName + suffix; - this.checkContext = () => {}; - } - handle(hookName, context, cb) { - assert.equal(hookName, this.hookName); - assert(context != null); - assert(context.req != null); - assert(context.res != null); - assert(context.next != null); - this.checkContext(context); - assert(!this.called); - this.called = true; - callOrder.push(this.id); - return cb(this.innerHandle(context)); - } - }; - const handlers = {}; - + before(async function () { agent = await common.init(); }); beforeEach(async function () { - callOrder = []; - for (const hookName of authHookNames) { - // Create two handlers for each hook to test deferral to the next function. - const h0 = new Handler(hookName, '_0'); - const h1 = new Handler(hookName, '_1'); - handlers[hookName] = [h0, h1]; - plugins.hooks[hookName] = [ - makeHook(hookName, h0.handle.bind(h0)), - makeHook(hookName, h1.handle.bind(h1)), - ]; - } - }); - - describe('preAuthorize', function () { - beforeEach(async function () { - settings.requireAuthentication = false; - settings.requireAuthorization = false; - }); - - it('defers if it returns []', async function () { - await agent.get('/').expect(200); - // Note: The preAuthorize hook always runs even if requireAuthorization is false. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); - }); - it('bypasses authenticate and authorize hooks when true is returned', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); - it('bypasses authenticate and authorize hooks when false is returned', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); - it('bypasses authenticate and authorize hooks when next is called', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - handlers.preAuthorize[0].innerHandle = ({next}) => next(); - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); - it('static content (expressPreSession) bypasses all auth checks', async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - await agent.get('/static/robots.txt').expect(200); - assert.deepEqual(callOrder, []); - }); - it('cannot grant access to /admin', async function () { - handlers.preAuthorize[0].innerHandle = () => [true]; - await agent.get('/admin/').expect(401); - // Notes: - // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because - // 'true' entries are ignored for /admin/* requests. - // * The authenticate hook always runs for /admin/* requests even if - // settings.requireAuthentication is false. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('can deny access to /admin', async function () { - handlers.preAuthorize[0].innerHandle = () => [false]; - await agent.get('/admin/').auth('admin', 'admin-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0']); - }); - it('runs preAuthzFailure hook when access is denied', async function () { - handlers.preAuthorize[0].innerHandle = () => [false]; - let called = false; - plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, {req, res}, cb) => { - assert.equal(hookName, 'preAuthzFailure'); - assert(req != null); - assert(res != null); - assert(!called); - called = true; - res.status(200).send('injected'); - return cb([true]); - })]; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); - assert(called); - }); - it('returns 500 if an exception is thrown', async function () { - handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').expect(500); - }); - }); - - describe('authenticate', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = false; - }); - - it('is not called if !requireAuthentication and not /admin/*', async function () { - settings.requireAuthentication = false; - await agent.get('/').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); - }); - it('is called if !requireAuthentication and /admin/*', async function () { - settings.requireAuthentication = false; - await agent.get('/admin/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('defers if empty list returned', async function () { - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('does not defer if return [true], 200', async function () { - handlers.authenticate[0].innerHandle = ({req}) => { req.session.user = {}; return [true]; }; - await agent.get('/').expect(200); - // Note: authenticate_1 was not called because authenticate_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); - it('does not defer if return [false], 401', async function () { - handlers.authenticate[0].innerHandle = () => [false]; - await agent.get('/').expect(401); - // Note: authenticate_1 was not called because authenticate_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); - it('falls back to HTTP basic auth', async function () { - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('passes settings.users in context', async function () { - handlers.authenticate[0].checkContext = ({users}) => { - assert.equal(users, settings.users); - }; - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('passes user, password in context if provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}) => { - assert.equal(username, 'user'); - assert.equal(password, 'user-password'); - }; - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('does not pass user, password in context if not provided', async function () { - handlers.authenticate[0].checkContext = ({username, password}) => { - assert(username == null); - assert(password == null); - }; - await agent.get('/').expect(401); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('errors if req.session.user is not created', async function () { - handlers.authenticate[0].innerHandle = () => [true]; - await agent.get('/').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); - it('returns 500 if an exception is thrown', async function () { - handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); - }); - }); - - describe('authorize', function () { - beforeEach(async function () { - settings.requireAuthentication = true; - settings.requireAuthorization = true; - }); - - it('is not called if !requireAuthorization (non-/admin)', async function () { - settings.requireAuthorization = false; - await agent.get('/').auth('user', 'user-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('is not called if !requireAuthorization (/admin)', async function () { - settings.requireAuthorization = false; - await agent.get('/admin/').auth('admin', 'admin-password').expect(200); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1']); - }); - it('defers if empty list returned', async function () { - await agent.get('/').auth('user', 'user-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0', - 'authorize_1']); - }); - it('does not defer if return [true], 200', async function () { - handlers.authorize[0].innerHandle = () => [true]; - await agent.get('/').auth('user', 'user-password').expect(200); - // Note: authorize_1 was not called because authorize_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); - it('does not defer if return [false], 403', async function () { - handlers.authorize[0].innerHandle = () => [false]; - await agent.get('/').auth('user', 'user-password').expect(403); - // Note: authorize_1 was not called because authorize_0 handled it. - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); - it('passes req.path in context', async function () { - handlers.authorize[0].checkContext = ({resource}) => { - assert.equal(resource, '/'); - }; - await agent.get('/').auth('user', 'user-password').expect(403); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0', - 'authorize_1']); - }); - it('returns 500 if an exception is thrown', async function () { - handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; - await agent.get('/').auth('user', 'user-password').expect(500); - assert.deepEqual(callOrder, ['preAuthorize_0', - 'preAuthorize_1', - 'authenticate_0', - 'authenticate_1', - 'authorize_0']); - }); - }); - }); - - describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () { - const Handler = class { - constructor(hookName) { - this.hookName = hookName; - this.shouldHandle = false; - this.called = false; - } - handle(hookName, context, cb) { - assert.equal(hookName, this.hookName); - assert(context != null); - assert(context.req != null); - assert(context.res != null); - assert(!this.called); - this.called = true; - if (this.shouldHandle) { - context.res.status(200).send(this.hookName); - return cb([true]); + backups.hooks = {}; + for (const hookName of authHookNames.concat(failHookNames)) { + backups.hooks[hookName] = plugins.hooks[hookName]; + plugins.hooks[hookName] = []; } - return cb([]); - } - }; - const handlers = {}; - - beforeEach(async function () { - failHookNames.forEach((hookName) => { - const handler = new Handler(hookName); - handlers[hookName] = handler; - plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))]; - }); - settings.requireAuthentication = true; - settings.requireAuthorization = true; + backups.settings = {}; + for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) { + backups.settings[setting] = settings[setting]; + } + settings.requireAuthentication = false; + settings.requireAuthorization = false; + settings.users = { + admin: { password: 'admin-password', is_admin: true }, + user: { password: 'user-password' }, + }; }); - - // authn failure tests - it('authn fail, no hooks handle -> 401', async function () { - await agent.get('/').expect(401); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(handlers.authFailure.called); + afterEach(async function () { + Object.assign(plugins.hooks, backups.hooks); + Object.assign(settings, backups.settings); }); - it('authn fail, authnFailure handles', async function () { - handlers.authnFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authnFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(!handlers.authFailure.called); + describe('webaccess: without plugins', function () { + it('!authn !authz anonymous / -> 200', async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + await agent.get('/').expect(200); + }); + it('!authn !authz anonymous /admin/ -> 401', async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + await agent.get('/admin/').expect(401); + }); + it('authn !authz anonymous / -> 401', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get('/').expect(401); + }); + it('authn !authz user / -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get('/').auth('user', 'user-password').expect(200); + }); + it('authn !authz user /admin/ -> 403', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get('/admin/').auth('user', 'user-password').expect(403); + }); + it('authn !authz admin / -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get('/').auth('admin', 'admin-password').expect(200); + }); + it('authn !authz admin /admin/ -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + }); + it('authn authz anonymous /robots.txt -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/robots.txt').expect(200); + }); + it('authn authz user / -> 403', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/').auth('user', 'user-password').expect(403); + }); + it('authn authz user /admin/ -> 403', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/admin/').auth('user', 'user-password').expect(403); + }); + it('authn authz admin / -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/').auth('admin', 'admin-password').expect(200); + }); + it('authn authz admin /admin/ -> 200', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + }); + describe('login fails if password is nullish', function () { + for (const adminPassword of [undefined, null]) { + // https://tools.ietf.org/html/rfc7617 says that the username and password are sent as + // base64(username + ':' + password), but there's nothing stopping a malicious user from + // sending just base64(username) (no colon). The lack of colon could throw off credential + // parsing, resulting in successful comparisons against a null or undefined password. + for (const creds of ['admin', 'admin:']) { + it(`admin password: ${adminPassword} credentials: ${creds}`, async function () { + settings.users.admin.password = adminPassword; + const encCreds = Buffer.from(creds).toString('base64'); + await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401); + }); + } + } + }); }); - it('authn fail, authFailure handles', async function () { - handlers.authFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authzFailure.called); - assert(handlers.authFailure.called); + describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () { + let callOrder; + const Handler = class { + constructor(hookName, suffix) { + this.called = false; + this.hookName = hookName; + this.innerHandle = () => []; + this.id = hookName + suffix; + this.checkContext = () => { }; + } + handle(hookName, context, cb) { + assert.equal(hookName, this.hookName); + assert(context != null); + assert(context.req != null); + assert(context.res != null); + assert(context.next != null); + this.checkContext(context); + assert(!this.called); + this.called = true; + callOrder.push(this.id); + return cb(this.innerHandle(context)); + } + }; + const handlers = {}; + beforeEach(async function () { + callOrder = []; + for (const hookName of authHookNames) { + // Create two handlers for each hook to test deferral to the next function. + const h0 = new Handler(hookName, '_0'); + const h1 = new Handler(hookName, '_1'); + handlers[hookName] = [h0, h1]; + plugins.hooks[hookName] = [ + makeHook(hookName, h0.handle.bind(h0)), + makeHook(hookName, h1.handle.bind(h1)), + ]; + } + }); + describe('preAuthorize', function () { + beforeEach(async function () { + settings.requireAuthentication = false; + settings.requireAuthorization = false; + }); + it('defers if it returns []', async function () { + await agent.get('/').expect(200); + // Note: The preAuthorize hook always runs even if requireAuthorization is false. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); + }); + it('bypasses authenticate and authorize hooks when true is returned', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get('/').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('bypasses authenticate and authorize hooks when false is returned', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent.get('/').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('bypasses authenticate and authorize hooks when next is called', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + handlers.preAuthorize[0].innerHandle = ({ next }) => next(); + await agent.get('/').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('static content (expressPreSession) bypasses all auth checks', async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + await agent.get('/static/robots.txt').expect(200); + assert.deepEqual(callOrder, []); + }); + it('cannot grant access to /admin', async function () { + handlers.preAuthorize[0].innerHandle = () => [true]; + await agent.get('/admin/').expect(401); + // Notes: + // * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because + // 'true' entries are ignored for /admin/* requests. + // * The authenticate hook always runs for /admin/* requests even if + // settings.requireAuthentication is false. + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('can deny access to /admin', async function () { + handlers.preAuthorize[0].innerHandle = () => [false]; + await agent.get('/admin/').auth('admin', 'admin-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0']); + }); + it('runs preAuthzFailure hook when access is denied', async function () { + handlers.preAuthorize[0].innerHandle = () => [false]; + let called = false; + plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, { req, res }, cb) => { + assert.equal(hookName, 'preAuthzFailure'); + assert(req != null); + assert(res != null); + assert(!called); + called = true; + res.status(200).send('injected'); + return cb([true]); + })]; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected'); + assert(called); + }); + it('returns 500 if an exception is thrown', async function () { + handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').expect(500); + }); + }); + describe('authenticate', function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = false; + }); + it('is not called if !requireAuthentication and not /admin/*', async function () { + settings.requireAuthentication = false; + await agent.get('/').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']); + }); + it('is called if !requireAuthentication and /admin/*', async function () { + settings.requireAuthentication = false; + await agent.get('/admin/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('defers if empty list returned', async function () { + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('does not defer if return [true], 200', async function () { + handlers.authenticate[0].innerHandle = ({ req }) => { req.session.user = {}; return [true]; }; + await agent.get('/').expect(200); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('does not defer if return [false], 401', async function () { + handlers.authenticate[0].innerHandle = () => [false]; + await agent.get('/').expect(401); + // Note: authenticate_1 was not called because authenticate_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('falls back to HTTP basic auth', async function () { + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('passes settings.users in context', async function () { + handlers.authenticate[0].checkContext = ({ users }) => { + assert.equal(users, settings.users); + }; + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('passes user, password in context if provided', async function () { + handlers.authenticate[0].checkContext = ({ username, password }) => { + assert.equal(username, 'user'); + assert.equal(password, 'user-password'); + }; + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('does not pass user, password in context if not provided', async function () { + handlers.authenticate[0].checkContext = ({ username, password }) => { + assert(username == null); + assert(password == null); + }; + await agent.get('/').expect(401); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('errors if req.session.user is not created', async function () { + handlers.authenticate[0].innerHandle = () => [true]; + await agent.get('/').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + it('returns 500 if an exception is thrown', async function () { + handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']); + }); + }); + describe('authorize', function () { + beforeEach(async function () { + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); + it('is not called if !requireAuthorization (non-/admin)', async function () { + settings.requireAuthorization = false; + await agent.get('/').auth('user', 'user-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('is not called if !requireAuthorization (/admin)', async function () { + settings.requireAuthorization = false; + await agent.get('/admin/').auth('admin', 'admin-password').expect(200); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1']); + }); + it('defers if empty list returned', async function () { + await agent.get('/').auth('user', 'user-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1', + 'authorize_0', + 'authorize_1']); + }); + it('does not defer if return [true], 200', async function () { + handlers.authorize[0].innerHandle = () => [true]; + await agent.get('/').auth('user', 'user-password').expect(200); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1', + 'authorize_0']); + }); + it('does not defer if return [false], 403', async function () { + handlers.authorize[0].innerHandle = () => [false]; + await agent.get('/').auth('user', 'user-password').expect(403); + // Note: authorize_1 was not called because authorize_0 handled it. + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1', + 'authorize_0']); + }); + it('passes req.path in context', async function () { + handlers.authorize[0].checkContext = ({ resource }) => { + assert.equal(resource, '/'); + }; + await agent.get('/').auth('user', 'user-password').expect(403); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1', + 'authorize_0', + 'authorize_1']); + }); + it('returns 500 if an exception is thrown', async function () { + handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); }; + await agent.get('/').auth('user', 'user-password').expect(500); + assert.deepEqual(callOrder, ['preAuthorize_0', + 'preAuthorize_1', + 'authenticate_0', + 'authenticate_1', + 'authorize_0']); + }); + }); }); - it('authnFailure trumps authFailure', async function () { - handlers.authnFailure.shouldHandle = true; - handlers.authFailure.shouldHandle = true; - await agent.get('/').expect(200, 'authnFailure'); - assert(handlers.authnFailure.called); - assert(!handlers.authFailure.called); + describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () { + const Handler = class { + constructor(hookName) { + this.hookName = hookName; + this.shouldHandle = false; + this.called = false; + } + handle(hookName, context, cb) { + assert.equal(hookName, this.hookName); + assert(context != null); + assert(context.req != null); + assert(context.res != null); + assert(!this.called); + this.called = true; + if (this.shouldHandle) { + context.res.status(200).send(this.hookName); + return cb([true]); + } + return cb([]); + } + }; + const handlers = {}; + beforeEach(async function () { + failHookNames.forEach((hookName) => { + const handler = new Handler(hookName); + handlers[hookName] = handler; + plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))]; + }); + settings.requireAuthentication = true; + settings.requireAuthorization = true; + }); + // authn failure tests + it('authn fail, no hooks handle -> 401', async function () { + await agent.get('/').expect(401); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); + it('authn fail, authnFailure handles', async function () { + handlers.authnFailure.shouldHandle = true; + await agent.get('/').expect(200, 'authnFailure'); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); + it('authn fail, authFailure handles', async function () { + handlers.authFailure.shouldHandle = true; + await agent.get('/').expect(200, 'authFailure'); + assert(handlers.authnFailure.called); + assert(!handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); + it('authnFailure trumps authFailure', async function () { + handlers.authnFailure.shouldHandle = true; + handlers.authFailure.shouldHandle = true; + await agent.get('/').expect(200, 'authnFailure'); + assert(handlers.authnFailure.called); + assert(!handlers.authFailure.called); + }); + // authz failure tests + it('authz fail, no hooks handle -> 403', async function () { + await agent.get('/').auth('user', 'user-password').expect(403); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); + it('authz fail, authzFailure handles', async function () { + handlers.authzFailure.shouldHandle = true; + await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); + it('authz fail, authFailure handles', async function () { + handlers.authFailure.shouldHandle = true; + await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure'); + assert(!handlers.authnFailure.called); + assert(handlers.authzFailure.called); + assert(handlers.authFailure.called); + }); + it('authzFailure trumps authFailure', async function () { + handlers.authzFailure.shouldHandle = true; + handlers.authFailure.shouldHandle = true; + await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); + assert(handlers.authzFailure.called); + assert(!handlers.authFailure.called); + }); }); - - // authz failure tests - it('authz fail, no hooks handle -> 403', async function () { - await agent.get('/').auth('user', 'user-password').expect(403); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); - it('authz fail, authzFailure handles', async function () { - handlers.authzFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(!handlers.authFailure.called); - }); - it('authz fail, authFailure handles', async function () { - handlers.authFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure'); - assert(!handlers.authnFailure.called); - assert(handlers.authzFailure.called); - assert(handlers.authFailure.called); - }); - it('authzFailure trumps authFailure', async function () { - handlers.authzFailure.shouldHandle = true; - handlers.authFailure.shouldHandle = true; - await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure'); - assert(handlers.authzFailure.called); - assert(!handlers.authFailure.called); - }); - }); });