Move all files to esm

This commit is contained in:
SamTV12345 2023-06-25 00:05:01 +02:00
parent 237f7242ec
commit 76a6f665a4
No known key found for this signature in database
GPG key ID: E63EEC7466038043
87 changed files with 23693 additions and 30732 deletions

1
node_modules/ep_etherpad-lite generated vendored
View file

@ -1 +0,0 @@
../src

View file

@ -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}`;

1553
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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)
// for the last, potentially partial, line
hasAttrib = hasAttrib && rangeHasAttrib([selEnd[0], 0], [selEnd[0], selEnd[1]]);
*/
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;
}
// 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;

View file

@ -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<Attribute>} 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<Attribute>} 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;

File diff suppressed because it is too large Load diff

View file

@ -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]);
};

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -1,180 +1,139 @@
// Autosize 1.13 - jQuery plugin for textareas
// (c) 2012 Jack Moore - jacklmoore.com
// license: www.opensource.org/licenses/mit-license.php
(function ($) {
var
defaults = {
className: 'autosizejs',
append: "",
callback: false
},
hidden = 'hidden',
borderBox = 'border-box',
lineHeight = 'lineHeight',
copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>',
// line-height is omitted because IE7/IE8 doesn't return the correct value.
copyStyle = [
'fontFamily',
'fontSize',
'fontWeight',
'fontStyle',
'letterSpacing',
'textTransform',
'wordSpacing',
'textIndent'
],
oninput = 'oninput',
onpropertychange = 'onpropertychange',
test = $(copy)[0];
// For testing support in old FireFox
test.setAttribute(oninput, "return");
if ($.isFunction(test[oninput]) || onpropertychange in test) {
// test that line-height can be accurately copied to avoid
// incorrect value reporting in old IE and old Opera
$(test).css(lineHeight, '99px');
if ($(test).css(lineHeight) === '99px') {
copyStyle.push(lineHeight);
}
$.fn.autosize = function (options) {
options = $.extend({}, defaults, options || {});
return this.each(function () {
var
ta = this,
$ta = $(ta),
mirror,
minHeight = $ta.height(),
maxHeight = parseInt($ta.css('maxHeight'), 10),
active,
i = copyStyle.length,
resize,
boxOffset = 0,
value = ta.value,
callback = $.isFunction(options.callback);
if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox){
boxOffset = $ta.outerHeight() - $ta.height();
}
if ($ta.data('mirror') || $ta.data('ismirror')) {
// if autosize has already been applied, exit.
// if autosize is being applied to a mirror element, exit.
return;
} else {
mirror = $(copy).data('ismirror', true).addClass(options.className)[0];
resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
$ta.data('mirror', $(mirror)).css({
overflow: hidden,
overflowY: hidden,
wordWrap: 'break-word',
resize: resize
});
}
// Opera returns '-1px' when max-height is set to 'none'.
maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
// Using mainly bare JS in this function because it is going
// to fire very often while typing, and needs to very efficient.
function adjust() {
var height, overflow, original;
// the active flag keeps IE from tripping all over itself. Otherwise
// actions in the adjust function will cause IE to call adjust again.
if (!active) {
active = true;
mirror.value = ta.value + options.append;
mirror.style.overflowY = ta.style.overflowY;
original = parseInt(ta.style.height,10);
// Update the width in case the original textarea width has changed
mirror.style.width = $ta.css('width');
// Needed for IE to reliably return the correct scrollHeight
mirror.scrollTop = 0;
// Set a very high value for scrollTop to be sure the
// mirror is scrolled all the way to the bottom.
mirror.scrollTop = 9e4;
height = mirror.scrollTop;
overflow = hidden;
if (height > maxHeight) {
height = maxHeight;
overflow = 'scroll';
} else if (height < minHeight) {
height = minHeight;
}
height += boxOffset;
ta.style.overflowY = overflow;
if (original !== height) {
ta.style.height = height + 'px';
if (callback) {
options.callback.call(ta);
}
}
// This small timeout gives IE a chance to draw it's scrollbar
// before adjust can be run again (prevents an infinite loop).
setTimeout(function () {
active = false;
}, 1);
}
}
// mirror is a duplicate textarea located off-screen that
// is automatically updated to contain the same text as the
// original textarea. mirror always has a height of 0.
// This gives a cross-browser supported way getting the actual
// height of the text, through the scrollTop property.
while (i--) {
mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
}
$('body').append(mirror);
if (onpropertychange in ta) {
if (oninput in ta) {
// Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
// so binding to onkeyup to catch most of those occassions. There is no way that I
// know of to detect something like 'cut' in IE9.
ta[oninput] = ta.onkeyup = adjust;
} else {
// IE7 / IE8
ta[onpropertychange] = adjust;
}
} else {
// Modern Browsers
ta[oninput] = adjust;
// The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
// This is a hack to get Chrome to reflow it's text.
ta.value = '';
ta.value = value;
}
$(window).resize(adjust);
// Allow for manual triggering if needed.
$ta.bind('autosize', adjust);
// Call adjust in case the textarea already contains text.
adjust();
});
};
} else {
// Makes no changes for older browsers (FireFox3- and Safari4-)
$.fn.autosize = function (callback) {
return this;
};
}
var defaults = {
className: 'autosizejs',
append: "",
callback: false
}, hidden = 'hidden', borderBox = 'border-box', lineHeight = 'lineHeight', copy = '<textarea tabindex="-1" style="position:absolute; top:-9999px; left:-9999px; right:auto; bottom:auto; -moz-box-sizing:content-box; -webkit-box-sizing:content-box; box-sizing:content-box; word-wrap:break-word; height:0 !important; min-height:0 !important; overflow:hidden;"/>',
// line-height is omitted because IE7/IE8 doesn't return the correct value.
copyStyle = [
'fontFamily',
'fontSize',
'fontWeight',
'fontStyle',
'letterSpacing',
'textTransform',
'wordSpacing',
'textIndent'
], oninput = 'oninput', onpropertychange = 'onpropertychange', test = $(copy)[0];
// For testing support in old FireFox
test.setAttribute(oninput, "return");
if ($.isFunction(test[oninput]) || onpropertychange in test) {
// test that line-height can be accurately copied to avoid
// incorrect value reporting in old IE and old Opera
$(test).css(lineHeight, '99px');
if ($(test).css(lineHeight) === '99px') {
copyStyle.push(lineHeight);
}
$.fn.autosize = function (options) {
options = $.extend({}, defaults, options || {});
return this.each(function () {
var ta = this, $ta = $(ta), mirror, minHeight = $ta.height(), maxHeight = parseInt($ta.css('maxHeight'), 10), active, i = copyStyle.length, resize, boxOffset = 0, value = ta.value, callback = $.isFunction(options.callback);
if ($ta.css('box-sizing') === borderBox || $ta.css('-moz-box-sizing') === borderBox || $ta.css('-webkit-box-sizing') === borderBox) {
boxOffset = $ta.outerHeight() - $ta.height();
}
if ($ta.data('mirror') || $ta.data('ismirror')) {
// if autosize has already been applied, exit.
// if autosize is being applied to a mirror element, exit.
return;
}
else {
mirror = $(copy).data('ismirror', true).addClass(options.className)[0];
resize = $ta.css('resize') === 'none' ? 'none' : 'horizontal';
$ta.data('mirror', $(mirror)).css({
overflow: hidden,
overflowY: hidden,
wordWrap: 'break-word',
resize: resize
});
}
// Opera returns '-1px' when max-height is set to 'none'.
maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
// Using mainly bare JS in this function because it is going
// to fire very often while typing, and needs to very efficient.
function adjust() {
var height, overflow, original;
// the active flag keeps IE from tripping all over itself. Otherwise
// actions in the adjust function will cause IE to call adjust again.
if (!active) {
active = true;
mirror.value = ta.value + options.append;
mirror.style.overflowY = ta.style.overflowY;
original = parseInt(ta.style.height, 10);
// Update the width in case the original textarea width has changed
mirror.style.width = $ta.css('width');
// Needed for IE to reliably return the correct scrollHeight
mirror.scrollTop = 0;
// Set a very high value for scrollTop to be sure the
// mirror is scrolled all the way to the bottom.
mirror.scrollTop = 9e4;
height = mirror.scrollTop;
overflow = hidden;
if (height > maxHeight) {
height = maxHeight;
overflow = 'scroll';
}
else if (height < minHeight) {
height = minHeight;
}
height += boxOffset;
ta.style.overflowY = overflow;
if (original !== height) {
ta.style.height = height + 'px';
if (callback) {
options.callback.call(ta);
}
}
// This small timeout gives IE a chance to draw it's scrollbar
// before adjust can be run again (prevents an infinite loop).
setTimeout(function () {
active = false;
}, 1);
}
}
// mirror is a duplicate textarea located off-screen that
// is automatically updated to contain the same text as the
// original textarea. mirror always has a height of 0.
// This gives a cross-browser supported way getting the actual
// height of the text, through the scrollTop property.
while (i--) {
mirror.style[copyStyle[i]] = $ta.css(copyStyle[i]);
}
$('body').append(mirror);
if (onpropertychange in ta) {
if (oninput in ta) {
// Detects IE9. IE9 does not fire onpropertychange or oninput for deletions,
// so binding to onkeyup to catch most of those occassions. There is no way that I
// know of to detect something like 'cut' in IE9.
ta[oninput] = ta.onkeyup = adjust;
}
else {
// IE7 / IE8
ta[onpropertychange] = adjust;
}
}
else {
// Modern Browsers
ta[oninput] = adjust;
// The textarea overflow is now hidden. But Chrome doesn't reflow the text after the scrollbars are removed.
// This is a hack to get Chrome to reflow it's text.
ta.value = '';
ta.value = value;
}
$(window).resize(adjust);
// Allow for manual triggering if needed.
$ta.bind('autosize', adjust);
// Call adjust in case the textarea already contains text.
adjust();
});
};
}
else {
// Makes no changes for older browsers (FireFox3- and Safari4-)
$.fn.autosize = function (callback) {
return this;
};
}
}(jQuery));

View file

@ -1,61 +1,50 @@
/*! JSON.minify()
v0.1 (c) Kyle Simpson
MIT License
v0.1 (c) Kyle Simpson
MIT License
*/
(function(global){
if (typeof global.JSON == "undefined" || !global.JSON) {
global.JSON = {};
}
global.JSON.minify = function(json) {
var tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g,
in_string = false,
in_multiline_comment = false,
in_singleline_comment = false,
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc
;
tokenizer.lastIndex = 0;
while (tmp = tokenizer.exec(json)) {
lc = RegExp.leftContext;
rc = RegExp.rightContext;
if (!in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.substring(from);
if (!in_string) {
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,"");
}
new_str[ns++] = tmp2;
}
from = tokenizer.lastIndex;
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/);
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
in_string = !in_string;
}
from--; // include " character in next catch
rc = json.substring(from);
}
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = true;
}
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = false;
}
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = true;
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
}
new_str[ns++] = rc;
return new_str.join("");
};
(function (global) {
if (typeof global.JSON == "undefined" || !global.JSON) {
global.JSON = {};
}
global.JSON.minify = function (json) {
var tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g, in_string = false, in_multiline_comment = false, in_singleline_comment = false, tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc;
tokenizer.lastIndex = 0;
while (tmp = tokenizer.exec(json)) {
lc = RegExp.leftContext;
rc = RegExp.rightContext;
if (!in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.substring(from);
if (!in_string) {
tmp2 = tmp2.replace(/(\n|\r|\s)*/g, "");
}
new_str[ns++] = tmp2;
}
from = tokenizer.lastIndex;
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/);
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
in_string = !in_string;
}
from--; // include " character in next catch
rc = json.substring(from);
}
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = true;
}
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = false;
}
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = true;
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
}
new_str[ns++] = rc;
return new_str.join("");
};
})(this);

View file

@ -1,273 +1,247 @@
'use strict';
/* global socketio */
$(document).ready(() => {
const socket = socketio.connect('..', '/pluginfw/installer');
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
const search = (searchTerm, limit) => {
if (search.searchTerm !== searchTerm) {
search.offset = 0;
search.results = [];
search.end = false;
}
limit = limit ? limit : search.limit;
search.searchTerm = searchTerm;
socket.emit('search', {
searchTerm,
offset: search.offset,
limit,
sortBy: search.sortBy,
sortDir: search.sortDir,
const socket = socketio.connect('..', '/pluginfw/installer');
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect')
socket.connect();
});
search.offset += limit;
$('#search-progress').show();
search.messages.show('fetching');
search.searching = true;
};
search.searching = false;
search.offset = 0;
search.limit = 999;
search.results = [];
search.sortBy = 'name';
search.sortDir = /* DESC?*/true;
search.end = true;// have we received all results already?
search.messages = {
show: (msg) => {
// $('.search-results .messages').show()
$(`.search-results .messages .${msg}`).show();
$(`.search-results .messages .${msg} *`).show();
},
hide: (msg) => {
$('.search-results .messages').hide();
$(`.search-results .messages .${msg}`).hide();
$(`.search-results .messages .${msg} *`).hide();
},
};
const installed = {
progress: {
show: (plugin, msg) => {
$(`.installed-results .${plugin} .progress`).show();
$(`.installed-results .${plugin} .progress .message`).text(msg);
if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
$(window).scrollTop($(`.${plugin}`).offset().top - 100);
const search = (searchTerm, limit) => {
if (search.searchTerm !== searchTerm) {
search.offset = 0;
search.results = [];
search.end = false;
}
},
hide: (plugin) => {
$(`.installed-results .${plugin} .progress`).hide();
$(`.installed-results .${plugin} .progress .message`).text('');
},
},
messages: {
show: (msg) => {
$('.installed-results .messages').show();
$(`.installed-results .messages .${msg}`).show();
},
hide: (msg) => {
$('.installed-results .messages').hide();
$(`.installed-results .messages .${msg}`).hide();
},
},
list: [],
};
const displayPluginList = (plugins, container, template) => {
plugins.forEach((plugin) => {
const row = template.clone();
for (const attr in plugin) {
if (attr === 'name') { // Hack to rewrite URLS into name
const link = $('<a>')
.attr('href', `https://npmjs.org/package/${plugin.name}`)
.attr('plugin', 'Plugin details')
.attr('rel', 'noopener noreferrer')
.attr('target', '_blank')
.text(plugin.name.substr(3));
row.find('.name').append(link);
} else {
row.find(`.${attr}`).text(plugin[attr]);
}
}
row.find('.version').text(plugin.version);
row.addClass(plugin.name);
row.data('plugin', plugin.name);
container.append(row);
});
updateHandlers();
};
const sortPluginList = (plugins, property, /* ASC?*/dir) => plugins.sort((a, b) => {
if (a[property] < b[property]) return dir ? -1 : 1;
if (a[property] > b[property]) return dir ? 1 : -1;
// a must be equal to b
return 0;
});
const updateHandlers = () => {
// Search
$('#search-query').unbind('keyup').keyup(() => {
search($('#search-query').val());
});
// Prevent form submit
$('#search-query').parent().bind('submit', () => false);
// update & install
$('.do-install, .do-update').unbind('click').click(function (e) {
const $row = $(e.target).closest('tr');
const plugin = $row.data('plugin');
if ($(this).hasClass('do-install')) {
$row.remove().appendTo('#installed-plugins');
installed.progress.show(plugin, 'Installing');
} else {
installed.progress.show(plugin, 'Updating');
}
socket.emit('install', plugin);
installed.messages.hide('nothing-installed');
});
// uninstall
$('.do-uninstall').unbind('click').click((e) => {
const $row = $(e.target).closest('tr');
const pluginName = $row.data('plugin');
socket.emit('uninstall', pluginName);
installed.progress.show(pluginName, 'Uninstalling');
installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
});
// Sort
$('.sort.up').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = false;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
$('.sort.down, .sort.none').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = true;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
};
socket.on('results:search', (data) => {
if (!data.results.length) search.end = true;
if (data.query.offset === 0) search.results = [];
search.messages.hide('nothing-found');
search.messages.hide('fetching');
$('#search-query').removeAttr('disabled');
console.log('got search results', data);
// add to results
search.results = search.results.concat(data.results);
// Update sorting head
$('.sort')
.removeClass('up down')
.addClass('none');
$(`.search-results thead th[data-label=${data.query.sortBy}]`)
.removeClass('none')
.addClass(data.query.sortDir ? 'up' : 'down');
// re-render search results
const searchWidget = $('.search-results');
searchWidget.find('.results *').remove();
if (search.results.length > 0) {
displayPluginList(
search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
} else {
search.messages.show('nothing-found');
}
search.messages.hide('fetching');
$('#search-progress').hide();
limit = limit ? limit : search.limit;
search.searchTerm = searchTerm;
socket.emit('search', {
searchTerm,
offset: search.offset,
limit,
sortBy: search.sortBy,
sortDir: search.sortDir,
});
search.offset += limit;
$('#search-progress').show();
search.messages.show('fetching');
search.searching = true;
};
search.searching = false;
});
socket.on('results:installed', (data) => {
installed.messages.hide('fetching');
installed.messages.hide('nothing-installed');
installed.list = data.installed;
sortPluginList(installed.list, 'name', /* ASC?*/true);
// filter out epl
installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
// remove all installed plugins (leave plugins that are still being installed)
installed.list.forEach((plugin) => {
$(`#installed-plugins .${plugin.name}`).remove();
});
if (installed.list.length > 0) {
displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
socket.emit('checkUpdates');
} else {
installed.messages.show('nothing-installed');
}
});
socket.on('results:updatable', (data) => {
data.updatable.forEach((pluginName) => {
const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
actions.find('.do-update').remove();
actions.append(
$('<input>').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
});
updateHandlers();
});
socket.on('finished:install', (data) => {
if (data.error) {
if (data.code === 'EPEERINVALID') {
alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
}
alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
$(`#installed-plugins .${data.plugin}`).remove();
}
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.limit = 999;
search.results = [];
});
socket.on('finished:uninstall', (data) => {
if (data.error) {
alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`);
}
// remove plugin from installed list
$(`#installed-plugins .${data.plugin}`).remove();
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('connect', () => {
updateHandlers();
socket.emit('getInstalled');
search.searchTerm = null;
search($('#search-query').val());
});
// check for updates every 5mins
setInterval(() => {
socket.emit('checkUpdates');
}, 1000 * 60 * 5);
search.sortBy = 'name';
search.sortDir = /* DESC?*/ true;
search.end = true; // have we received all results already?
search.messages = {
show: (msg) => {
// $('.search-results .messages').show()
$(`.search-results .messages .${msg}`).show();
$(`.search-results .messages .${msg} *`).show();
},
hide: (msg) => {
$('.search-results .messages').hide();
$(`.search-results .messages .${msg}`).hide();
$(`.search-results .messages .${msg} *`).hide();
},
};
const installed = {
progress: {
show: (plugin, msg) => {
$(`.installed-results .${plugin} .progress`).show();
$(`.installed-results .${plugin} .progress .message`).text(msg);
if ($(window).scrollTop() > $(`.${plugin}`).offset().top) {
$(window).scrollTop($(`.${plugin}`).offset().top - 100);
}
},
hide: (plugin) => {
$(`.installed-results .${plugin} .progress`).hide();
$(`.installed-results .${plugin} .progress .message`).text('');
},
},
messages: {
show: (msg) => {
$('.installed-results .messages').show();
$(`.installed-results .messages .${msg}`).show();
},
hide: (msg) => {
$('.installed-results .messages').hide();
$(`.installed-results .messages .${msg}`).hide();
},
},
list: [],
};
const displayPluginList = (plugins, container, template) => {
plugins.forEach((plugin) => {
const row = template.clone();
for (const attr in plugin) {
if (attr === 'name') { // Hack to rewrite URLS into name
const link = $('<a>')
.attr('href', `https://npmjs.org/package/${plugin.name}`)
.attr('plugin', 'Plugin details')
.attr('rel', 'noopener noreferrer')
.attr('target', '_blank')
.text(plugin.name.substr(3));
row.find('.name').append(link);
}
else {
row.find(`.${attr}`).text(plugin[attr]);
}
}
row.find('.version').text(plugin.version);
row.addClass(plugin.name);
row.data('plugin', plugin.name);
container.append(row);
});
updateHandlers();
};
const sortPluginList = (plugins, property, /* ASC?*/ dir) => plugins.sort((a, b) => {
if (a[property] < b[property])
return dir ? -1 : 1;
if (a[property] > b[property])
return dir ? 1 : -1;
// a must be equal to b
return 0;
});
const updateHandlers = () => {
// Search
$('#search-query').unbind('keyup').keyup(() => {
search($('#search-query').val());
});
// Prevent form submit
$('#search-query').parent().bind('submit', () => false);
// update & install
$('.do-install, .do-update').unbind('click').click(function (e) {
const $row = $(e.target).closest('tr');
const plugin = $row.data('plugin');
if ($(this).hasClass('do-install')) {
$row.remove().appendTo('#installed-plugins');
installed.progress.show(plugin, 'Installing');
}
else {
installed.progress.show(plugin, 'Updating');
}
socket.emit('install', plugin);
installed.messages.hide('nothing-installed');
});
// uninstall
$('.do-uninstall').unbind('click').click((e) => {
const $row = $(e.target).closest('tr');
const pluginName = $row.data('plugin');
socket.emit('uninstall', pluginName);
installed.progress.show(pluginName, 'Uninstalling');
installed.list = installed.list.filter((plugin) => plugin.name !== pluginName);
});
// Sort
$('.sort.up').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = false;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
$('.sort.down, .sort.none').unbind('click').click(function () {
search.sortBy = $(this).attr('data-label').toLowerCase();
search.sortDir = true;
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
};
socket.on('results:search', (data) => {
if (!data.results.length)
search.end = true;
if (data.query.offset === 0)
search.results = [];
search.messages.hide('nothing-found');
search.messages.hide('fetching');
$('#search-query').removeAttr('disabled');
console.log('got search results', data);
// add to results
search.results = search.results.concat(data.results);
// Update sorting head
$('.sort')
.removeClass('up down')
.addClass('none');
$(`.search-results thead th[data-label=${data.query.sortBy}]`)
.removeClass('none')
.addClass(data.query.sortDir ? 'up' : 'down');
// re-render search results
const searchWidget = $('.search-results');
searchWidget.find('.results *').remove();
if (search.results.length > 0) {
displayPluginList(search.results, searchWidget.find('.results'), searchWidget.find('.template tr'));
}
else {
search.messages.show('nothing-found');
}
search.messages.hide('fetching');
$('#search-progress').hide();
search.searching = false;
});
socket.on('results:installed', (data) => {
installed.messages.hide('fetching');
installed.messages.hide('nothing-installed');
installed.list = data.installed;
sortPluginList(installed.list, 'name', /* ASC?*/ true);
// filter out epl
installed.list = installed.list.filter((plugin) => plugin.name !== 'ep_etherpad-lite');
// remove all installed plugins (leave plugins that are still being installed)
installed.list.forEach((plugin) => {
$(`#installed-plugins .${plugin.name}`).remove();
});
if (installed.list.length > 0) {
displayPluginList(installed.list, $('#installed-plugins'), $('#installed-plugin-template'));
socket.emit('checkUpdates');
}
else {
installed.messages.show('nothing-installed');
}
});
socket.on('results:updatable', (data) => {
data.updatable.forEach((pluginName) => {
const actions = $(`#installed-plugins > tr.${pluginName} .actions`);
actions.find('.do-update').remove();
actions.append($('<input>').addClass('do-update').attr('type', 'button').attr('value', 'Update'));
});
updateHandlers();
});
socket.on('finished:install', (data) => {
if (data.error) {
if (data.code === 'EPEERINVALID') {
alert("This plugin requires that you update Etherpad so it can operate in it's true glory");
}
alert(`An error occurred while installing ${data.plugin} \n${data.error}`);
$(`#installed-plugins .${data.plugin}`).remove();
}
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('finished:uninstall', (data) => {
if (data.error) {
alert(`An error occurred while uninstalling the ${data.plugin} \n${data.error}`);
}
// remove plugin from installed list
$(`#installed-plugins .${data.plugin}`).remove();
socket.emit('getInstalled');
// update search results
search.offset = 0;
search(search.searchTerm, search.results.length);
search.results = [];
});
socket.on('connect', () => {
updateHandlers();
socket.emit('getInstalled');
search.searchTerm = null;
search($('#search-query').val());
});
// check for updates every 5mins
setInterval(() => {
socket.emit('checkUpdates');
}, 1000 * 60 * 5);
});

View file

@ -1,69 +1,63 @@
'use strict';
$(document).ready(() => {
const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => {
socket.emit('load');
});
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
socket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
if (settings.results === 'NOT_ALLOWED') {
$('.innerwrapper').hide();
$('.innerwrapper-err').show();
$('.err-message').html('Settings json is not authorized to be viewed in Admin page!!');
return;
}
/* Check to make sure the JSON is clean before proceeding */
if (isJSONClean(settings.results)) {
$('.settings').append(settings.results);
$('.settings').focus();
$('.settings').autosize();
} else {
alert('Invalid JSON');
}
});
/* When the admin clicks save Settings check the JSON then send the JSON back to the server */
$('#saveSettings').on('click', () => {
const editedSettings = $('.settings').val();
if (isJSONClean(editedSettings)) {
// JSON is clean so emit it to the server
socket.emit('saveSettings', $('.settings').val());
} else {
alert('Invalid JSON');
$('.settings').focus();
}
});
/* Tell Etherpad Server to restart */
$('#restartEtherpad').on('click', () => {
socket.emit('restartServer');
});
socket.on('saveprogress', (progress) => {
$('#response').show();
$('#response').text(progress);
$('#response').fadeOut('slow');
});
const socket = window.socketio.connect('..', '/settings');
socket.on('connect', () => {
socket.emit('load');
});
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect')
socket.connect();
});
socket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
if (settings.results === 'NOT_ALLOWED') {
$('.innerwrapper').hide();
$('.innerwrapper-err').show();
$('.err-message').html('Settings json is not authorized to be viewed in Admin page!!');
return;
}
/* Check to make sure the JSON is clean before proceeding */
if (isJSONClean(settings.results)) {
$('.settings').append(settings.results);
$('.settings').focus();
$('.settings').autosize();
}
else {
alert('Invalid JSON');
}
});
/* When the admin clicks save Settings check the JSON then send the JSON back to the server */
$('#saveSettings').on('click', () => {
const editedSettings = $('.settings').val();
if (isJSONClean(editedSettings)) {
// JSON is clean so emit it to the server
socket.emit('saveSettings', $('.settings').val());
}
else {
alert('Invalid JSON');
$('.settings').focus();
}
});
/* Tell Etherpad Server to restart */
$('#restartEtherpad').on('click', () => {
socket.emit('restartServer');
});
socket.on('saveprogress', (progress) => {
$('#response').show();
$('#response').text(progress);
$('#response').fadeOut('slow');
});
});
const isJSONClean = (data) => {
let cleanSettings = JSON.minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try {
return typeof jQuery.parseJSON(cleanSettings) === 'object';
} catch (e) {
return false; // the JSON failed to be parsed
}
let cleanSettings = JSON.minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try {
return typeof jQuery.parseJSON(cleanSettings) === 'object';
}
catch (e) {
return false; // the JSON failed to be parsed
}
};

View file

@ -1,130 +1,45 @@
'use strict';
// Low-level utilities for manipulating attribute strings. For a high-level API, see AttributeMap.
/**
* 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.
*
* Examples: '', '*0', '*3*j*z*1q'
*
* @typedef {string} AttributeString
*/
/**
* Converts an attribute string into a sequence of attribute identifier numbers.
*
* WARNING: This only works on attribute strings. It does NOT work on serialized operations or
* changesets.
*
* @param {AttributeString} str - Attribute string.
* @yields {number} The attribute numbers (to look up in the associated pool), in the order they
* appear in `str`.
* @returns {Generator<number>}
*/
exports.decodeAttribString = function* (str) {
const re = /\*([0-9a-z]+)|./gy;
let match;
while ((match = re.exec(str)) != null) {
const [m, n] = match;
if (n == null) throw new Error(`invalid character in attribute string: ${m}`);
yield Number.parseInt(n, 36);
}
};
const checkAttribNum = (n) => {
if (typeof n !== 'number') throw new TypeError(`not a number: ${n}`);
if (n < 0) throw new Error(`attribute number is negative: ${n}`);
if (n !== Math.trunc(n)) throw new Error(`attribute number is not an integer: ${n}`);
if (typeof n !== 'number')
throw new TypeError(`not a number: ${n}`);
if (n < 0)
throw new Error(`attribute number is negative: ${n}`);
if (n !== Math.trunc(n))
throw new Error(`attribute number is not an integer: ${n}`);
};
/**
* Inverse of `decodeAttribString`.
*
* @param {Iterable<number>} attribNums - Sequence of attribute numbers.
* @returns {AttributeString}
*/
exports.encodeAttribString = (attribNums) => {
let str = '';
for (const n of attribNums) {
checkAttribNum(n);
str += `*${n.toString(36).toLowerCase()}`;
}
return str;
export const decodeAttribString = function* (str) {
const re = /\*([0-9a-z]+)|./gy;
let match;
while ((match = re.exec(str)) != null) {
const [m, n] = match;
if (n == null)
throw new Error(`invalid character in attribute string: ${m}`);
yield Number.parseInt(n, 36);
}
};
/**
* Converts a sequence of attribute numbers into a sequence of attributes.
*
* @param {Iterable<number>} attribNums - Attribute numbers to look up in the pool.
* @param {AttributePool} pool - Attribute pool.
* @yields {Attribute} The identified attributes, in the same order as `attribNums`.
* @returns {Generator<Attribute>}
*/
exports.attribsFromNums = function* (attribNums, pool) {
for (const n of attribNums) {
checkAttribNum(n);
const attrib = pool.getAttrib(n);
if (attrib == null) throw new Error(`attribute ${n} does not exist in pool`);
yield attrib;
}
export const encodeAttribString = (attribNums) => {
let str = '';
for (const n of attribNums) {
checkAttribNum(n);
str += `*${n.toString(36).toLowerCase()}`;
}
return str;
};
/**
* Inverse of `attribsFromNums`.
*
* @param {Iterable<Attribute>} attribs - Attributes. Any attributes not already in `pool` are
* inserted into `pool`. No checking is performed to ensure that the attributes are in the
* canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if
* required.)
* @param {AttributePool} pool - Attribute pool.
* @yields {number} The attribute number of each attribute in `attribs`, in order.
* @returns {Generator<number>}
*/
exports.attribsToNums = function* (attribs, pool) {
for (const attrib of attribs) yield pool.putAttrib(attrib);
export const attribsFromNums = function* (attribNums, pool) {
for (const n of attribNums) {
checkAttribNum(n);
const attrib = pool.getAttrib(n);
if (attrib == null)
throw new Error(`attribute ${n} does not exist in pool`);
yield attrib;
}
};
/**
* Convenience function that is equivalent to `attribsFromNums(decodeAttribString(str), pool)`.
*
* WARNING: This only works on attribute strings. It does NOT work on serialized operations or
* changesets.
*
* @param {AttributeString} str - Attribute string.
* @param {AttributePool} pool - Attribute pool.
* @yields {Attribute} The attributes identified in `str`, in order.
* @returns {Generator<Attribute>}
*/
exports.attribsFromString = function* (str, pool) {
yield* exports.attribsFromNums(exports.decodeAttribString(str), pool);
export const attribsToNums = function* (attribs, pool) {
for (const attrib of attribs)
yield pool.putAttrib(attrib);
};
/**
* Inverse of `attribsFromString`.
*
* @param {Iterable<Attribute>} attribs - Attributes. The attributes to insert into the pool (if
* necessary) and encode. No checking is performed to ensure that the attributes are in the
* canonical order and that there are no duplicate keys. (Use an AttributeMap and/or `sort()` if
* required.)
* @param {AttributePool} pool - Attribute pool.
* @returns {AttributeString}
*/
exports.attribsToString =
(attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool));
/**
* Sorts the attributes in canonical order. The order of entries with the same attribute name is
* unspecified.
*
* @param {Attribute[]} attribs - Attributes to sort in place.
* @returns {Attribute[]} `attribs` (for chaining).
*/
exports.sort =
(attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));
export const attribsFromString = function* (str, pool) {
yield* exports.attribsFromNums(exports.decodeAttribString(str), pool);
};
export const attribsToString = (attribs, pool) => exports.encodeAttribString(exports.attribsToNums(attribs, pool));
export const sort = (attribs) => attribs.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : 0) - (keyA < keyB ? 1 : 0));

View file

@ -1,48 +1,40 @@
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/* Copyright 2021 Richard Hansen <rhansen@rhansen.org> */
'use strict';
// Set up an error handler to display errors that happen during page load. This handler will be
// overridden with a nicer handler by setupGlobalExceptionHandler() in pad_utils.js.
(() => {
const originalHandler = window.onerror;
window.onerror = (...args) => {
const [msg, url, line, col, err] = args;
// Purge the existing HTML and styles for a consistent view.
document.body.textContent = '';
for (const el of document.querySelectorAll('head style, head link[rel="stylesheet"]')) {
el.remove();
}
const box = document.body;
box.textContent = '';
const summary = document.createElement('p');
box.appendChild(summary);
summary.appendChild(document.createTextNode('An error occurred while loading the page:'));
const msgBlock = document.createElement('blockquote');
box.appendChild(msgBlock);
msgBlock.style.fontWeight = 'bold';
msgBlock.appendChild(document.createTextNode(msg));
const loc = document.createElement('p');
box.appendChild(loc);
loc.appendChild(document.createTextNode(`in ${url}`));
loc.appendChild(document.createElement('br'));
loc.appendChild(document.createTextNode(`at line ${line}:${col}`));
const stackSummary = document.createElement('p');
box.appendChild(stackSummary);
stackSummary.appendChild(document.createTextNode('Stack trace:'));
const stackBlock = document.createElement('blockquote');
box.appendChild(stackBlock);
const stack = document.createElement('pre');
stackBlock.appendChild(stack);
stack.appendChild(document.createTextNode(err.stack || err.toString()));
if (typeof originalHandler === 'function') originalHandler(...args);
};
const originalHandler = window.onerror;
window.onerror = (...args) => {
const [msg, url, line, col, err] = args;
// Purge the existing HTML and styles for a consistent view.
document.body.textContent = '';
for (const el of document.querySelectorAll('head style, head link[rel="stylesheet"]')) {
el.remove();
}
const box = document.body;
box.textContent = '';
const summary = document.createElement('p');
box.appendChild(summary);
summary.appendChild(document.createTextNode('An error occurred while loading the page:'));
const msgBlock = document.createElement('blockquote');
box.appendChild(msgBlock);
msgBlock.style.fontWeight = 'bold';
msgBlock.appendChild(document.createTextNode(msg));
const loc = document.createElement('p');
box.appendChild(loc);
loc.appendChild(document.createTextNode(`in ${url}`));
loc.appendChild(document.createElement('br'));
loc.appendChild(document.createTextNode(`at line ${line}:${col}`));
const stackSummary = document.createElement('p');
box.appendChild(stackSummary);
stackSummary.appendChild(document.createTextNode('Stack trace:'));
const stackBlock = document.createElement('blockquote');
box.appendChild(stackBlock);
const stack = document.createElement('pre');
stackBlock.appendChild(stack);
stack.appendChild(document.createTextNode(err.stack || err.toString()));
if (typeof originalHandler === 'function')
originalHandler(...args);
};
})();
// @license-end

View file

@ -1,11 +1,18 @@
import { makeCSSManager as makeCSSManager$0 } from "./cssmanager.js";
import { domline as domline$0 } from "./domline.js";
import AttribPool from "./AttributePool.js";
import * as Changeset from "./Changeset.js";
import * as attributes from "./attributes.js";
import { linestylefilter as linestylefilter$0 } from "./linestylefilter.js";
import { colorutils as colorutils$0 } from "./colorutils.js";
import * as _ from "./underscore.js";
import * as hooks from "./pluginfw/hooks.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,467 +28,410 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const makeCSSManager = require('./cssmanager').makeCSSManager;
const domline = require('./domline').domline;
const AttribPool = require('./AttributePool');
const Changeset = require('./Changeset');
const attributes = require('./attributes');
const linestylefilter = require('./linestylefilter').linestylefilter;
const colorutils = require('./colorutils').colorutils;
const _ = require('./underscore');
const hooks = require('./pluginfw/hooks');
const makeCSSManager = { makeCSSManager: makeCSSManager$0 }.makeCSSManager;
const domline = { domline: domline$0 }.domline;
const linestylefilter = { linestylefilter: linestylefilter$0 }.linestylefilter;
const colorutils = { colorutils: colorutils$0 }.colorutils;
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider) => {
let goToRevisionIfEnabledCount = 0;
let changesetLoader = undefined;
const debugLog = (...args) => {
try {
if (window.console) console.log(...args);
} catch (e) {
if (window.console) console.log('error printing: ', e);
}
};
const padContents = {
currentRevision: clientVars.collab_client_vars.rev,
currentTime: clientVars.collab_client_vars.time,
currentLines:
Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),
currentDivs: null,
// to be filled in once the dom loads
apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),
alines: Changeset.splitAttributionLines(
clientVars.collab_client_vars.initialAttributedText.attribs,
clientVars.collab_client_vars.initialAttributedText.text),
// generates a jquery element containing HTML for a line
lineToElement(line, aline) {
const element = document.createElement('div');
const emptyLine = (line === '\n');
const domInfo = domline.createDomLine(!emptyLine, true);
linestylefilter.populateDomLine(line, aline, this.apool, domInfo);
domInfo.prepareForAdd();
element.className = domInfo.node.className;
element.innerHTML = domInfo.node.innerHTML;
element.id = Math.random();
return $(element);
},
// splice the lines
splice(start, numRemoved, ...newLines) {
// remove spliced-out lines from DOM
for (let i = start; i < start + numRemoved && i < this.currentDivs.length; i++) {
this.currentDivs[i].remove();
}
// remove spliced-out line divs from currentDivs array
this.currentDivs.splice(start, numRemoved);
const newDivs = [];
for (let i = 0; i < newLines.length; i++) {
newDivs.push(this.lineToElement(newLines[i], this.alines[start + i]));
}
// grab the div just before the first one
let startDiv = this.currentDivs[start - 1] || null;
// insert the div elements into the correct place, in the correct order
for (let i = 0; i < newDivs.length; i++) {
if (startDiv) {
startDiv.after(newDivs[i]);
} else {
$('#innerdocbody').prepend(newDivs[i]);
let goToRevisionIfEnabledCount = 0;
let changesetLoader = undefined;
const debugLog = (...args) => {
try {
if (window.console)
console.log(...args);
}
startDiv = newDivs[i];
}
// insert new divs into currentDivs array
this.currentDivs.splice(start, 0, ...newDivs);
// call currentLines.splice, to keep the currentLines array up to date
this.currentLines.splice(start, numRemoved, ...newLines);
},
// returns the contents of the specified line I
get(i) {
return this.currentLines[i];
},
// returns the number of lines in the document
length() {
return this.currentLines.length;
},
getActiveAuthors() {
const authorIds = new Set();
for (const aline of this.alines) {
for (const op of Changeset.deserializeOps(aline)) {
for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {
if (k !== 'author') continue;
if (v) authorIds.add(v);
}
catch (e) {
if (window.console)
console.log('error printing: ', e);
}
}
return [...authorIds].sort();
},
};
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {
// disable the next 'gotorevision' call handled by a timeslider update
if (!preventSliderMovement) {
goToRevisionIfEnabledCount++;
BroadcastSlider.setSliderPosition(revision);
}
const oldAlines = padContents.alines.slice();
try {
// must mutate attribution lines before text lines
Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool);
} catch (e) {
debugLog(e);
}
// scroll to the area that is changed before the lines are mutated
if ($('#options-followContents').is(':checked') ||
$('#options-followContents').prop('checked')) {
// get the index of the first line that has mutated attributes
// the last line in `oldAlines` should always equal to "|1+1", ie newline without attributes
// so it should be safe to assume this line has changed attributes when inserting content at
// the bottom of a pad
let lineChanged;
_.some(oldAlines, (line, index) => {
if (line !== padContents.alines[index]) {
lineChanged = index;
return true; // break
};
const padContents = {
currentRevision: clientVars.collab_client_vars.rev,
currentTime: clientVars.collab_client_vars.time,
currentLines: Changeset.splitTextLines(clientVars.collab_client_vars.initialAttributedText.text),
currentDivs: null,
// to be filled in once the dom loads
apool: (new AttribPool()).fromJsonable(clientVars.collab_client_vars.apool),
alines: Changeset.splitAttributionLines(clientVars.collab_client_vars.initialAttributedText.attribs, clientVars.collab_client_vars.initialAttributedText.text),
// generates a jquery element containing HTML for a line
lineToElement(line, aline) {
const element = document.createElement('div');
const emptyLine = (line === '\n');
const domInfo = domline.createDomLine(!emptyLine, true);
linestylefilter.populateDomLine(line, aline, this.apool, domInfo);
domInfo.prepareForAdd();
element.className = domInfo.node.className;
element.innerHTML = domInfo.node.innerHTML;
element.id = Math.random();
return $(element);
},
// splice the lines
splice(start, numRemoved, ...newLines) {
// remove spliced-out lines from DOM
for (let i = start; i < start + numRemoved && i < this.currentDivs.length; i++) {
this.currentDivs[i].remove();
}
// remove spliced-out line divs from currentDivs array
this.currentDivs.splice(start, numRemoved);
const newDivs = [];
for (let i = 0; i < newLines.length; i++) {
newDivs.push(this.lineToElement(newLines[i], this.alines[start + i]));
}
// grab the div just before the first one
let startDiv = this.currentDivs[start - 1] || null;
// insert the div elements into the correct place, in the correct order
for (let i = 0; i < newDivs.length; i++) {
if (startDiv) {
startDiv.after(newDivs[i]);
}
else {
$('#innerdocbody').prepend(newDivs[i]);
}
startDiv = newDivs[i];
}
// insert new divs into currentDivs array
this.currentDivs.splice(start, 0, ...newDivs);
// call currentLines.splice, to keep the currentLines array up to date
this.currentLines.splice(start, numRemoved, ...newLines);
},
// returns the contents of the specified line I
get(i) {
return this.currentLines[i];
},
// returns the number of lines in the document
length() {
return this.currentLines.length;
},
getActiveAuthors() {
const authorIds = new Set();
for (const aline of this.alines) {
for (const op of Changeset.deserializeOps(aline)) {
for (const [k, v] of attributes.attribsFromString(op.attribs, this.apool)) {
if (k !== 'author')
continue;
if (v)
authorIds.add(v);
}
}
}
return [...authorIds].sort();
},
};
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {
// disable the next 'gotorevision' call handled by a timeslider update
if (!preventSliderMovement) {
goToRevisionIfEnabledCount++;
BroadcastSlider.setSliderPosition(revision);
}
});
// some chars are replaced (no attributes change and no length change)
// test if there are keep ops at the start of the cs
if (lineChanged === undefined) {
const [op] = Changeset.deserializeOps(Changeset.unpack(changeset).ops);
lineChanged = op != null && op.opcode === '=' ? op.lines : 0;
}
const goToLineNumber = (lineNumber) => {
// Sets the Y scrolling of the browser to go to this line
const line = $('#innerdocbody').find(`div:nth-child(${lineNumber + 1})`);
const newY = $(line)[0].offsetTop;
const ecb = document.getElementById('editorcontainerbox');
// Chrome 55 - 59 bugfix
if (ecb.scrollTo) {
ecb.scrollTo({top: newY, behavior: 'auto'});
} else {
$('#editorcontainerbox').scrollTop(newY);
const oldAlines = padContents.alines.slice();
try {
// must mutate attribution lines before text lines
Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool);
}
};
goToLineNumber(lineChanged);
}
Changeset.mutateTextLines(changeset, padContents);
padContents.currentRevision = revision;
padContents.currentTime += timeDelta * 1000;
catch (e) {
debugLog(e);
}
// scroll to the area that is changed before the lines are mutated
if ($('#options-followContents').is(':checked') ||
$('#options-followContents').prop('checked')) {
// get the index of the first line that has mutated attributes
// the last line in `oldAlines` should always equal to "|1+1", ie newline without attributes
// so it should be safe to assume this line has changed attributes when inserting content at
// the bottom of a pad
let lineChanged;
_.some(oldAlines, (line, index) => {
if (line !== padContents.alines[index]) {
lineChanged = index;
return true; // break
}
});
// some chars are replaced (no attributes change and no length change)
// test if there are keep ops at the start of the cs
if (lineChanged === undefined) {
const [op] = Changeset.deserializeOps(Changeset.unpack(changeset).ops);
lineChanged = op != null && op.opcode === '=' ? op.lines : 0;
}
const goToLineNumber = (lineNumber) => {
// Sets the Y scrolling of the browser to go to this line
const line = $('#innerdocbody').find(`div:nth-child(${lineNumber + 1})`);
const newY = $(line)[0].offsetTop;
const ecb = document.getElementById('editorcontainerbox');
// Chrome 55 - 59 bugfix
if (ecb.scrollTo) {
ecb.scrollTo({ top: newY, behavior: 'auto' });
}
else {
$('#editorcontainerbox').scrollTop(newY);
}
};
goToLineNumber(lineChanged);
}
Changeset.mutateTextLines(changeset, padContents);
padContents.currentRevision = revision;
padContents.currentTime += timeDelta * 1000;
updateTimer();
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadedNewChangeset = (changesetForward, changesetBackward, revision, timeDelta) => {
const revisionInfo = window.revisionInfo;
const broadcasting = (BroadcastSlider.getSliderPosition() === revisionInfo.latest);
revisionInfo.addChangeset(revision, revision + 1, changesetForward, changesetBackward, timeDelta);
BroadcastSlider.setSliderLength(revisionInfo.latest);
if (broadcasting)
applyChangeset(changesetForward, revision + 1, false, timeDelta);
};
/*
At this point, we must be certain that the changeset really does map from
the current revision to the specified revision. Any mistakes here will
cause the whole slider to get out of sync.
*/
const updateTimer = () => {
const zpad = (str, length) => {
str = `${str}`;
while (str.length < length)
str = `0${str}`;
return str;
};
const date = new Date(padContents.currentTime);
const dateFormat = () => {
const month = zpad(date.getMonth() + 1, 2);
const day = zpad(date.getDate(), 2);
const year = (date.getFullYear());
const hours = zpad(date.getHours(), 2);
const minutes = zpad(date.getMinutes(), 2);
const seconds = zpad(date.getSeconds(), 2);
return (html10n.get('timeslider.dateformat', {
day,
month,
year,
hours,
minutes,
seconds,
}));
};
$('#timer').html(dateFormat());
const revisionDate = html10n.get('timeslider.saved', {
day: date.getDate(),
month: [
html10n.get('timeslider.month.january'),
html10n.get('timeslider.month.february'),
html10n.get('timeslider.month.march'),
html10n.get('timeslider.month.april'),
html10n.get('timeslider.month.may'),
html10n.get('timeslider.month.june'),
html10n.get('timeslider.month.july'),
html10n.get('timeslider.month.august'),
html10n.get('timeslider.month.september'),
html10n.get('timeslider.month.october'),
html10n.get('timeslider.month.november'),
html10n.get('timeslider.month.december'),
][date.getMonth()],
year: date.getFullYear(),
});
$('#revision_date').html(revisionDate);
};
updateTimer();
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadedNewChangeset = (changesetForward, changesetBackward, revision, timeDelta) => {
const revisionInfo = window.revisionInfo;
const broadcasting = (BroadcastSlider.getSliderPosition() === revisionInfo.latest);
revisionInfo.addChangeset(
revision, revision + 1, changesetForward, changesetBackward, timeDelta);
BroadcastSlider.setSliderLength(revisionInfo.latest);
if (broadcasting) applyChangeset(changesetForward, revision + 1, false, timeDelta);
};
/*
At this point, we must be certain that the changeset really does map from
the current revision to the specified revision. Any mistakes here will
cause the whole slider to get out of sync.
*/
const updateTimer = () => {
const zpad = (str, length) => {
str = `${str}`;
while (str.length < length) str = `0${str}`;
return str;
};
const date = new Date(padContents.currentTime);
const dateFormat = () => {
const month = zpad(date.getMonth() + 1, 2);
const day = zpad(date.getDate(), 2);
const year = (date.getFullYear());
const hours = zpad(date.getHours(), 2);
const minutes = zpad(date.getMinutes(), 2);
const seconds = zpad(date.getSeconds(), 2);
return (html10n.get('timeslider.dateformat', {
day,
month,
year,
hours,
minutes,
seconds,
}));
};
$('#timer').html(dateFormat());
const revisionDate = html10n.get('timeslider.saved', {
day: date.getDate(),
month: [
html10n.get('timeslider.month.january'),
html10n.get('timeslider.month.february'),
html10n.get('timeslider.month.march'),
html10n.get('timeslider.month.april'),
html10n.get('timeslider.month.may'),
html10n.get('timeslider.month.june'),
html10n.get('timeslider.month.july'),
html10n.get('timeslider.month.august'),
html10n.get('timeslider.month.september'),
html10n.get('timeslider.month.october'),
html10n.get('timeslider.month.november'),
html10n.get('timeslider.month.december'),
][date.getMonth()],
year: date.getFullYear(),
});
$('#revision_date').html(revisionDate);
};
updateTimer();
const goToRevision = (newRevision) => {
padContents.targetRevision = newRevision;
const path = window.revisionInfo.getPath(padContents.currentRevision, newRevision);
hooks.aCallAll('goToRevisionEvent', {
rev: newRevision,
});
if (path.status === 'complete') {
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
} else if (path.status === 'partial') {
// callback is called after changeset information is pulled from server
// this may never get called, if the changeset has already been loaded
const update = (start, end) => {
// if we've called goToRevision in the time since, don't goToRevision
goToRevision(padContents.targetRevision);
};
// do our best with what we have...
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset) applyChangeset(changeset, path.rev, true, timeDelta);
// Loading changeset history for new revision
loadChangesetsForRevision(newRevision, update);
// Loading changeset history for old revision (to make diff between old and new revision)
loadChangesetsForRevision(padContents.currentRevision - 1);
}
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadChangesetsForRevision = (revision, callback) => {
if (BroadcastSlider.getSliderLength() > 10000) {
const start = (Math.floor((revision) / 10000) * 10000); // revision 0 to 10
changesetLoader.queueUp(start, 100);
}
if (BroadcastSlider.getSliderLength() > 1000) {
const start = (Math.floor((revision) / 1000) * 1000); // (start from -1, go to 19) + 1
changesetLoader.queueUp(start, 10);
}
const start = (Math.floor((revision) / 100) * 100);
changesetLoader.queueUp(start, 1, callback);
};
changesetLoader = {
running: false,
resolved: [],
requestQueue1: [],
requestQueue2: [],
requestQueue3: [],
reqCallbacks: [],
queueUp(revision, width, callback) {
if (revision < 0) revision = 0;
// if(this.requestQueue.indexOf(revision) != -1)
// return; // already in the queue.
if (this.resolved.indexOf(`${revision}_${width}`) !== -1) {
// already loaded from the server
return;
}
this.resolved.push(`${revision}_${width}`);
const requestQueue =
width === 1 ? this.requestQueue3
: width === 10 ? this.requestQueue2
: this.requestQueue1;
requestQueue.push(
{
rev: revision,
res: width,
callback,
});
if (!this.running) {
this.running = true;
setTimeout(() => this.loadFromQueue(), 10);
}
},
loadFromQueue() {
const requestQueue =
this.requestQueue1.length > 0 ? this.requestQueue1
: this.requestQueue2.length > 0 ? this.requestQueue2
: this.requestQueue3.length > 0 ? this.requestQueue3
: null;
if (!requestQueue) {
this.running = false;
return;
}
const request = requestQueue.pop();
const granularity = request.res;
const callback = request.callback;
const start = request.rev;
const requestID = Math.floor(Math.random() * 100000);
sendSocketMsg('CHANGESET_REQ', {
start,
granularity,
requestID,
});
this.reqCallbacks[requestID] = callback;
},
handleSocketResponse(message) {
const start = message.data.start;
const granularity = message.data.granularity;
const callback = this.reqCallbacks[message.data.requestID];
delete this.reqCallbacks[message.data.requestID];
this.handleResponse(message.data, start, granularity, callback);
setTimeout(() => this.loadFromQueue(), 10);
},
handleResponse: (data, start, granularity, callback) => {
const pool = (new AttribPool()).fromJsonable(data.apool);
for (let i = 0; i < data.forwardsChangesets.length; i++) {
const astart = start + i * granularity - 1; // rev -1 is a blank single line
let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision
if (aend > data.actualEndNum - 1) aend = data.actualEndNum - 1;
// debugLog("adding changeset:", astart, aend);
const forwardcs =
Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);
const backwardcs =
Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);
window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);
}
if (callback) callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);
},
handleMessageFromServer(obj) {
if (obj.type === 'COLLABROOM') {
obj = obj.data;
if (obj.type === 'NEW_CHANGES') {
const changeset = Changeset.moveOpsToNewPool(
obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
let changesetBack = Changeset.inverse(
obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
changesetBack = Changeset.moveOpsToNewPool(
changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);
} else if (obj.type === 'NEW_AUTHORDATA') {
const authorMap = {};
authorMap[obj.author] = obj.data;
receiveAuthorData(authorMap);
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
} else if (obj.type === 'NEW_SAVEDREV') {
const savedRev = obj.savedRev;
BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev);
const goToRevision = (newRevision) => {
padContents.targetRevision = newRevision;
const path = window.revisionInfo.getPath(padContents.currentRevision, newRevision);
hooks.aCallAll('goToRevisionEvent', {
rev: newRevision,
});
if (path.status === 'complete') {
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset)
applyChangeset(changeset, path.rev, true, timeDelta);
}
hooks.callAll(`handleClientTimesliderMessage_${obj.type}`, {payload: obj});
} else if (obj.type === 'CHANGESET_REQ') {
this.handleSocketResponse(obj);
} else {
debugLog(`Unknown message type: ${obj.type}`);
}
},
};
// to start upon window load, just push a function onto this array
// window['onloadFuncts'].push(setUpSocket);
// window['onloadFuncts'].push(function ()
fireWhenAllScriptsAreLoaded.push(() => {
// set up the currentDivs and DOM
padContents.currentDivs = [];
$('#innerdocbody').html('');
for (let i = 0; i < padContents.currentLines.length; i++) {
const div = padContents.lineToElement(padContents.currentLines[i], padContents.alines[i]);
padContents.currentDivs.push(div);
$('#innerdocbody').append(div);
}
});
// this is necessary to keep infinite loops of events firing,
// since goToRevision changes the slider position
const goToRevisionIfEnabled = (...args) => {
if (goToRevisionIfEnabledCount > 0) {
goToRevisionIfEnabledCount--;
} else {
goToRevision(...args);
}
};
BroadcastSlider.onSlider(goToRevisionIfEnabled);
const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet);
const authorData = {};
const receiveAuthorData = (newAuthorData) => {
for (const [author, data] of Object.entries(newAuthorData)) {
const bgcolor = typeof data.colorId === 'number'
? clientVars.colorPalette[data.colorId] : data.colorId;
if (bgcolor) {
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);
selector.backgroundColor = bgcolor;
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)
? '#ffffff' : '#000000'; // see ace2_inner.js for the other part
}
authorData[author] = data;
}
};
receiveAuthorData(clientVars.collab_client_vars.historicalAuthorData);
return changesetLoader;
else if (path.status === 'partial') {
// callback is called after changeset information is pulled from server
// this may never get called, if the changeset has already been loaded
const update = (start, end) => {
// if we've called goToRevision in the time since, don't goToRevision
goToRevision(padContents.targetRevision);
};
// do our best with what we have...
const cs = path.changesets;
let changeset = cs[0];
let timeDelta = path.times[0];
for (let i = 1; i < cs.length; i++) {
changeset = Changeset.compose(changeset, cs[i], padContents.apool);
timeDelta += path.times[i];
}
if (changeset)
applyChangeset(changeset, path.rev, true, timeDelta);
// Loading changeset history for new revision
loadChangesetsForRevision(newRevision, update);
// Loading changeset history for old revision (to make diff between old and new revision)
loadChangesetsForRevision(padContents.currentRevision - 1);
}
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
};
const loadChangesetsForRevision = (revision, callback) => {
if (BroadcastSlider.getSliderLength() > 10000) {
const start = (Math.floor((revision) / 10000) * 10000); // revision 0 to 10
changesetLoader.queueUp(start, 100);
}
if (BroadcastSlider.getSliderLength() > 1000) {
const start = (Math.floor((revision) / 1000) * 1000); // (start from -1, go to 19) + 1
changesetLoader.queueUp(start, 10);
}
const start = (Math.floor((revision) / 100) * 100);
changesetLoader.queueUp(start, 1, callback);
};
changesetLoader = {
running: false,
resolved: [],
requestQueue1: [],
requestQueue2: [],
requestQueue3: [],
reqCallbacks: [],
queueUp(revision, width, callback) {
if (revision < 0)
revision = 0;
// if(this.requestQueue.indexOf(revision) != -1)
// return; // already in the queue.
if (this.resolved.indexOf(`${revision}_${width}`) !== -1) {
// already loaded from the server
return;
}
this.resolved.push(`${revision}_${width}`);
const requestQueue = width === 1 ? this.requestQueue3
: width === 10 ? this.requestQueue2
: this.requestQueue1;
requestQueue.push({
rev: revision,
res: width,
callback,
});
if (!this.running) {
this.running = true;
setTimeout(() => this.loadFromQueue(), 10);
}
},
loadFromQueue() {
const requestQueue = this.requestQueue1.length > 0 ? this.requestQueue1
: this.requestQueue2.length > 0 ? this.requestQueue2
: this.requestQueue3.length > 0 ? this.requestQueue3
: null;
if (!requestQueue) {
this.running = false;
return;
}
const request = requestQueue.pop();
const granularity = request.res;
const callback = request.callback;
const start = request.rev;
const requestID = Math.floor(Math.random() * 100000);
sendSocketMsg('CHANGESET_REQ', {
start,
granularity,
requestID,
});
this.reqCallbacks[requestID] = callback;
},
handleSocketResponse(message) {
const start = message.data.start;
const granularity = message.data.granularity;
const callback = this.reqCallbacks[message.data.requestID];
delete this.reqCallbacks[message.data.requestID];
this.handleResponse(message.data, start, granularity, callback);
setTimeout(() => this.loadFromQueue(), 10);
},
handleResponse: (data, start, granularity, callback) => {
const pool = (new AttribPool()).fromJsonable(data.apool);
for (let i = 0; i < data.forwardsChangesets.length; i++) {
const astart = start + i * granularity - 1; // rev -1 is a blank single line
let aend = start + (i + 1) * granularity - 1; // totalRevs is the most recent revision
if (aend > data.actualEndNum - 1)
aend = data.actualEndNum - 1;
// debugLog("adding changeset:", astart, aend);
const forwardcs = Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool);
const backwardcs = Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool);
window.revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]);
}
if (callback)
callback(start - 1, start + data.forwardsChangesets.length * granularity - 1);
},
handleMessageFromServer(obj) {
if (obj.type === 'COLLABROOM') {
obj = obj.data;
if (obj.type === 'NEW_CHANGES') {
const changeset = Changeset.moveOpsToNewPool(obj.changeset, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
let changesetBack = Changeset.inverse(obj.changeset, padContents.currentLines, padContents.alines, padContents.apool);
changesetBack = Changeset.moveOpsToNewPool(changesetBack, (new AttribPool()).fromJsonable(obj.apool), padContents.apool);
loadedNewChangeset(changeset, changesetBack, obj.newRev - 1, obj.timeDelta);
}
else if (obj.type === 'NEW_AUTHORDATA') {
const authorMap = {};
authorMap[obj.author] = obj.data;
receiveAuthorData(authorMap);
const authors = _.map(padContents.getActiveAuthors(), (name) => authorData[name]);
BroadcastSlider.setAuthors(authors);
}
else if (obj.type === 'NEW_SAVEDREV') {
const savedRev = obj.savedRev;
BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev);
}
hooks.callAll(`handleClientTimesliderMessage_${obj.type}`, { payload: obj });
}
else if (obj.type === 'CHANGESET_REQ') {
this.handleSocketResponse(obj);
}
else {
debugLog(`Unknown message type: ${obj.type}`);
}
},
};
// to start upon window load, just push a function onto this array
// window['onloadFuncts'].push(setUpSocket);
// window['onloadFuncts'].push(function ()
fireWhenAllScriptsAreLoaded.push(() => {
// set up the currentDivs and DOM
padContents.currentDivs = [];
$('#innerdocbody').html('');
for (let i = 0; i < padContents.currentLines.length; i++) {
const div = padContents.lineToElement(padContents.currentLines[i], padContents.alines[i]);
padContents.currentDivs.push(div);
$('#innerdocbody').append(div);
}
});
// this is necessary to keep infinite loops of events firing,
// since goToRevision changes the slider position
const goToRevisionIfEnabled = (...args) => {
if (goToRevisionIfEnabledCount > 0) {
goToRevisionIfEnabledCount--;
}
else {
goToRevision(...args);
}
};
BroadcastSlider.onSlider(goToRevisionIfEnabled);
const dynamicCSS = makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet);
const authorData = {};
const receiveAuthorData = (newAuthorData) => {
for (const [author, data] of Object.entries(newAuthorData)) {
const bgcolor = typeof data.colorId === 'number'
? clientVars.colorPalette[data.colorId] : data.colorId;
if (bgcolor) {
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);
selector.backgroundColor = bgcolor;
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)
? '#ffffff' : '#000000'; // see ace2_inner.js for the other part
}
authorData[author] = data;
}
};
receiveAuthorData(clientVars.collab_client_vars.historicalAuthorData);
return changesetLoader;
};
exports.loadBroadcastJS = loadBroadcastJS;
export { loadBroadcastJS };

View file

@ -1,11 +1,9 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -24,92 +22,79 @@
// revision info is a skip list whos entries represent a particular revision
// of the document. These revisions are connected together by various
// changesets, or deltas, between any two revisions.
const loadBroadcastRevisionsJS = () => {
function Revision(revNum) {
this.rev = revNum;
this.changesets = [];
}
Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) {
const changesetWrapper = {
deltaRev: destIndex - this.rev,
deltaTime: timeDelta,
getValue: () => changeset,
};
this.changesets.push(changesetWrapper);
this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));
};
const revisionInfo = {};
revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {
const startRevision = this[fromIndex] || this.createNew(fromIndex);
const endRevision = this[toIndex] || this.createNew(toIndex);
startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
};
revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
revisionInfo.createNew = function (index) {
this[index] = new Revision(index);
if (index > this.latest) {
this.latest = index;
function Revision(revNum) {
this.rev = revNum;
this.changesets = [];
}
return this[index];
};
// assuming that there is a path from fromIndex to toIndex, and that the links
// are laid out in a skip-list format
revisionInfo.getPath = function (fromIndex, toIndex) {
const changesets = [];
const spans = [];
const times = [];
let elem = this[fromIndex] || this.createNew(fromIndex);
if (elem.changesets.length !== 0 && fromIndex !== toIndex) {
const reverse = !(fromIndex < toIndex);
while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) {
let couldNotContinue = false;
const oldRev = elem.rev;
for (let i = reverse ? elem.changesets.length - 1 : 0;
reverse ? i >= 0 : i < elem.changesets.length;
i += reverse ? -1 : 1) {
if (((elem.changesets[i].deltaRev < 0) && !reverse) ||
((elem.changesets[i].deltaRev > 0) && reverse)) {
couldNotContinue = true;
break;
}
if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||
((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {
const topush = elem.changesets[i];
changesets.push(topush.getValue());
spans.push(elem.changesets[i].deltaRev);
times.push(topush.deltaTime);
elem = this[elem.rev + elem.changesets[i].deltaRev];
break;
}
Revision.prototype.addChangeset = function (destIndex, changeset, timeDelta) {
const changesetWrapper = {
deltaRev: destIndex - this.rev,
deltaTime: timeDelta,
getValue: () => changeset,
};
this.changesets.push(changesetWrapper);
this.changesets.sort((a, b) => (b.deltaRev - a.deltaRev));
};
const revisionInfo = {};
revisionInfo.addChangeset = function (fromIndex, toIndex, changeset, backChangeset, timeDelta) {
const startRevision = this[fromIndex] || this.createNew(fromIndex);
const endRevision = this[toIndex] || this.createNew(toIndex);
startRevision.addChangeset(toIndex, changeset, timeDelta);
endRevision.addChangeset(fromIndex, backChangeset, -1 * timeDelta);
};
revisionInfo.latest = clientVars.collab_client_vars.rev || -1;
revisionInfo.createNew = function (index) {
this[index] = new Revision(index);
if (index > this.latest) {
this.latest = index;
}
if (couldNotContinue || oldRev === elem.rev) break;
}
}
let status = 'partial';
if (elem.rev === toIndex) status = 'complete';
return {
fromRev: fromIndex,
rev: elem.rev,
status,
changesets,
spans,
times,
return this[index];
};
};
window.revisionInfo = revisionInfo;
// assuming that there is a path from fromIndex to toIndex, and that the links
// are laid out in a skip-list format
revisionInfo.getPath = function (fromIndex, toIndex) {
const changesets = [];
const spans = [];
const times = [];
let elem = this[fromIndex] || this.createNew(fromIndex);
if (elem.changesets.length !== 0 && fromIndex !== toIndex) {
const reverse = !(fromIndex < toIndex);
while (((elem.rev < toIndex) && !reverse) || ((elem.rev > toIndex) && reverse)) {
let couldNotContinue = false;
const oldRev = elem.rev;
for (let i = reverse ? elem.changesets.length - 1 : 0; reverse ? i >= 0 : i < elem.changesets.length; i += reverse ? -1 : 1) {
if (((elem.changesets[i].deltaRev < 0) && !reverse) ||
((elem.changesets[i].deltaRev > 0) && reverse)) {
couldNotContinue = true;
break;
}
if (((elem.rev + elem.changesets[i].deltaRev <= toIndex) && !reverse) ||
((elem.rev + elem.changesets[i].deltaRev >= toIndex) && reverse)) {
const topush = elem.changesets[i];
changesets.push(topush.getValue());
spans.push(elem.changesets[i].deltaRev);
times.push(topush.deltaTime);
elem = this[elem.rev + elem.changesets[i].deltaRev];
break;
}
}
if (couldNotContinue || oldRev === elem.rev)
break;
}
}
let status = 'partial';
if (elem.rev === toIndex)
status = 'complete';
return {
fromRev: fromIndex,
rev: elem.rev,
status,
changesets,
spans,
times,
};
};
window.revisionInfo = revisionInfo;
};
exports.loadBroadcastRevisionsJS = loadBroadcastRevisionsJS;
export { loadBroadcastRevisionsJS };

View file

@ -1,342 +1,293 @@
import * as _ from "./underscore.js";
import { padmodals as padmodals$0 } from "./pad_modals.js";
import { colorutils as colorutils$0 } from "./colorutils.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.
*/
// These parameters were global, now they are injected. A reference to the
// Timeslider controller would probably be more appropriate.
const _ = require('./underscore');
const padmodals = require('./pad_modals').padmodals;
const colorutils = require('./colorutils').colorutils;
const padmodals = { padmodals: padmodals$0 }.padmodals;
const colorutils = { colorutils: colorutils$0 }.colorutils;
const loadBroadcastSliderJS = (fireWhenAllScriptsAreLoaded) => {
let BroadcastSlider;
// Hack to ensure timeslider i18n values are in
$("[data-key='timeslider_returnToPad'] > a > span").html(
html10n.get('timeslider.toolbar.returnbutton'));
(() => { // wrap this code in its own namespace
let sliderLength = 1000;
let sliderPos = 0;
let sliderActive = false;
const slidercallbacks = [];
const savedRevisions = [];
let sliderPlaying = false;
const _callSliderCallbacks = (newval) => {
sliderPos = newval;
for (let i = 0; i < slidercallbacks.length; i++) {
slidercallbacks[i](newval);
}
};
const updateSliderElements = () => {
for (let i = 0; i < savedRevisions.length; i++) {
const position = parseInt(savedRevisions[i].attr('pos'));
savedRevisions[i].css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css(
'left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
};
const addSavedRevision = (position, info) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css(
'left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.mouseup((evt) => {
BroadcastSlider.setSliderPosition(position);
});
savedRevisions.push(newSavedRevision);
};
/* Begin small 'API' */
const onSlider = (callback) => {
slidercallbacks.push(callback);
};
const getSliderPosition = () => sliderPos;
const setSliderPosition = (newpos) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > sliderLength) return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css(
'left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));
});
$('#revision_label').html(html10n.get('timeslider.version', {version: newpos}));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);
sliderPos = newpos;
_callSliderCallbacks(newpos);
};
const getSliderLength = () => sliderLength;
const setSliderLength = (newlength) => {
sliderLength = newlength;
updateSliderElements();
};
// just take over the whole slider screen with a reconnect message
const showReconnectUI = () => {
padmodals.showModal('disconnected');
};
const setAuthors = (authors) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous = [];
_.each(authors, (author) => {
if (author) {
const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0) authorsList.append(', ');
const textColor =
colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
} else {
numAnonymous++;
if (authorColor) colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', {num: numAnonymous});
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
} else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color, i) => {
if (i > 0) authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
};
const playButtonUpdater = () => {
if (sliderPlaying) {
if (getSliderPosition() + 1 > sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
sliderPlaying = false;
return;
}
setSliderPosition(getSliderPosition() + 1);
setTimeout(playButtonUpdater, 100);
}
};
const playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!sliderPlaying) {
if (getSliderPosition() === sliderLength) setSliderPosition(0);
sliderPlaying = true;
playButtonUpdater();
} else {
sliderPlaying = false;
}
};
BroadcastSlider = {
onSlider,
getSliderPosition,
setSliderPosition,
getSliderLength,
setSliderLength,
isSliderActive: () => sliderActive,
playpause,
addSavedRevision,
showReconnectUI,
setAuthors,
};
// assign event handlers to html UI elements after page load
fireWhenAllScriptsAreLoaded.push(() => {
$(document).keyup((e) => {
if (!e) e = window.event;
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').click();
} else {
$('#leftstep').click();
}
} else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').click();
} else {
$('#rightstep').click();
}
} else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).resize(() => {
updateSliderElements();
});
// Slider click
$('#ui-slider-bar').mousedown((evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').mousedown(function (evt) {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
sliderActive = true;
$(document).mousemove((evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
const version = Math.floor(newloc * sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', {version}));
$(this).css('left', newloc);
if (getSliderPosition() !== version) _callSliderCallbacks(version);
});
$(document).mouseup((evt2) => {
$(document).unbind('mousemove');
$(document).unbind('mouseup');
sliderActive = false;
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0) newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos) newloc = maxPos;
$(this).css('left', newloc);
setSliderPosition(Math.floor(newloc * sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
} else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').click((evt) => {
BroadcastSlider.playpause();
});
// next/prev saved revision and changeset
$('.stepper').click(function (evt) {
switch ($(this).attr('id')) {
case 'leftstep':
setSliderPosition(getSliderPosition() - 1);
break;
case 'rightstep':
setSliderPosition(getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos < getSliderPosition() && nextStar < pos) nextStar = pos;
let BroadcastSlider;
// Hack to ensure timeslider i18n values are in
$("[data-key='timeslider_returnToPad'] > a > span").html(html10n.get('timeslider.toolbar.returnbutton'));
(() => {
let sliderLength = 1000;
let sliderPos = 0;
let sliderActive = false;
const slidercallbacks = [];
const savedRevisions = [];
let sliderPlaying = false;
const _callSliderCallbacks = (newval) => {
sliderPos = newval;
for (let i = 0; i < slidercallbacks.length; i++) {
slidercallbacks[i](newval);
}
setSliderPosition(nextStar);
break;
}
case 'rightstar': {
let nextStar = sliderLength; // default to last revision in document
};
const updateSliderElements = () => {
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos > getSliderPosition() && nextStar > pos) nextStar = pos;
const position = parseInt(savedRevisions[i].attr('pos'));
savedRevisions[i].css('left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
}
$('#ui-slider-handle').css('left', sliderPos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
};
const addSavedRevision = (position, info) => {
const newSavedRevision = $('<div></div>');
newSavedRevision.addClass('star');
newSavedRevision.attr('pos', position);
newSavedRevision.css('left', (position * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0)) - 1);
$('#ui-slider-bar').append(newSavedRevision);
newSavedRevision.mouseup((evt) => {
BroadcastSlider.setSliderPosition(position);
});
savedRevisions.push(newSavedRevision);
};
/* Begin small 'API' */
const onSlider = (callback) => {
slidercallbacks.push(callback);
};
const getSliderPosition = () => sliderPos;
const setSliderPosition = (newpos) => {
newpos = Number(newpos);
if (newpos < 0 || newpos > sliderLength)
return;
if (!newpos) {
newpos = 0; // stops it from displaying NaN if newpos isn't set
}
window.location.hash = `#${newpos}`;
$('#ui-slider-handle').css('left', newpos * ($('#ui-slider-bar').width() - 2) / (sliderLength * 1.0));
$('a.tlink').map(function () {
$(this).attr('href', $(this).attr('thref').replace('%revision%', newpos));
});
$('#revision_label').html(html10n.get('timeslider.version', { version: newpos }));
$('#leftstar, #leftstep').toggleClass('disabled', newpos === 0);
$('#rightstar, #rightstep').toggleClass('disabled', newpos === sliderLength);
sliderPos = newpos;
_callSliderCallbacks(newpos);
};
const getSliderLength = () => sliderLength;
const setSliderLength = (newlength) => {
sliderLength = newlength;
updateSliderElements();
};
// just take over the whole slider screen with a reconnect message
const showReconnectUI = () => {
padmodals.showModal('disconnected');
};
const setAuthors = (authors) => {
const authorsList = $('#authorsList');
authorsList.empty();
let numAnonymous = 0;
let numNamed = 0;
const colorsAnonymous = [];
_.each(authors, (author) => {
if (author) {
const authorColor = clientVars.colorPalette[author.colorId] || author.colorId;
if (author.name) {
if (numNamed !== 0)
authorsList.append(', ');
const textColor = colorutils.textColorFromBackgroundColor(authorColor, clientVars.skinName);
$('<span />')
.text(author.name || 'unnamed')
.css('background-color', authorColor)
.css('color', textColor)
.addClass('author')
.appendTo(authorsList);
numNamed++;
}
else {
numAnonymous++;
if (authorColor)
colorsAnonymous.push(authorColor);
}
}
});
if (numAnonymous > 0) {
const anonymousAuthorString = html10n.get('timeslider.unnamedauthors', { num: numAnonymous });
if (numNamed !== 0) {
authorsList.append(` + ${anonymousAuthorString}`);
}
else {
authorsList.append(anonymousAuthorString);
}
if (colorsAnonymous.length > 0) {
authorsList.append(' (');
_.each(colorsAnonymous, (color, i) => {
if (i > 0)
authorsList.append(' ');
$('<span>&nbsp;</span>')
.css('background-color', color)
.addClass('author author-anonymous')
.appendTo(authorsList);
});
authorsList.append(')');
}
}
if (authors.length === 0) {
authorsList.append(html10n.get('timeslider.toolbar.authorsList'));
}
};
const playButtonUpdater = () => {
if (sliderPlaying) {
if (getSliderPosition() + 1 > sliderLength) {
$('#playpause_button_icon').toggleClass('pause');
sliderPlaying = false;
return;
}
setSliderPosition(getSliderPosition() + 1);
setTimeout(playButtonUpdater, 100);
}
};
const playpause = () => {
$('#playpause_button_icon').toggleClass('pause');
if (!sliderPlaying) {
if (getSliderPosition() === sliderLength)
setSliderPosition(0);
sliderPlaying = true;
playButtonUpdater();
}
else {
sliderPlaying = false;
}
};
BroadcastSlider = {
onSlider,
getSliderPosition,
setSliderPosition,
getSliderLength,
setSliderLength,
isSliderActive: () => sliderActive,
playpause,
addSavedRevision,
showReconnectUI,
setAuthors,
};
// assign event handlers to html UI elements after page load
fireWhenAllScriptsAreLoaded.push(() => {
$(document).keyup((e) => {
if (!e)
e = window.event;
const code = e.keyCode || e.which;
if (code === 37) { // left
if (e.shiftKey) {
$('#leftstar').click();
}
else {
$('#leftstep').click();
}
}
else if (code === 39) { // right
if (e.shiftKey) {
$('#rightstar').click();
}
else {
$('#rightstep').click();
}
}
else if (code === 32) { // spacebar
$('#playpause_button_icon').trigger('click');
}
});
// Resize
$(window).resize(() => {
updateSliderElements();
});
// Slider click
$('#ui-slider-bar').mousedown((evt) => {
$('#ui-slider-handle').css('left', (evt.clientX - $('#ui-slider-bar').offset().left));
$('#ui-slider-handle').trigger(evt);
});
// Slider dragging
$('#ui-slider-handle').mousedown(function (evt) {
this.startLoc = evt.clientX;
this.currentLoc = parseInt($(this).css('left'));
sliderActive = true;
$(document).mousemove((evt2) => {
$(this).css('pointer', 'move');
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0)
newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos)
newloc = maxPos;
const version = Math.floor(newloc * sliderLength / maxPos);
$('#revision_label').html(html10n.get('timeslider.version', { version }));
$(this).css('left', newloc);
if (getSliderPosition() !== version)
_callSliderCallbacks(version);
});
$(document).mouseup((evt2) => {
$(document).unbind('mousemove');
$(document).unbind('mouseup');
sliderActive = false;
let newloc = this.currentLoc + (evt2.clientX - this.startLoc);
if (newloc < 0)
newloc = 0;
const maxPos = $('#ui-slider-bar').width() - 2;
if (newloc > maxPos)
newloc = maxPos;
$(this).css('left', newloc);
setSliderPosition(Math.floor(newloc * sliderLength / maxPos));
if (parseInt($(this).css('left')) < 2) {
$(this).css('left', '2px');
}
else {
this.currentLoc = parseInt($(this).css('left'));
}
});
});
// play/pause toggling
$('#playpause_button_icon').click((evt) => {
BroadcastSlider.playpause();
});
// next/prev saved revision and changeset
$('.stepper').click(function (evt) {
switch ($(this).attr('id')) {
case 'leftstep':
setSliderPosition(getSliderPosition() - 1);
break;
case 'rightstep':
setSliderPosition(getSliderPosition() + 1);
break;
case 'leftstar': {
let nextStar = 0; // default to first revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos < getSliderPosition() && nextStar < pos)
nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
case 'rightstar': {
let nextStar = sliderLength; // default to last revision in document
for (let i = 0; i < savedRevisions.length; i++) {
const pos = parseInt(savedRevisions[i].attr('pos'));
if (pos > getSliderPosition() && nextStar > pos)
nextStar = pos;
}
setSliderPosition(nextStar);
break;
}
}
});
if (clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { setSliderPosition(hashRev); }, 1);
}
}
setSliderLength(clientVars.collab_client_vars.rev);
setSliderPosition(clientVars.collab_client_vars.rev);
_.each(clientVars.savedRevisions, (revision) => {
addSavedRevision(revision.revNum, revision);
});
}
setSliderPosition(nextStar);
break;
}
}
});
if (clientVars) {
$('#timeslider-wrapper').show();
if (window.location.hash.length > 1) {
const hashRev = Number(window.location.hash.substr(1));
if (!isNaN(hashRev)) {
// this is necessary because of the socket.io-event which loads the changesets
setTimeout(() => { setSliderPosition(hashRev); }, 1);
}
}
setSliderLength(clientVars.collab_client_vars.rev);
setSliderPosition(clientVars.collab_client_vars.rev);
_.each(clientVars.savedRevisions, (revision) => {
addSavedRevision(revision.revNum, revision);
});
}
})();
BroadcastSlider.onSlider((loc) => {
$('#viewlatest').html(`${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);
});
})();
BroadcastSlider.onSlider((loc) => {
$('#viewlatest').html(
`${loc === BroadcastSlider.getSliderLength() ? 'Viewing' : 'View'} latest content`);
});
return BroadcastSlider;
return BroadcastSlider;
};
exports.loadBroadcastSliderJS = loadBroadcastSliderJS;
export { loadBroadcastSliderJS };

View file

@ -1,203 +1,165 @@
'use strict';
// One rep.line(div) can be broken in more than one line in the browser.
// This function is useful to get the caret position of the line as
// is represented by the browser
exports.getPosition = () => {
const range = getSelectionRange();
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
const createSelectionRange = (range) => {
const clonedRange = range.cloneRange();
// we set the selection start and end to avoid error when user selects a text bigger than
// the viewport height and uses the arrow keys to expand the selection. In this particular
// case is necessary to know where the selections ends because both edges of the selection
// is out of the viewport but we only use the end of it to calculate if it needs to scroll
clonedRange.setStart(range.endContainer, range.endOffset);
clonedRange.setEnd(range.endContainer, range.endOffset);
return clonedRange;
const clonedRange = range.cloneRange();
// we set the selection start and end to avoid error when user selects a text bigger than
// the viewport height and uses the arrow keys to expand the selection. In this particular
// case is necessary to know where the selections ends because both edges of the selection
// is out of the viewport but we only use the end of it to calculate if it needs to scroll
clonedRange.setStart(range.endContainer, range.endOffset);
clonedRange.setEnd(range.endContainer, range.endOffset);
return clonedRange;
};
const getPositionOfRepLineAtOffset = (node, offset) => {
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
}
while (node.length === 0 && node.nextSibling) {
node = node.nextSibling;
}
const newRange = new Range();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
const linePosition = getPositionOfElementOrSelection(newRange);
newRange.detach(); // performance sake
return linePosition;
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
}
while (node.length === 0 && node.nextSibling) {
node = node.nextSibling;
}
const newRange = new Range();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
const linePosition = getPositionOfElementOrSelection(newRange);
newRange.detach(); // performance sake
return linePosition;
};
const getPositionOfElementOrSelection = (element) => {
const rect = element.getBoundingClientRect();
const linePosition = {
bottom: rect.bottom,
height: rect.height,
top: rect.top,
};
return linePosition;
const rect = element.getBoundingClientRect();
const linePosition = {
bottom: rect.bottom,
height: rect.height,
top: rect.top,
};
return linePosition;
};
// here we have two possibilities:
// [1] the line before the caret line has the same type, so both of them has the same margin,
// padding height, etc. So, we can use the caret line to make calculation necessary to know
// where is the top of the previous line
// [2] the line before is part of another rep line. It's possible this line has different margins
// height. So we have to get the exactly position of the line
exports.getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
const caretLineIsFirstBrowserLine = (caretLineTop, rep) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);
// to get the position of the node we get the position of the first char
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
return positionOfFirstRootNode.top === caretLineTop;
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);
// to get the position of the node we get the position of the first char
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
return positionOfFirstRootNode.top === caretLineTop;
};
// find the first root node, usually it is a text node
const getFirstRootChildNode = (node) => {
if (!node.firstChild) {
return node;
} else {
return getFirstRootChildNode(node.firstChild);
}
if (!node.firstChild) {
return node;
}
else {
return getFirstRootChildNode(node.firstChild);
}
};
const getDimensionOfLastBrowserLineOfRepLine = (line, rep) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we get the position of the line in the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition;
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we get the position of the line in the last char of it
const lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition;
};
const getLastRootChildNode = (node) => {
if (!node.lastChild) {
return {
node,
length: node.length,
};
} else {
return getLastRootChildNode(node.lastChild);
}
if (!node.lastChild) {
return {
node,
length: node.length,
};
}
else {
return getLastRootChildNode(node.lastChild);
}
};
// here we have two possibilities:
// [1] The next line is part of the same rep line of the caret line, so we have the same dimensions.
// So, we can use the caret line to calculate the bottom of the line.
// [2] the next line is part of another rep line.
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
exports.getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we take a rep line and get the position of the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition.top === caretLineTop;
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we take a rep line and get the position of the last char of it
const lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
return lastRootChildNodePosition.top === caretLineTop;
};
const getPreviousVisibleLine = (line, rep) => {
const firstLineOfPad = 0;
if (line <= firstLineOfPad) {
return firstLineOfPad;
} else if (isLineVisible(line, rep)) {
return line;
} else {
return getPreviousVisibleLine(line - 1, rep);
}
const firstLineOfPad = 0;
if (line <= firstLineOfPad) {
return firstLineOfPad;
}
else if (isLineVisible(line, rep)) {
return line;
}
else {
return getPreviousVisibleLine(line - 1, rep);
}
};
exports.getPreviousVisibleLine = getPreviousVisibleLine;
const getNextVisibleLine = (line, rep) => {
const lastLineOfThePad = rep.lines.length() - 1;
if (line >= lastLineOfThePad) {
return lastLineOfThePad;
} else if (isLineVisible(line, rep)) {
return line;
} else {
return getNextVisibleLine(line + 1, rep);
}
const lastLineOfThePad = rep.lines.length() - 1;
if (line >= lastLineOfThePad) {
return lastLineOfThePad;
}
else if (isLineVisible(line, rep)) {
return line;
}
else {
return getNextVisibleLine(line + 1, rep);
}
};
exports.getNextVisibleLine = getNextVisibleLine;
const isLineVisible = (line, rep) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
const getDimensionOfFirstBrowserLineOfRepLine = (line, rep) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);
// we can get the position of the line, getting the position of the first char of the rep line
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
return firstRootChildNodePosition;
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);
// we can get the position of the line, getting the position of the first char of the rep line
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
return firstRootChildNodePosition;
};
const getSelectionRange = () => {
if (!window.getSelection) {
return;
}
const selection = window.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0);
} else {
return null;
}
if (!window.getSelection) {
return;
}
const selection = window.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0);
}
else {
return null;
}
};
export const getPosition = () => {
const range = getSelectionRange();
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody')
return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition = getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
export const getBottomOfNextBrowserLine = (caretLinePosition, rep) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine = caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition = getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
export { getPreviousVisibleLine };
export { getNextVisibleLine };

View file

@ -1,203 +1,171 @@
import AttributeMap from "./AttributeMap.js";
import AttributePool from "./AttributePool.js";
import * as Changeset from "./Changeset.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.
*/
const AttributeMap = require('./AttributeMap');
const AttributePool = require('./AttributePool');
const Changeset = require('./Changeset');
const makeChangesetTracker = (scheduler, apool, aceCallbacksProvider) => {
// latest official text from server
let baseAText = Changeset.makeAText('\n');
// changes applied to baseText that have been submitted
let submittedChangeset = null;
// changes applied to submittedChangeset since it was prepared
let userChangeset = Changeset.identity(1);
// is the changesetTracker enabled
let tracking = false;
// stack state flag so that when we change the rep we don't
// handle the notification recursively. When setting, always
// unset in a "finally" block. When set to true, the setter
// takes change of userChangeset.
let applyingNonUserChanges = false;
let changeCallback = null;
let changeCallbackTimeout = null;
const setChangeCallbackTimeout = () => {
// can call this multiple times per call-stack, because
// we only schedule a call to changeCallback if it exists
// and if there isn't a timeout already scheduled.
if (changeCallback && changeCallbackTimeout == null) {
changeCallbackTimeout = scheduler.setTimeout(() => {
try {
changeCallback();
} catch (pseudoError) {
// as empty as my soul
} finally {
changeCallbackTimeout = null;
// latest official text from server
let baseAText = Changeset.makeAText('\n');
// changes applied to baseText that have been submitted
let submittedChangeset = null;
// changes applied to submittedChangeset since it was prepared
let userChangeset = Changeset.identity(1);
// is the changesetTracker enabled
let tracking = false;
// stack state flag so that when we change the rep we don't
// handle the notification recursively. When setting, always
// unset in a "finally" block. When set to true, the setter
// takes change of userChangeset.
let applyingNonUserChanges = false;
let changeCallback = null;
let changeCallbackTimeout = null;
const setChangeCallbackTimeout = () => {
// can call this multiple times per call-stack, because
// we only schedule a call to changeCallback if it exists
// and if there isn't a timeout already scheduled.
if (changeCallback && changeCallbackTimeout == null) {
changeCallbackTimeout = scheduler.setTimeout(() => {
try {
changeCallback();
}
catch (pseudoError) {
// as empty as my soul
}
finally {
changeCallbackTimeout = null;
}
}, 0);
}
}, 0);
}
};
let self;
return self = {
isTracking: () => tracking,
setBaseText: (text) => {
self.setBaseAttributedText(Changeset.makeAText(text), null);
},
setBaseAttributedText: (atext, apoolJsonObj) => {
aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => {
tracking = true;
baseAText = Changeset.cloneAText(atext);
if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool);
}
submittedChangeset = null;
userChangeset = Changeset.identity(atext.text.length);
applyingNonUserChanges = true;
try {
callbacks.setDocumentAttributedText(atext);
} finally {
applyingNonUserChanges = false;
}
});
},
composeUserChangeset: (c) => {
if (!tracking) return;
if (applyingNonUserChanges) return;
if (Changeset.isIdentity(c)) return;
userChangeset = Changeset.compose(userChangeset, c, apool);
setChangeCallbackTimeout();
},
applyChangesToBase: (c, optAuthor, apoolJsonObj) => {
if (!tracking) return;
aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => {
if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
c = Changeset.moveOpsToNewPool(c, wireApool, apool);
}
baseAText = Changeset.applyToAText(c, baseAText, apool);
let c2 = c;
if (submittedChangeset) {
const oldSubmittedChangeset = submittedChangeset;
submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool);
c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool);
}
const preferInsertingAfterUserChanges = true;
const oldUserChangeset = userChangeset;
userChangeset = Changeset.follow(
c2, oldUserChangeset, preferInsertingAfterUserChanges, apool);
const postChange = Changeset.follow(
oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool);
const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor);
applyingNonUserChanges = true;
try {
callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret);
} finally {
applyingNonUserChanges = false;
}
});
},
prepareUserChangeset: () => {
// If there are user changes to submit, 'changeset' will be the
// changeset, else it will be null.
let toSubmit;
if (submittedChangeset) {
// submission must have been canceled, prepare new changeset
// that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
} else {
// Get my authorID
const authorId = parent.parent.pad.myUserInfo.userId;
// Sanitize authorship: Replace all author attributes with this user's author ID in case the
// text was copied from another author.
const cs = Changeset.unpack(userChangeset);
const assem = Changeset.mergingOpAssembler();
for (const op of Changeset.deserializeOps(cs.ops)) {
if (op.opcode === '+') {
const attribs = AttributeMap.fromString(op.attribs, apool);
const oldAuthorId = attribs.get('author');
if (oldAuthorId != null && oldAuthorId !== authorId) {
attribs.set('author', authorId);
op.attribs = attribs.toString();
};
let self;
return self = {
isTracking: () => tracking,
setBaseText: (text) => {
self.setBaseAttributedText(Changeset.makeAText(text), null);
},
setBaseAttributedText: (atext, apoolJsonObj) => {
aceCallbacksProvider.withCallbacks('setBaseText', (callbacks) => {
tracking = true;
baseAText = Changeset.cloneAText(atext);
if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
baseAText.attribs = Changeset.moveOpsToNewPool(baseAText.attribs, wireApool, apool);
}
submittedChangeset = null;
userChangeset = Changeset.identity(atext.text.length);
applyingNonUserChanges = true;
try {
callbacks.setDocumentAttributedText(atext);
}
finally {
applyingNonUserChanges = false;
}
});
},
composeUserChangeset: (c) => {
if (!tracking)
return;
if (applyingNonUserChanges)
return;
if (Changeset.isIdentity(c))
return;
userChangeset = Changeset.compose(userChangeset, c, apool);
setChangeCallbackTimeout();
},
applyChangesToBase: (c, optAuthor, apoolJsonObj) => {
if (!tracking)
return;
aceCallbacksProvider.withCallbacks('applyChangesToBase', (callbacks) => {
if (apoolJsonObj) {
const wireApool = (new AttributePool()).fromJsonable(apoolJsonObj);
c = Changeset.moveOpsToNewPool(c, wireApool, apool);
}
baseAText = Changeset.applyToAText(c, baseAText, apool);
let c2 = c;
if (submittedChangeset) {
const oldSubmittedChangeset = submittedChangeset;
submittedChangeset = Changeset.follow(c, oldSubmittedChangeset, false, apool);
c2 = Changeset.follow(oldSubmittedChangeset, c, true, apool);
}
const preferInsertingAfterUserChanges = true;
const oldUserChangeset = userChangeset;
userChangeset = Changeset.follow(c2, oldUserChangeset, preferInsertingAfterUserChanges, apool);
const postChange = Changeset.follow(oldUserChangeset, c2, !preferInsertingAfterUserChanges, apool);
const preferInsertionAfterCaret = true; // (optAuthor && optAuthor > thisAuthor);
applyingNonUserChanges = true;
try {
callbacks.applyChangesetToDocument(postChange, preferInsertionAfterCaret);
}
finally {
applyingNonUserChanges = false;
}
});
},
prepareUserChangeset: () => {
// If there are user changes to submit, 'changeset' will be the
// changeset, else it will be null.
let toSubmit;
if (submittedChangeset) {
// submission must have been canceled, prepare new changeset
// that includes old submittedChangeset
toSubmit = Changeset.compose(submittedChangeset, userChangeset, apool);
}
}
assem.append(op);
}
assem.endDocument();
userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank);
Changeset.checkRep(userChangeset);
if (Changeset.isIdentity(userChangeset)) toSubmit = null;
else toSubmit = userChangeset;
}
let cs = null;
if (toSubmit) {
submittedChangeset = toSubmit;
userChangeset = Changeset.identity(Changeset.newLen(toSubmit));
cs = toSubmit;
}
let wireApool = null;
if (cs) {
const forWire = Changeset.prepareForWire(cs, apool);
wireApool = forWire.pool.toJsonable();
cs = forWire.translated;
}
const data = {
changeset: cs,
apool: wireApool,
};
return data;
},
applyPreparedChangesetToBase: () => {
if (!submittedChangeset) {
// violation of protocol; use prepareUserChangeset first
throw new Error('applySubmittedChangesToBase: no submitted changes to apply');
}
// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false));
baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool);
submittedChangeset = null;
},
setUserChangeNotificationCallback: (callback) => {
changeCallback = callback;
},
hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))),
};
else {
// Get my authorID
const authorId = parent.parent.pad.myUserInfo.userId;
// Sanitize authorship: Replace all author attributes with this user's author ID in case the
// text was copied from another author.
const cs = Changeset.unpack(userChangeset);
const assem = Changeset.mergingOpAssembler();
for (const op of Changeset.deserializeOps(cs.ops)) {
if (op.opcode === '+') {
const attribs = AttributeMap.fromString(op.attribs, apool);
const oldAuthorId = attribs.get('author');
if (oldAuthorId != null && oldAuthorId !== authorId) {
attribs.set('author', authorId);
op.attribs = attribs.toString();
}
}
assem.append(op);
}
assem.endDocument();
userChangeset = Changeset.pack(cs.oldLen, cs.newLen, assem.toString(), cs.charBank);
Changeset.checkRep(userChangeset);
if (Changeset.isIdentity(userChangeset))
toSubmit = null;
else
toSubmit = userChangeset;
}
let cs = null;
if (toSubmit) {
submittedChangeset = toSubmit;
userChangeset = Changeset.identity(Changeset.newLen(toSubmit));
cs = toSubmit;
}
let wireApool = null;
if (cs) {
const forWire = Changeset.prepareForWire(cs, apool);
wireApool = forWire.pool.toJsonable();
cs = forWire.translated;
}
const data = {
changeset: cs,
apool: wireApool,
};
return data;
},
applyPreparedChangesetToBase: () => {
if (!submittedChangeset) {
// violation of protocol; use prepareUserChangeset first
throw new Error('applySubmittedChangesToBase: no submitted changes to apply');
}
// bumpDebug("applying committed changeset: "+submittedChangeset.encodeToString(false));
baseAText = Changeset.applyToAText(submittedChangeset, baseAText, apool);
submittedChangeset = null;
},
setUserChangeNotificationCallback: (callback) => {
changeCallback = callback;
},
hasUncommittedChanges: () => !!(submittedChangeset || (!Changeset.isIdentity(userChangeset))),
};
};
exports.makeChangesetTracker = makeChangesetTracker;
export { makeChangesetTracker };

View file

@ -1,274 +1,247 @@
import ChatMessage from "./ChatMessage.js";
import { padutils as padutils$0 } from "./pad_utils.js";
import { padcookie as padcookie$0 } from "./pad_cookie.js";
import Tinycon from "tinycon/tinycon";
import * as hooks from "./pluginfw/hooks.js";
import { padeditor as padeditor$0 } from "./pad_editor.js";
'use strict';
/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* 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.
*/
const ChatMessage = require('./ChatMessage');
const padutils = require('./pad_utils').padutils;
const padcookie = require('./pad_cookie').padcookie;
const Tinycon = require('tinycon/tinycon');
const hooks = require('./pluginfw/hooks');
const padeditor = require('./pad_editor').padeditor;
const padutils = { padutils: padutils$0 }.padutils;
const padcookie = { padcookie: padcookie$0 }.padcookie;
const padeditor = { padeditor: padeditor$0 }.padeditor;
// Removes diacritics and lower-cases letters. https://stackoverflow.com/a/37511463
const normalize = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
exports.chat = (() => {
let isStuck = false;
let userAndChat = false;
let chatMentions = 0;
return {
show() {
$('#chaticon').removeClass('visible');
$('#chatbox').addClass('visible');
this.scrollDown(true);
chatMentions = 0;
Tinycon.setBubble(0);
$('.chat-gritter-msg').each(function () {
$.gritter.remove(this.id);
});
},
focus: () => {
setTimeout(() => {
$('#chatinput').focus();
}, 100);
},
// Make chat stick to right hand side of screen
stickToScreen(fromInitialCall) {
if (pad.settings.hideChat) {
return;
}
this.show();
isStuck = (!isStuck || fromInitialCall);
$('#chatbox').hide();
// Add timeout to disable the chatbox animations
setTimeout(() => {
$('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck);
$('#chatbox').css('display', 'flex');
}, 0);
padcookie.setPref('chatAlwaysVisible', isStuck);
$('#options-stickychat').prop('checked', isStuck);
},
chatAndUsers(fromInitialCall) {
const toEnable = $('#options-chatandusers').is(':checked');
if (toEnable || !userAndChat || fromInitialCall) {
this.stickToScreen(true);
$('#options-stickychat').prop('checked', true);
$('#options-chatandusers').prop('checked', true);
$('#options-stickychat').prop('disabled', 'disabled');
userAndChat = true;
} else {
$('#options-stickychat').prop('disabled', false);
userAndChat = false;
}
padcookie.setPref('chatAndUsers', userAndChat);
$('#users, .sticky-container')
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
},
hide() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
} else {
$('#chatcounter').text('0');
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
},
scrollDown(force) {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) {
// if we use a slow animate here we can have a race condition
// when a users focus can not be moved away from the last message recieved.
$('#chattext').animate(
{scrollTop: $('#chattext')[0].scrollHeight},
{duration: 400, queue: false});
this.lastMessage = $('#chattext > p').eq(-1);
}
}
},
async send() {
const text = $('#chatinput').val();
if (text.replace(/\s+/, '').length === 0) return;
const message = new ChatMessage(text);
await hooks.aCallAll('chatSendMessage', Object.freeze({message}));
this._pad.collabClient.sendMessage({type: 'CHAT_MESSAGE', message});
$('#chatinput').val('');
},
async addMessage(msg, increment, isHistoryAdd) {
msg = ChatMessage.fromObject(msg);
// correct the time
msg.time += this._pad.clientTimeOffset;
if (!msg.authorId) {
/*
* If, for a bug or a database corruption, the message coming from the
* server does not contain the authorId field (see for example #3731),
* let's be defensive and replace it with "unknown".
*/
msg.authorId = 'unknown';
console.warn(
'The "authorId" field of a chat message coming from the server was not present. ' +
'Replacing with "unknown". This may be a bug or a database corruption.');
}
const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-';
return `z${c.charCodeAt(0)}z`;
})}`;
// the hook args
const ctx = {
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
message: msg,
rendered: null,
sticky: false,
timestamp: msg.time,
timeStr: (() => {
let minutes = `${new Date(msg.time).getMinutes()}`;
let hours = `${new Date(msg.time).getHours()}`;
if (minutes.length === 1) minutes = `0${minutes}`;
if (hours.length === 1) hours = `0${hours}`;
return `${hours}:${minutes}`;
})(),
duration: 4000,
};
// is the users focus already in the chatbox?
const alreadyFocused = $('#chatinput').is(':focus');
// does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the current user mentioned?)
const wasMentioned =
msg.authorId !== window.clientVars.userId &&
ctx.authorName !== html10n.get('pad.userlist.unnamed') &&
normalize(ctx.text).includes(normalize(ctx.authorName));
// If the user was mentioned, make the message sticky
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
chatMentions++;
Tinycon.setBubble(chatMentions);
ctx.sticky = true;
}
await hooks.aCallAll('chatNewMessage', ctx);
const cls = authorClass(ctx.author);
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
.attr('data-authorId', ctx.author)
.addClass(cls)
.append($('<b>').text(`${ctx.authorName}:`))
.append($('<span>')
.addClass('time')
.addClass(cls)
// Hook functions are trusted to not introduce an XSS vulnerability by adding
// unescaped user input to ctx.timeStr.
.html(ctx.timeStr))
.append(' ')
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
// introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
else $('#chattext').append(chatMsg);
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter??
if (increment && !isHistoryAdd) {
// Update the counter of unread messages
let count = Number($('#chatcounter').text());
count++;
$('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
const text = $('<p>')
.append($('<span>').addClass('author-name').text(ctx.authorName))
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// to not introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
text.each((i, e) => html10n.translateElement(html10n.translations, e));
$.gritter.add({
text,
sticky: ctx.sticky,
time: ctx.duration,
position: 'bottom',
class_name: 'chat-gritter-msg',
});
}
}
if (!isHistoryAdd) this.scrollDown();
},
init(pad) {
this._pad = pad;
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
// If we're in chat already..
$(':focus').blur(); // required to do not try to remove!
padeditor.ace.focus(); // Sends focus back to pad
evt.preventDefault();
return false;
}
});
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').click(() => {
chatMentions = 0;
Tinycon.setBubble(0);
});
const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) {
if (evt.altKey && evt.which === 67) {
// Alt c focuses on the Chat window
$(this).blur();
self.show();
$('#chatinput').focus();
evt.preventDefault();
}
});
$('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
this.send();
}
});
// initial messages are loaded in pad.js' _afterHandshake
$('#chatcounter').text(0);
$('#chatloadmessagesbutton').click(() => {
const start = Math.max(this.historyPointer - 20, 0);
const end = this.historyPointer;
if (start === end) return; // nothing to load
$('#chatloadmessagesbutton').css('display', 'none');
$('#chatloadmessagesball').css('display', 'block');
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end});
this.historyPointer = start;
});
},
};
export const chat = (() => {
let isStuck = false;
let userAndChat = false;
let chatMentions = 0;
return {
show() {
$('#chaticon').removeClass('visible');
$('#chatbox').addClass('visible');
this.scrollDown(true);
chatMentions = 0;
Tinycon.setBubble(0);
$('.chat-gritter-msg').each(function () {
$.gritter.remove(this.id);
});
},
focus: () => {
setTimeout(() => {
$('#chatinput').focus();
}, 100);
},
// Make chat stick to right hand side of screen
stickToScreen(fromInitialCall) {
if (pad.settings.hideChat) {
return;
}
this.show();
isStuck = (!isStuck || fromInitialCall);
$('#chatbox').hide();
// Add timeout to disable the chatbox animations
setTimeout(() => {
$('#chatbox, .sticky-container').toggleClass('stickyChat', isStuck);
$('#chatbox').css('display', 'flex');
}, 0);
padcookie.setPref('chatAlwaysVisible', isStuck);
$('#options-stickychat').prop('checked', isStuck);
},
chatAndUsers(fromInitialCall) {
const toEnable = $('#options-chatandusers').is(':checked');
if (toEnable || !userAndChat || fromInitialCall) {
this.stickToScreen(true);
$('#options-stickychat').prop('checked', true);
$('#options-chatandusers').prop('checked', true);
$('#options-stickychat').prop('disabled', 'disabled');
userAndChat = true;
}
else {
$('#options-stickychat').prop('disabled', false);
userAndChat = false;
}
padcookie.setPref('chatAndUsers', userAndChat);
$('#users, .sticky-container')
.toggleClass('chatAndUsers popup-show stickyUsers', userAndChat);
$('#chatbox').toggleClass('chatAndUsersChat', userAndChat);
},
hide() {
// decide on hide logic based on chat window being maximized or not
if ($('#options-stickychat').prop('checked')) {
this.stickToScreen();
$('#options-stickychat').prop('checked', false);
}
else {
$('#chatcounter').text('0');
$('#chaticon').addClass('visible');
$('#chatbox').removeClass('visible');
}
},
scrollDown(force) {
if ($('#chatbox').hasClass('visible')) {
if (force || !this.lastMessage || !this.lastMessage.position() ||
this.lastMessage.position().top < ($('#chattext').outerHeight() + 20)) {
// if we use a slow animate here we can have a race condition
// when a users focus can not be moved away from the last message recieved.
$('#chattext').animate({ scrollTop: $('#chattext')[0].scrollHeight }, { duration: 400, queue: false });
this.lastMessage = $('#chattext > p').eq(-1);
}
}
},
async send() {
const text = $('#chatinput').val();
if (text.replace(/\s+/, '').length === 0)
return;
const message = new ChatMessage(text);
await hooks.aCallAll('chatSendMessage', Object.freeze({ message }));
this._pad.collabClient.sendMessage({ type: 'CHAT_MESSAGE', message });
$('#chatinput').val('');
},
async addMessage(msg, increment, isHistoryAdd) {
msg = ChatMessage.fromObject(msg);
// correct the time
msg.time += this._pad.clientTimeOffset;
if (!msg.authorId) {
/*
* If, for a bug or a database corruption, the message coming from the
* server does not contain the authorId field (see for example #3731),
* let's be defensive and replace it with "unknown".
*/
msg.authorId = 'unknown';
console.warn('The "authorId" field of a chat message coming from the server was not present. ' +
'Replacing with "unknown". This may be a bug or a database corruption.');
}
const authorClass = (authorId) => `author-${authorId.replace(/[^a-y0-9]/g, (c) => {
if (c === '.')
return '-';
return `z${c.charCodeAt(0)}z`;
})}`;
// the hook args
const ctx = {
authorName: msg.displayName != null ? msg.displayName : html10n.get('pad.userlist.unnamed'),
author: msg.authorId,
text: padutils.escapeHtmlWithClickableLinks(msg.text, '_blank'),
message: msg,
rendered: null,
sticky: false,
timestamp: msg.time,
timeStr: (() => {
let minutes = `${new Date(msg.time).getMinutes()}`;
let hours = `${new Date(msg.time).getHours()}`;
if (minutes.length === 1)
minutes = `0${minutes}`;
if (hours.length === 1)
hours = `0${hours}`;
return `${hours}:${minutes}`;
})(),
duration: 4000,
};
// is the users focus already in the chatbox?
const alreadyFocused = $('#chatinput').is(':focus');
// does the user already have the chatbox open?
const chatOpen = $('#chatbox').hasClass('visible');
// does this message contain this user's name? (is the current user mentioned?)
const wasMentioned = msg.authorId !== window.clientVars.userId &&
ctx.authorName !== html10n.get('pad.userlist.unnamed') &&
normalize(ctx.text).includes(normalize(ctx.authorName));
// If the user was mentioned, make the message sticky
if (wasMentioned && !alreadyFocused && !isHistoryAdd && !chatOpen) {
chatMentions++;
Tinycon.setBubble(chatMentions);
ctx.sticky = true;
}
await hooks.aCallAll('chatNewMessage', ctx);
const cls = authorClass(ctx.author);
const chatMsg = ctx.rendered != null ? $(ctx.rendered) : $('<p>')
.attr('data-authorId', ctx.author)
.addClass(cls)
.append($('<b>').text(`${ctx.authorName}:`))
.append($('<span>')
.addClass('time')
.addClass(cls)
// Hook functions are trusted to not introduce an XSS vulnerability by adding
// unescaped user input to ctx.timeStr.
.html(ctx.timeStr))
.append(' ')
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
// introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
if (isHistoryAdd)
chatMsg.insertAfter('#chatloadmessagesbutton');
else
$('#chattext').append(chatMsg);
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));
// should we increment the counter??
if (increment && !isHistoryAdd) {
// Update the counter of unread messages
let count = Number($('#chatcounter').text());
count++;
$('#chatcounter').text(count);
if (!chatOpen && ctx.duration > 0) {
const text = $('<p>')
.append($('<span>').addClass('author-name').text(ctx.authorName))
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted
// to not introduce an XSS vulnerability by adding unescaped user input.
.append($('<div>').html(ctx.text).contents());
text.each((i, e) => html10n.translateElement(html10n.translations, e));
$.gritter.add({
text,
sticky: ctx.sticky,
time: ctx.duration,
position: 'bottom',
class_name: 'chat-gritter-msg',
});
}
}
if (!isHistoryAdd)
this.scrollDown();
},
init(pad) {
this._pad = pad;
$('#chatinput').on('keydown', (evt) => {
// If the event is Alt C or Escape & we're already in the chat menu
// Send the users focus back to the pad
if ((evt.altKey === true && evt.which === 67) || evt.which === 27) {
// If we're in chat already..
$(':focus').blur(); // required to do not try to remove!
padeditor.ace.focus(); // Sends focus back to pad
evt.preventDefault();
return false;
}
});
// Clear the chat mentions when the user clicks on the chat input box
$('#chatinput').click(() => {
chatMentions = 0;
Tinycon.setBubble(0);
});
const self = this;
$('body:not(#chatinput)').on('keypress', function (evt) {
if (evt.altKey && evt.which === 67) {
// Alt c focuses on the Chat window
$(this).blur();
self.show();
$('#chatinput').focus();
evt.preventDefault();
}
});
$('#chatinput').keypress((evt) => {
// if the user typed enter, fire the send
if (evt.key === 'Enter' && !evt.shiftKey) {
evt.preventDefault();
this.send();
}
});
// initial messages are loaded in pad.js' _afterHandshake
$('#chatcounter').text(0);
$('#chatloadmessagesbutton').click(() => {
const start = Math.max(this.historyPointer - 20, 0);
const end = this.historyPointer;
if (start === end)
return; // nothing to load
$('#chatloadmessagesbutton').css('display', 'none');
$('#chatloadmessagesball').css('display', 'block');
pad.collabClient.sendMessage({ type: 'GET_CHAT_MESSAGES', start, end });
this.historyPointer = start;
});
},
};
})();

View file

@ -1,11 +1,12 @@
import { chat as chat$0 } from "./chat.js";
import * as hooks from "./pluginfw/hooks.js";
import browser from "./vendors/browser.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,482 +22,451 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser');
const chat = { chat: chat$0 }.chat;
// Dependency fill on init. This exists for `pad.socket` only.
// TODO: bind directly to the socket.
let pad = undefined;
const getSocket = () => pad && pad.socket;
/** Call this when the document is ready, and a new Ace2Editor() has been created and inited.
ACE's ready callback does not need to have fired yet.
"serverVars" are from calling doc.getCollabClientVars() on the server. */
const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) => {
const editor = ace2editor;
pad = _pad; // Inject pad to avoid a circular dependency.
let rev = serverVars.rev;
let committing = false;
let stateMessage;
let channelState = 'CONNECTING';
let lastCommitTime = 0;
let initialStartConnectTime = 0;
let commitDelay = 500;
const userId = initialUserInfo.userId;
// var socket;
const userSet = {}; // userId -> userInfo
userSet[userId] = initialUserInfo;
let isPendingRevision = false;
const callbacks = {
onUserJoin: () => {},
onUserLeave: () => {},
onUpdateUserInfo: () => {},
onChannelStateChange: () => {},
onClientMessage: () => {},
onInternalAction: () => {},
onConnectionTrouble: () => {},
onServerMessage: () => {},
};
if (browser.firefox) {
// Prevent "escape" from taking effect and canceling a comet connection;
// doesn't work if focus is on an iframe.
$(window).bind('keydown', (evt) => {
if (evt.which === 27) {
evt.preventDefault();
}
});
}
const handleUserChanges = () => {
if (editor.getInInternationalComposition()) {
// handleUserChanges() will be called again once composition ends so there's no need to set up
// a future call before returning.
return;
}
const now = Date.now();
if ((!getSocket()) || channelState === 'CONNECTING') {
if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
setChannelState('DISCONNECTED', 'initsocketfail');
} else {
// check again in a bit
setTimeout(handleUserChanges, 1000);
}
return;
}
if (committing) {
if (now - lastCommitTime > 20000) {
// a commit is taking too long
setChannelState('DISCONNECTED', 'slowcommit');
} else if (now - lastCommitTime > 5000) {
callbacks.onConnectionTrouble('SLOW');
} else {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
return;
}
const earliestCommit = lastCommitTime + commitDelay;
if (now < earliestCommit) {
setTimeout(handleUserChanges, earliestCommit - now);
return;
}
let sentMessage = false;
// Check if there are any pending revisions to be received from server.
// Allow only if there are no pending revisions to be received from server
if (!isPendingRevision) {
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
lastCommitTime = now;
committing = true;
stateMessage = {
type: 'USER_CHANGES',
baseRev: rev,
changeset: userChangesData.changeset,
apool: userChangesData.apool,
};
sendMessage(stateMessage);
sentMessage = true;
callbacks.onInternalAction('commitPerformed');
}
} else {
// run again in a few seconds, to check if there was a reconnection attempt
setTimeout(handleUserChanges, 3000);
}
if (sentMessage) {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
};
const acceptCommit = () => {
editor.applyPreparedChangesetToBase();
setStateIdle();
try {
callbacks.onInternalAction('commitAcceptedByServer');
callbacks.onConnectionTrouble('OK');
} catch (err) { /* intentionally ignored */ }
handleUserChanges();
};
const setUpSocket = () => {
setChannelState('CONNECTED');
doDeferredActions();
initialStartConnectTime = Date.now();
};
const sendMessage = (msg) => {
getSocket().json.send(
{
type: 'COLLABROOM',
component: 'pad',
data: msg,
});
};
const serverMessageTaskQueue = new class {
constructor() {
this._promiseChain = Promise.resolve();
}
async enqueue(fn) {
const taskPromise = this._promiseChain.then(fn);
// Use .catch() to prevent rejections from halting the queue.
this._promiseChain = taskPromise.catch(() => {});
// Do NOT do `return await this._promiseChain;` because the caller would not see an error if
// fn() throws/rejects (due to the .catch() added above).
return await taskPromise;
}
}();
const handleMessageFromServer = (evt) => {
if (!getSocket()) return;
if (!evt.data) return;
const wrapper = evt;
if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM') return;
const msg = wrapper.data;
if (msg.type === 'NEW_CHANGES') {
serverMessageTaskQueue.enqueue(async () => {
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
// currently composing a character then execution will continue without error.
// * We assume that it is not possible for a new 'compositionstart' event to fire after
// the `await` but before the next line of code after the `await` (or, if it is
// possible, that the chances are so small or the consequences so minor that it's not
// worth addressing).
await editor.getInInternationalComposition();
const {newRev, changeset, author = '', apool} = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_newchanges");
return;
}
rev = newRev;
editor.applyChangesToBase(changeset, author, apool);
});
} else if (msg.type === 'ACCEPT_COMMIT') {
serverMessageTaskQueue.enqueue(() => {
const {newRev} = msg;
// newRev will equal rev if the changeset has no net effect (identity changeset, removing
// and re-adding the same characters with the same attributes, or retransmission of an
// already applied changeset).
if (![rev, rev + 1].includes(newRev)) {
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
acceptCommit();
});
} else if (msg.type === 'CLIENT_RECONNECT') {
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
serverMessageTaskQueue.enqueue(() => {
if (msg.noChanges) {
// If no revisions are pending, just make everything normal
setIsPendingRevision(false);
return;
}
const {headRev, newRev, changeset, author = '', apool} = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
if (author === pad.getUserId()) {
acceptCommit();
} else {
editor.applyChangesToBase(changeset, author, apool);
}
if (newRev === headRev) {
// Once we have applied all pending revisions, make everything normal
setIsPendingRevision(false);
}
});
} else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo);
} else {
userSet[id] = userInfo;
callbacks.onUserJoin(userInfo);
}
tellAceActiveAuthorInfo(userInfo);
} else if (msg.type === 'USER_LEAVE') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
delete userSet[userInfo.userId];
fadeAceAuthorInfo(userInfo);
callbacks.onUserLeave(userInfo);
}
} else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
} else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg.message, true, false);
} else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
} else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
// "handleClientMessage_USER_" hooks would work, populate payload
// FIXME: USER_* messages to have "payload" property instead of "userInfo",
// seems like a quite a big work
if (msg.type.indexOf('USER_') > -1) {
msg.payload = msg.userInfo;
}
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES') msg.payload = msg;
hooks.callAll(`handleClientMessage_${msg.type}`, {payload: msg.payload});
};
const updateUserInfo = (userInfo) => {
userInfo.userId = userId;
userSet[userId] = userInfo;
tellAceActiveAuthorInfo(userInfo);
if (!getSocket()) return;
sendMessage(
{
type: 'USERINFO_UPDATE',
userInfo,
});
};
const tellAceActiveAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId);
};
const tellAceAuthorInfo = (userId, colorId, inactive) => {
if (typeof colorId === 'number') {
colorId = clientVars.colorPalette[colorId];
}
const cssColor = colorId;
if (inactive) {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
fade: 0.5,
});
} else {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
});
}
};
const fadeAceAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true);
};
const getConnectedUsers = () => valuesArray(userSet);
const tellAceAboutHistoricalAuthors = (hadata) => {
for (const [author, data] of Object.entries(hadata)) {
if (!userSet[author]) {
tellAceAuthorInfo(author, data.colorId, true);
}
}
};
const setChannelState = (newChannelState, moreInfo) => {
if (newChannelState !== channelState) {
channelState = newChannelState;
callbacks.onChannelStateChange(channelState, moreInfo);
}
};
const valuesArray = (obj) => {
const array = [];
$.each(obj, (k, v) => {
array.push(v);
});
return array;
};
// We need to present a working interface even before the socket
// is connected for the first time.
let deferredActions = [];
const defer = (func, tag) => function (...args) {
const action = () => {
func.call(this, ...args);
const editor = ace2editor;
pad = _pad; // Inject pad to avoid a circular dependency.
let rev = serverVars.rev;
let committing = false;
let stateMessage;
let channelState = 'CONNECTING';
let lastCommitTime = 0;
let initialStartConnectTime = 0;
let commitDelay = 500;
const userId = initialUserInfo.userId;
// var socket;
const userSet = {}; // userId -> userInfo
userSet[userId] = initialUserInfo;
let isPendingRevision = false;
const callbacks = {
onUserJoin: () => { },
onUserLeave: () => { },
onUpdateUserInfo: () => { },
onChannelStateChange: () => { },
onClientMessage: () => { },
onInternalAction: () => { },
onConnectionTrouble: () => { },
onServerMessage: () => { },
};
action.tag = tag;
if (channelState === 'CONNECTING') {
deferredActions.push(action);
} else {
action();
}
};
const doDeferredActions = (tag) => {
const newArray = [];
for (let i = 0; i < deferredActions.length; i++) {
const a = deferredActions[i];
if ((!tag) || (tag === a.tag)) {
a();
} else {
newArray.push(a);
}
}
deferredActions = newArray;
};
const sendClientMessage = (msg) => {
sendMessage(
{
type: 'CLIENT_MESSAGE',
payload: msg,
if (browser.firefox) {
// Prevent "escape" from taking effect and canceling a comet connection;
// doesn't work if focus is on an iframe.
$(window).bind('keydown', (evt) => {
if (evt.which === 27) {
evt.preventDefault();
}
});
};
const getCurrentRevisionNumber = () => rev;
const getMissedChanges = () => {
const obj = {};
obj.userInfo = userSet[userId];
obj.baseRev = rev;
if (committing && stateMessage) {
obj.committedChangeset = stateMessage.changeset;
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
}
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
obj.furtherChangeset = userChangesData.changeset;
obj.furtherChangesetAPool = userChangesData.apool;
}
return obj;
};
const setStateIdle = () => {
committing = false;
callbacks.onInternalAction('newlyIdle');
schedulePerhapsCallIdleFuncs();
};
const setIsPendingRevision = (value) => {
isPendingRevision = value;
};
const idleFuncs = [];
const callWhenNotCommitting = (func) => {
idleFuncs.push(func);
schedulePerhapsCallIdleFuncs();
};
const schedulePerhapsCallIdleFuncs = () => {
setTimeout(() => {
if (!committing) {
while (idleFuncs.length > 0) {
const f = idleFuncs.shift();
f();
const handleUserChanges = () => {
if (editor.getInInternationalComposition()) {
// handleUserChanges() will be called again once composition ends so there's no need to set up
// a future call before returning.
return;
}
}
}, 0);
};
const self = {
setOnUserJoin: (cb) => {
callbacks.onUserJoin = cb;
},
setOnUserLeave: (cb) => {
callbacks.onUserLeave = cb;
},
setOnUpdateUserInfo: (cb) => {
callbacks.onUpdateUserInfo = cb;
},
setOnChannelStateChange: (cb) => {
callbacks.onChannelStateChange = cb;
},
setOnClientMessage: (cb) => {
callbacks.onClientMessage = cb;
},
setOnInternalAction: (cb) => {
callbacks.onInternalAction = cb;
},
setOnConnectionTrouble: (cb) => {
callbacks.onConnectionTrouble = cb;
},
updateUserInfo: defer(updateUserInfo),
handleMessageFromServer,
getConnectedUsers,
sendClientMessage,
sendMessage,
getCurrentRevisionNumber,
getMissedChanges,
callWhenNotCommitting,
addHistoricalAuthors: tellAceAboutHistoricalAuthors,
setChannelState,
setStateIdle,
setIsPendingRevision,
set commitDelay(ms) { commitDelay = ms; },
get commitDelay() { return commitDelay; },
};
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
tellAceActiveAuthorInfo(initialUserInfo);
editor.setProperty('userAuthor', userId);
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
editor.setUserChangeNotificationCallback(handleUserChanges);
setUpSocket();
return self;
const now = Date.now();
if ((!getSocket()) || channelState === 'CONNECTING') {
if (channelState === 'CONNECTING' && (now - initialStartConnectTime) > 20000) {
setChannelState('DISCONNECTED', 'initsocketfail');
}
else {
// check again in a bit
setTimeout(handleUserChanges, 1000);
}
return;
}
if (committing) {
if (now - lastCommitTime > 20000) {
// a commit is taking too long
setChannelState('DISCONNECTED', 'slowcommit');
}
else if (now - lastCommitTime > 5000) {
callbacks.onConnectionTrouble('SLOW');
}
else {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
return;
}
const earliestCommit = lastCommitTime + commitDelay;
if (now < earliestCommit) {
setTimeout(handleUserChanges, earliestCommit - now);
return;
}
let sentMessage = false;
// Check if there are any pending revisions to be received from server.
// Allow only if there are no pending revisions to be received from server
if (!isPendingRevision) {
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
lastCommitTime = now;
committing = true;
stateMessage = {
type: 'USER_CHANGES',
baseRev: rev,
changeset: userChangesData.changeset,
apool: userChangesData.apool,
};
sendMessage(stateMessage);
sentMessage = true;
callbacks.onInternalAction('commitPerformed');
}
}
else {
// run again in a few seconds, to check if there was a reconnection attempt
setTimeout(handleUserChanges, 3000);
}
if (sentMessage) {
// run again in a few seconds, to detect a disconnect
setTimeout(handleUserChanges, 3000);
}
};
const acceptCommit = () => {
editor.applyPreparedChangesetToBase();
setStateIdle();
try {
callbacks.onInternalAction('commitAcceptedByServer');
callbacks.onConnectionTrouble('OK');
}
catch (err) { /* intentionally ignored */ }
handleUserChanges();
};
const setUpSocket = () => {
setChannelState('CONNECTED');
doDeferredActions();
initialStartConnectTime = Date.now();
};
const sendMessage = (msg) => {
getSocket().json.send({
type: 'COLLABROOM',
component: 'pad',
data: msg,
});
};
const serverMessageTaskQueue = new class {
constructor() {
this._promiseChain = Promise.resolve();
}
async enqueue(fn) {
const taskPromise = this._promiseChain.then(fn);
// Use .catch() to prevent rejections from halting the queue.
this._promiseChain = taskPromise.catch(() => { });
// Do NOT do `return await this._promiseChain;` because the caller would not see an error if
// fn() throws/rejects (due to the .catch() added above).
return await taskPromise;
}
}();
const handleMessageFromServer = (evt) => {
if (!getSocket())
return;
if (!evt.data)
return;
const wrapper = evt;
if (wrapper.type !== 'COLLABROOM' && wrapper.type !== 'CUSTOM')
return;
const msg = wrapper.data;
if (msg.type === 'NEW_CHANGES') {
serverMessageTaskQueue.enqueue(async () => {
// Avoid updating the DOM while the user is composing a character. Notes about this `await`:
// * `await null;` is equivalent to `await Promise.resolve(null);`, so if the user is not
// currently composing a character then execution will continue without error.
// * We assume that it is not possible for a new 'compositionstart' event to fire after
// the `await` but before the next line of code after the `await` (or, if it is
// possible, that the chances are so small or the consequences so minor that it's not
// worth addressing).
await editor.getInInternationalComposition();
const { newRev, changeset, author = '', apool } = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on NEW_CHANGES: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_newchanges");
return;
}
rev = newRev;
editor.applyChangesToBase(changeset, author, apool);
});
}
else if (msg.type === 'ACCEPT_COMMIT') {
serverMessageTaskQueue.enqueue(() => {
const { newRev } = msg;
// newRev will equal rev if the changeset has no net effect (identity changeset, removing
// and re-adding the same characters with the same attributes, or retransmission of an
// already applied changeset).
if (![rev, rev + 1].includes(newRev)) {
window.console.warn(`bad message revision on ACCEPT_COMMIT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
acceptCommit();
});
}
else if (msg.type === 'CLIENT_RECONNECT') {
// Server sends a CLIENT_RECONNECT message when there is a client reconnect.
// Server also returns all pending revisions along with this CLIENT_RECONNECT message
serverMessageTaskQueue.enqueue(() => {
if (msg.noChanges) {
// If no revisions are pending, just make everything normal
setIsPendingRevision(false);
return;
}
const { headRev, newRev, changeset, author = '', apool } = msg;
if (newRev !== (rev + 1)) {
window.console.warn(`bad message revision on CLIENT_RECONNECT: ${newRev} not ${rev + 1}`);
// setChannelState("DISCONNECTED", "badmessage_acceptcommit");
return;
}
rev = newRev;
if (author === pad.getUserId()) {
acceptCommit();
}
else {
editor.applyChangesToBase(changeset, author, apool);
}
if (newRev === headRev) {
// Once we have applied all pending revisions, make everything normal
setIsPendingRevision(false);
}
});
}
else if (msg.type === 'USER_NEWINFO') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
userSet[id] = userInfo;
callbacks.onUpdateUserInfo(userInfo);
}
else {
userSet[id] = userInfo;
callbacks.onUserJoin(userInfo);
}
tellAceActiveAuthorInfo(userInfo);
}
else if (msg.type === 'USER_LEAVE') {
const userInfo = msg.userInfo;
const id = userInfo.userId;
if (userSet[id]) {
delete userSet[userInfo.userId];
fadeAceAuthorInfo(userInfo);
callbacks.onUserLeave(userInfo);
}
}
else if (msg.type === 'CLIENT_MESSAGE') {
callbacks.onClientMessage(msg.payload);
}
else if (msg.type === 'CHAT_MESSAGE') {
chat.addMessage(msg.message, true, false);
}
else if (msg.type === 'CHAT_MESSAGES') {
for (let i = msg.messages.length - 1; i >= 0; i--) {
chat.addMessage(msg.messages[i], true, true);
}
if (!chat.gotInitalMessages) {
chat.scrollDown();
chat.gotInitalMessages = true;
chat.historyPointer = clientVars.chatHead - msg.messages.length;
}
// messages are loaded, so hide the loading-ball
$('#chatloadmessagesball').css('display', 'none');
// there are less than 100 messages or we reached the top
if (chat.historyPointer <= 0) {
$('#chatloadmessagesbutton').css('display', 'none');
}
else {
// there are still more messages, re-show the load-button
$('#chatloadmessagesbutton').css('display', 'block');
}
}
// HACKISH: User messages do not have "payload" but "userInfo", so that all
// "handleClientMessage_USER_" hooks would work, populate payload
// FIXME: USER_* messages to have "payload" property instead of "userInfo",
// seems like a quite a big work
if (msg.type.indexOf('USER_') > -1) {
msg.payload = msg.userInfo;
}
// Similar for NEW_CHANGES
if (msg.type === 'NEW_CHANGES')
msg.payload = msg;
hooks.callAll(`handleClientMessage_${msg.type}`, { payload: msg.payload });
};
const updateUserInfo = (userInfo) => {
userInfo.userId = userId;
userSet[userId] = userInfo;
tellAceActiveAuthorInfo(userInfo);
if (!getSocket())
return;
sendMessage({
type: 'USERINFO_UPDATE',
userInfo,
});
};
const tellAceActiveAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId);
};
const tellAceAuthorInfo = (userId, colorId, inactive) => {
if (typeof colorId === 'number') {
colorId = clientVars.colorPalette[colorId];
}
const cssColor = colorId;
if (inactive) {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
fade: 0.5,
});
}
else {
editor.setAuthorInfo(userId, {
bgcolor: cssColor,
});
}
};
const fadeAceAuthorInfo = (userInfo) => {
tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true);
};
const getConnectedUsers = () => valuesArray(userSet);
const tellAceAboutHistoricalAuthors = (hadata) => {
for (const [author, data] of Object.entries(hadata)) {
if (!userSet[author]) {
tellAceAuthorInfo(author, data.colorId, true);
}
}
};
const setChannelState = (newChannelState, moreInfo) => {
if (newChannelState !== channelState) {
channelState = newChannelState;
callbacks.onChannelStateChange(channelState, moreInfo);
}
};
const valuesArray = (obj) => {
const array = [];
$.each(obj, (k, v) => {
array.push(v);
});
return array;
};
// We need to present a working interface even before the socket
// is connected for the first time.
let deferredActions = [];
const defer = (func, tag) => function (...args) {
const action = () => {
func.call(this, ...args);
};
action.tag = tag;
if (channelState === 'CONNECTING') {
deferredActions.push(action);
}
else {
action();
}
};
const doDeferredActions = (tag) => {
const newArray = [];
for (let i = 0; i < deferredActions.length; i++) {
const a = deferredActions[i];
if ((!tag) || (tag === a.tag)) {
a();
}
else {
newArray.push(a);
}
}
deferredActions = newArray;
};
const sendClientMessage = (msg) => {
sendMessage({
type: 'CLIENT_MESSAGE',
payload: msg,
});
};
const getCurrentRevisionNumber = () => rev;
const getMissedChanges = () => {
const obj = {};
obj.userInfo = userSet[userId];
obj.baseRev = rev;
if (committing && stateMessage) {
obj.committedChangeset = stateMessage.changeset;
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
}
const userChangesData = editor.prepareUserChangeset();
if (userChangesData.changeset) {
obj.furtherChangeset = userChangesData.changeset;
obj.furtherChangesetAPool = userChangesData.apool;
}
return obj;
};
const setStateIdle = () => {
committing = false;
callbacks.onInternalAction('newlyIdle');
schedulePerhapsCallIdleFuncs();
};
const setIsPendingRevision = (value) => {
isPendingRevision = value;
};
const idleFuncs = [];
const callWhenNotCommitting = (func) => {
idleFuncs.push(func);
schedulePerhapsCallIdleFuncs();
};
const schedulePerhapsCallIdleFuncs = () => {
setTimeout(() => {
if (!committing) {
while (idleFuncs.length > 0) {
const f = idleFuncs.shift();
f();
}
}
}, 0);
};
const self = {
setOnUserJoin: (cb) => {
callbacks.onUserJoin = cb;
},
setOnUserLeave: (cb) => {
callbacks.onUserLeave = cb;
},
setOnUpdateUserInfo: (cb) => {
callbacks.onUpdateUserInfo = cb;
},
setOnChannelStateChange: (cb) => {
callbacks.onChannelStateChange = cb;
},
setOnClientMessage: (cb) => {
callbacks.onClientMessage = cb;
},
setOnInternalAction: (cb) => {
callbacks.onInternalAction = cb;
},
setOnConnectionTrouble: (cb) => {
callbacks.onConnectionTrouble = cb;
},
updateUserInfo: defer(updateUserInfo),
handleMessageFromServer,
getConnectedUsers,
sendClientMessage,
sendMessage,
getCurrentRevisionNumber,
getMissedChanges,
callWhenNotCommitting,
addHistoricalAuthors: tellAceAboutHistoricalAuthors,
setChannelState,
setStateIdle,
setIsPendingRevision,
set commitDelay(ms) { commitDelay = ms; },
get commitDelay() { return commitDelay; },
};
tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData);
tellAceActiveAuthorInfo(initialUserInfo);
editor.setProperty('userAuthor', userId);
editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool);
editor.setUserChangeNotificationCallback(handleUserChanges);
setUpSocket();
return self;
};
exports.getCollabClient = getCollabClient;
export { getCollabClient };

View file

@ -1,11 +1,9 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/colorutils.js
// THIS FILE IS ALSO SERVED AS CLIENT-SIDE JS
/**
@ -23,47 +21,39 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const colorutils = {};
// Check that a given value is a css hex color value, e.g.
// "#ffffff" or "#fff"
colorutils.isCssHex = (cssColor) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(cssColor);
// "#ffffff" or "#fff" or "ffffff" or "fff" to [1.0, 1.0, 1.0]
colorutils.css2triple = (cssColor) => {
const sixHex = colorutils.css2sixhex(cssColor);
const hexToFloat = (hh) => Number(`0x${hh}`) / 255;
return [
hexToFloat(sixHex.substr(0, 2)),
hexToFloat(sixHex.substr(2, 2)),
hexToFloat(sixHex.substr(4, 2)),
];
const sixHex = colorutils.css2sixhex(cssColor);
const hexToFloat = (hh) => Number(`0x${hh}`) / 255;
return [
hexToFloat(sixHex.substr(0, 2)),
hexToFloat(sixHex.substr(2, 2)),
hexToFloat(sixHex.substr(4, 2)),
];
};
// "#ffffff" or "#fff" or "ffffff" or "fff" to "ffffff"
colorutils.css2sixhex = (cssColor) => {
let h = /[0-9a-fA-F]+/.exec(cssColor)[0];
if (h.length !== 6) {
const a = h.charAt(0);
const b = h.charAt(1);
const c = h.charAt(2);
h = a + a + b + b + c + c;
}
return h;
let h = /[0-9a-fA-F]+/.exec(cssColor)[0];
if (h.length !== 6) {
const a = h.charAt(0);
const b = h.charAt(1);
const c = h.charAt(2);
h = a + a + b + b + c + c;
}
return h;
};
// [1.0, 1.0, 1.0] -> "#ffffff"
colorutils.triple2css = (triple) => {
const floatToHex = (n) => {
const n2 = colorutils.clamp(Math.round(n * 255), 0, 255);
return (`0${n2.toString(16)}`).slice(-2);
};
return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`;
const floatToHex = (n) => {
const n2 = colorutils.clamp(Math.round(n * 255), 0, 255);
return (`0${n2.toString(16)}`).slice(-2);
};
return `#${floatToHex(triple[0])}${floatToHex(triple[1])}${floatToHex(triple[2])}`;
};
colorutils.clamp = (v, bot, top) => v < bot ? bot : (v > top ? top : v);
colorutils.min3 = (a, b, c) => (a < b) ? (a < c ? a : c) : (b < c ? b : c);
colorutils.max3 = (a, b, c) => (a > b) ? (a > c ? a : c) : (b > c ? b : c);
@ -71,51 +61,42 @@ colorutils.colorMin = (c) => colorutils.min3(c[0], c[1], c[2]);
colorutils.colorMax = (c) => colorutils.max3(c[0], c[1], c[2]);
colorutils.scale = (v, bot, top) => colorutils.clamp(bot + v * (top - bot), 0, 1);
colorutils.unscale = (v, bot, top) => colorutils.clamp((v - bot) / (top - bot), 0, 1);
colorutils.scaleColor = (c, bot, top) => [
colorutils.scale(c[0], bot, top),
colorutils.scale(c[1], bot, top),
colorutils.scale(c[2], bot, top),
colorutils.scale(c[0], bot, top),
colorutils.scale(c[1], bot, top),
colorutils.scale(c[2], bot, top),
];
colorutils.unscaleColor = (c, bot, top) => [
colorutils.unscale(c[0], bot, top),
colorutils.unscale(c[1], bot, top),
colorutils.unscale(c[2], bot, top),
colorutils.unscale(c[0], bot, top),
colorutils.unscale(c[1], bot, top),
colorutils.unscale(c[2], bot, top),
];
// rule of thumb for RGB brightness; 1.0 is white
colorutils.luminosity = (c) => c[0] * 0.30 + c[1] * 0.59 + c[2] * 0.11;
colorutils.saturate = (c) => {
const min = colorutils.colorMin(c);
const max = colorutils.colorMax(c);
if (max - min <= 0) return [1.0, 1.0, 1.0];
return colorutils.unscaleColor(c, min, max);
const min = colorutils.colorMin(c);
const max = colorutils.colorMax(c);
if (max - min <= 0)
return [1.0, 1.0, 1.0];
return colorutils.unscaleColor(c, min, max);
};
colorutils.blend = (c1, c2, t) => [
colorutils.scale(t, c1[0], c2[0]),
colorutils.scale(t, c1[1], c2[1]),
colorutils.scale(t, c1[2], c2[2]),
colorutils.scale(t, c1[0], c2[0]),
colorutils.scale(t, c1[1], c2[1]),
colorutils.scale(t, c1[2], c2[2]),
];
colorutils.invert = (c) => [1 - c[0], 1 - c[1], 1 - c[2]];
colorutils.complementary = (c) => {
const inv = colorutils.invert(c);
return [
(inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30),
(inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59),
(inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11),
];
const inv = colorutils.invert(c);
return [
(inv[0] >= c[0]) ? Math.min(inv[0] * 1.30, 1) : (c[0] * 0.30),
(inv[1] >= c[1]) ? Math.min(inv[1] * 1.59, 1) : (c[1] * 0.59),
(inv[2] >= c[2]) ? Math.min(inv[2] * 1.11, 1) : (c[2] * 0.11),
];
};
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
};
exports.colorutils = colorutils;
export { colorutils };

File diff suppressed because it is too large Load diff

View file

@ -1,72 +1,47 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.makeCSSManager = (browserSheet) => {
const browserRules = () => (browserSheet.cssRules || browserSheet.rules);
const browserDeleteRule = (i) => {
if (browserSheet.deleteRule) browserSheet.deleteRule(i);
else browserSheet.removeRule(i);
};
const browserInsertRule = (i, selector) => {
if (browserSheet.insertRule) browserSheet.insertRule(`${selector} {}`, i);
else browserSheet.addRule(selector, null, i);
};
const selectorList = [];
const indexOfSelector = (selector) => {
for (let i = 0; i < selectorList.length; i++) {
if (selectorList[i] === selector) {
return i;
}
}
return -1;
};
const selectorStyle = (selector) => {
let i = indexOfSelector(selector);
if (i < 0) {
// add selector
browserInsertRule(0, selector);
selectorList.splice(0, 0, selector);
i = 0;
}
return browserRules().item(i).style;
};
const removeSelectorStyle = (selector) => {
const i = indexOfSelector(selector);
if (i >= 0) {
browserDeleteRule(i);
selectorList.splice(i, 1);
}
};
return {
selectorStyle,
removeSelectorStyle,
info: () => `${selectorList.length}:${browserRules().length}`,
};
export const makeCSSManager = (browserSheet) => {
const browserRules = () => (browserSheet.cssRules || browserSheet.rules);
const browserDeleteRule = (i) => {
if (browserSheet.deleteRule)
browserSheet.deleteRule(i);
else
browserSheet.removeRule(i);
};
const browserInsertRule = (i, selector) => {
if (browserSheet.insertRule)
browserSheet.insertRule(`${selector} {}`, i);
else
browserSheet.addRule(selector, null, i);
};
const selectorList = [];
const indexOfSelector = (selector) => {
for (let i = 0; i < selectorList.length; i++) {
if (selectorList[i] === selector) {
return i;
}
}
return -1;
};
const selectorStyle = (selector) => {
let i = indexOfSelector(selector);
if (i < 0) {
// add selector
browserInsertRule(0, selector);
selectorList.splice(0, 0, selector);
i = 0;
}
return browserRules().item(i).style;
};
const removeSelectorStyle = (selector) => {
const i = indexOfSelector(selector);
if (i >= 0) {
browserDeleteRule(i);
selectorList.splice(i, 1);
}
};
return {
selectorStyle,
removeSelectorStyle,
info: () => `${selectorList.length}:${browserRules().length}`,
};
};

View file

@ -1,278 +1,244 @@
import * as Security from "./security.js";
import * as hooks from "./pluginfw/hooks.js";
import * as _ from "./underscore.js";
import { lineAttributeMarker as lineAttributeMarker$0 } from "./linestylefilter.js";
'use strict';
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.domline
// %APPJET%: import("etherpad.admin.plugins");
/**
* 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.
*/
// requires: top
// requires: plugins
// requires: undefined
const Security = require('./security');
const hooks = require('./pluginfw/hooks');
const _ = require('./underscore');
const lineAttributeMarker = require('./linestylefilter').lineAttributeMarker;
const noop = () => {};
const lineAttributeMarker = { lineAttributeMarker: lineAttributeMarker$0 }.lineAttributeMarker;
const noop = () => { };
const domline = {};
domline.addToLineClass = (lineClass, cls) => {
// an "empty span" at any point can be used to add classes to
// the line, using line:className. otherwise, we ignore
// the span.
cls.replace(/\S+/g, (c) => {
if (c.indexOf('line:') === 0) {
// add class to line
lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5);
}
});
return lineClass;
// an "empty span" at any point can be used to add classes to
// the line, using line:className. otherwise, we ignore
// the span.
cls.replace(/\S+/g, (c) => {
if (c.indexOf('line:') === 0) {
// add class to line
lineClass = (lineClass ? `${lineClass} ` : '') + c.substring(5);
}
});
return lineClass;
};
// if "document" is falsy we don't create a DOM node, just
// an object with innerHTML and className
domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
const result = {
node: null,
appendSpan: noop,
prepareForAdd: noop,
notifyAdded: noop,
clearSpans: noop,
finishUpdate: noop,
lineMarker: 0,
};
const document = optDocument;
if (document) {
result.node = document.createElement('div');
// JAWS and NVDA screen reader compatibility. Only needed if in a real browser.
result.node.setAttribute('aria-live', 'assertive');
} else {
result.node = {
innerHTML: '',
className: '',
const result = {
node: null,
appendSpan: noop,
prepareForAdd: noop,
notifyAdded: noop,
clearSpans: noop,
finishUpdate: noop,
lineMarker: 0,
};
}
let html = [];
let preHtml = '';
let postHtml = '';
let curHTML = null;
const processSpaces = (s) => domline.processSpaces(s, doesWrap);
const perTextNodeProcess = (doesWrap ? _.identity : processSpaces);
const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity);
let lineClass = 'ace-line';
result.appendSpan = (txt, cls) => {
let processedMarker = false;
// Handle lineAttributeMarker, if present
if (cls.indexOf(lineAttributeMarker) >= 0) {
let listType = /(?:^| )list:(\S+)/.exec(cls);
const start = /(?:^| )start:(\S+)/.exec(cls);
_.map(hooks.callAll('aceDomLinePreProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (listType) {
listType = listType[1];
if (listType) {
if (listType.indexOf('number') < 0) {
preHtml += `<ul class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
postHtml = `</li></ul>${postHtml}`;
} else {
if (start) { // is it a start of a list with more than one item in?
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
// Add start class to DIV node
lineClass = `${lineClass} ` + `list-start-${listType}`;
}
preHtml +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
} else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
const document = optDocument;
if (document) {
result.node = document.createElement('div');
// JAWS and NVDA screen reader compatibility. Only needed if in a real browser.
result.node.setAttribute('aria-live', 'assertive');
}
else {
result.node = {
innerHTML: '',
className: '',
};
}
let html = [];
let preHtml = '';
let postHtml = '';
let curHTML = null;
const processSpaces = (s) => domline.processSpaces(s, doesWrap);
const perTextNodeProcess = (doesWrap ? _.identity : processSpaces);
const perHtmlLineProcess = (doesWrap ? processSpaces : _.identity);
let lineClass = 'ace-line';
result.appendSpan = (txt, cls) => {
let processedMarker = false;
// Handle lineAttributeMarker, if present
if (cls.indexOf(lineAttributeMarker) >= 0) {
let listType = /(?:^| )list:(\S+)/.exec(cls);
const start = /(?:^| )start:(\S+)/.exec(cls);
_.map(hooks.callAll('aceDomLinePreProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (listType) {
listType = listType[1];
if (listType) {
if (listType.indexOf('number') < 0) {
preHtml += `<ul class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
postHtml = `</li></ul>${postHtml}`;
}
else {
if (start) { // is it a start of a list with more than one item in?
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
// Add start class to DIV node
lineClass = `${lineClass} ` + `list-start-${listType}`;
}
preHtml +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
}
else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
}
postHtml += '</li></ol>';
}
}
processedMarker = true;
}
_.map(hooks.callAll('aceDomLineProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (processedMarker) {
result.lineMarker += txt.length;
return; // don't append any text
}
postHtml += '</li></ol>';
}
}
processedMarker = true;
}
_.map(hooks.callAll('aceDomLineProcessLineAttributes', {
domline,
cls,
}), (modifier) => {
preHtml += modifier.preHtml;
postHtml += modifier.postHtml;
processedMarker |= modifier.processedMarker;
});
if (processedMarker) {
result.lineMarker += txt.length;
return; // don't append any text
}
}
let href = null;
let simpleTags = null;
if (cls.indexOf('url') >= 0) {
cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url) => {
href = url;
return `${space}url`;
});
}
if (cls.indexOf('tag') >= 0) {
cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => {
if (!simpleTags) simpleTags = [];
simpleTags.push(tag.toLowerCase());
return space + tag;
});
}
let extraOpenTags = '';
let extraCloseTags = '';
_.map(hooks.callAll('aceCreateDomLine', {
domline,
cls,
}), (modifier) => {
cls = modifier.cls;
extraOpenTags += modifier.extraOpenTags;
extraCloseTags = modifier.extraCloseTags + extraCloseTags;
});
if ((!txt) && cls) {
lineClass = domline.addToLineClass(lineClass, cls);
} else if (txt) {
if (href) {
const urn_schemes = new RegExp('^(about|geo|mailto|tel):');
// if the url doesn't include a protocol prefix, assume http
if (!~href.indexOf('://') && !urn_schemes.test(href)) {
href = `http://${href}`;
let href = null;
let simpleTags = null;
if (cls.indexOf('url') >= 0) {
cls = cls.replace(/(^| )url:(\S+)/g, (x0, space, url) => {
href = url;
return `${space}url`;
});
}
// Using rel="noreferrer" stops leaking the URL/location of the pad when
// clicking links in the document.
// Not all browsers understand this attribute, but it's part of the HTML5 standard.
// https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer
// Additionally, we do rel="noopener" to ensure a higher level of referrer security.
// https://html.spec.whatwg.org/multipage/links.html#link-type-noopener
// https://mathiasbynens.github.io/rel-noopener/
// https://github.com/ether/etherpad-lite/pull/3636
const escapedHref = Security.escapeHTMLAttribute(href);
extraOpenTags = `${extraOpenTags}<a href="${escapedHref}" rel="noreferrer noopener">`;
extraCloseTags = `</a>${extraCloseTags}`;
}
if (simpleTags) {
simpleTags.sort();
extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;
simpleTags.reverse();
extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;
}
html.push(
'<span class="', Security.escapeHTMLAttribute(cls || ''),
'">',
extraOpenTags,
perTextNodeProcess(Security.escapeHTML(txt)),
extraCloseTags,
'</span>');
}
};
result.clearSpans = () => {
html = [];
lineClass = 'ace-line';
result.lineMarker = 0;
};
const writeHTML = () => {
let newHTML = perHtmlLineProcess(html.join(''));
if (!newHTML) {
if ((!document) || (!optBrowser)) {
newHTML += '&nbsp;';
} else {
newHTML += '<br/>';
}
}
if (nonEmpty) {
newHTML = (preHtml || '') + newHTML + (postHtml || '');
}
html = preHtml = postHtml = ''; // free memory
if (newHTML !== curHTML) {
curHTML = newHTML;
result.node.innerHTML = curHTML;
}
if (lineClass != null) result.node.className = lineClass;
hooks.callAll('acePostWriteDomLineHTML', {
node: result.node,
});
};
result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML;
return result;
if (cls.indexOf('tag') >= 0) {
cls = cls.replace(/(^| )tag:(\S+)/g, (x0, space, tag) => {
if (!simpleTags)
simpleTags = [];
simpleTags.push(tag.toLowerCase());
return space + tag;
});
}
let extraOpenTags = '';
let extraCloseTags = '';
_.map(hooks.callAll('aceCreateDomLine', {
domline,
cls,
}), (modifier) => {
cls = modifier.cls;
extraOpenTags += modifier.extraOpenTags;
extraCloseTags = modifier.extraCloseTags + extraCloseTags;
});
if ((!txt) && cls) {
lineClass = domline.addToLineClass(lineClass, cls);
}
else if (txt) {
if (href) {
const urn_schemes = new RegExp('^(about|geo|mailto|tel):');
// if the url doesn't include a protocol prefix, assume http
if (!~href.indexOf('://') && !urn_schemes.test(href)) {
href = `http://${href}`;
}
// Using rel="noreferrer" stops leaking the URL/location of the pad when
// clicking links in the document.
// Not all browsers understand this attribute, but it's part of the HTML5 standard.
// https://html.spec.whatwg.org/multipage/links.html#link-type-noreferrer
// Additionally, we do rel="noopener" to ensure a higher level of referrer security.
// https://html.spec.whatwg.org/multipage/links.html#link-type-noopener
// https://mathiasbynens.github.io/rel-noopener/
// https://github.com/ether/etherpad-lite/pull/3636
const escapedHref = Security.escapeHTMLAttribute(href);
extraOpenTags = `${extraOpenTags}<a href="${escapedHref}" rel="noreferrer noopener">`;
extraCloseTags = `</a>${extraCloseTags}`;
}
if (simpleTags) {
simpleTags.sort();
extraOpenTags = `${extraOpenTags}<${simpleTags.join('><')}>`;
simpleTags.reverse();
extraCloseTags = `</${simpleTags.join('></')}>${extraCloseTags}`;
}
html.push('<span class="', Security.escapeHTMLAttribute(cls || ''), '">', extraOpenTags, perTextNodeProcess(Security.escapeHTML(txt)), extraCloseTags, '</span>');
}
};
result.clearSpans = () => {
html = [];
lineClass = 'ace-line';
result.lineMarker = 0;
};
const writeHTML = () => {
let newHTML = perHtmlLineProcess(html.join(''));
if (!newHTML) {
if ((!document) || (!optBrowser)) {
newHTML += '&nbsp;';
}
else {
newHTML += '<br/>';
}
}
if (nonEmpty) {
newHTML = (preHtml || '') + newHTML + (postHtml || '');
}
html = preHtml = postHtml = ''; // free memory
if (newHTML !== curHTML) {
curHTML = newHTML;
result.node.innerHTML = curHTML;
}
if (lineClass != null)
result.node.className = lineClass;
hooks.callAll('acePostWriteDomLineHTML', {
node: result.node,
});
};
result.prepareForAdd = writeHTML;
result.finishUpdate = writeHTML;
return result;
};
domline.processSpaces = (s, doesWrap) => {
if (s.indexOf('<') < 0 && !doesWrap) {
// short-cut
return s.replace(/ /g, '&nbsp;');
}
const parts = [];
s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {
parts.push(m);
});
if (doesWrap) {
let endOfLine = true;
let beforeSpace = false;
// last space in a run is normal, others are nbsp,
// end of line is nbsp
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p === ' ') {
if (endOfLine || beforeSpace) parts[i] = '&nbsp;';
endOfLine = false;
beforeSpace = true;
} else if (p.charAt(0) !== '<') {
endOfLine = false;
beforeSpace = false;
}
if (s.indexOf('<') < 0 && !doesWrap) {
// short-cut
return s.replace(/ /g, '&nbsp;');
}
// beginning of line is nbsp
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
break;
} else if (p.charAt(0) !== '<') {
break;
}
const parts = [];
s.replace(/<[^>]*>?| |[^ <]+/g, (m) => {
parts.push(m);
});
if (doesWrap) {
let endOfLine = true;
let beforeSpace = false;
// last space in a run is normal, others are nbsp,
// end of line is nbsp
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p === ' ') {
if (endOfLine || beforeSpace)
parts[i] = '&nbsp;';
endOfLine = false;
beforeSpace = true;
}
else if (p.charAt(0) !== '<') {
endOfLine = false;
beforeSpace = false;
}
}
// beginning of line is nbsp
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
break;
}
else if (p.charAt(0) !== '<') {
break;
}
}
}
} else {
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
}
else {
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === ' ') {
parts[i] = '&nbsp;';
}
}
}
}
return parts.join('');
return parts.join('');
};
exports.domline = domline;
export { domline };

View file

@ -1,5 +1,4 @@
'use strict';
/* eslint-disable-next-line max-len */
// @license magnet:?xt=urn:btih:8e4f440f4c65981c5bf93c76d35135ba5064d8b7&dn=apache-2.0.txt Apache-2.0
/**
@ -18,45 +17,41 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const randomPadName = () => {
// the number of distinct chars (64) is chosen to ensure that the selection will be uniform when
// using the PRNG below
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
// the length of the pad name is chosen to get 120-bit security: log2(64^20) = 120
const stringLength = 20;
// make room for 8-bit integer values that span from 0 to 255.
const randomarray = new Uint8Array(stringLength);
// use browser's PRNG to generate a "unique" sequence
const cryptoObj = window.crypto || window.msCrypto; // for IE 11
cryptoObj.getRandomValues(randomarray);
let randomstring = '';
for (let i = 0; i < stringLength; i++) {
// instead of writing "Math.floor(randomarray[i]/256*64)"
// we can save some cycles.
const rnum = Math.floor(randomarray[i] / 4);
randomstring += chars.substring(rnum, rnum + 1);
}
return randomstring;
};
$(() => {
$('#go2Name').submit(() => {
const padname = $('#padname').val();
if (padname.length > 0) {
window.location = `p/${encodeURIComponent(padname.trim())}`;
} else {
alert('Please enter a name');
// the number of distinct chars (64) is chosen to ensure that the selection will be uniform when
// using the PRNG below
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
// the length of the pad name is chosen to get 120-bit security: log2(64^20) = 120
const stringLength = 20;
// make room for 8-bit integer values that span from 0 to 255.
const randomarray = new Uint8Array(stringLength);
// use browser's PRNG to generate a "unique" sequence
const cryptoObj = window.crypto || window.msCrypto; // for IE 11
cryptoObj.getRandomValues(randomarray);
let randomstring = '';
for (let i = 0; i < stringLength; i++) {
// instead of writing "Math.floor(randomarray[i]/256*64)"
// we can save some cycles.
const rnum = Math.floor(randomarray[i] / 4);
randomstring += chars.substring(rnum, rnum + 1);
}
return false;
});
$('#button').click(() => {
window.location = `p/${randomPadName()}`;
});
// start the custom js
if (typeof window.customStart === 'function') window.customStart();
return randomstring;
};
$(() => {
$('#go2Name').submit(() => {
const padname = $('#padname').val();
if (padname.length > 0) {
window.location = `p/${encodeURIComponent(padname.trim())}`;
}
else {
alert('Please enter a name');
}
return false;
});
$('#button').click(() => {
window.location = `p/${randomPadName()}`;
});
// start the custom js
if (typeof window.customStart === 'function')
window.customStart();
});
// @license-end

View file

@ -1,16 +1,14 @@
'use strict';
((document) => {
// Set language for l10n
let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/);
if (language) language = language[1];
html10n.bind('indexed', () => {
html10n.localize([language, navigator.language, navigator.userLanguage, 'en']);
});
html10n.bind('localized', () => {
document.documentElement.lang = html10n.getLanguage();
document.documentElement.dir = html10n.getDirection();
});
// Set language for l10n
let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/);
if (language)
language = language[1];
html10n.bind('indexed', () => {
html10n.localize([language, navigator.language, navigator.userLanguage, 'en']);
});
html10n.bind('localized', () => {
document.documentElement.lang = html10n.getLanguage();
document.documentElement.dir = html10n.getDirection();
});
})(document);

View file

@ -1,291 +1,240 @@
import * as Changeset from "./Changeset.js";
import * as attributes from "./attributes.js";
import * as hooks from "./pluginfw/hooks.js";
import AttributeManager from "./AttributeManager.js";
import { padutils as padutils$0 } from "./pad_utils.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
// THIS FILE IS ALSO AN APPJET MODULE: etherpad.collab.ace.linestylefilter
// %APPJET%: import("etherpad.collab.ace.easysync2.Changeset");
// %APPJET%: import("etherpad.admin.plugins");
/**
* 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.
*/
// requires: easysync2.Changeset
// requires: top
// requires: plugins
// requires: undefined
const Changeset = require('./Changeset');
const attributes = require('./attributes');
const hooks = require('./pluginfw/hooks');
const linestylefilter = {};
const AttributeManager = require('./AttributeManager');
const padutils = require('./pad_utils').padutils;
const padutils = { padutils: padutils$0 }.padutils;
linestylefilter.ATTRIB_CLASSES = {
bold: 'tag:b',
italic: 'tag:i',
underline: 'tag:u',
strikethrough: 'tag:s',
bold: 'tag:b',
italic: 'tag:i',
underline: 'tag:u',
strikethrough: 'tag:s',
};
const lineAttributeMarker = 'lineAttribMarker';
exports.lineAttributeMarker = lineAttributeMarker;
linestylefilter.getAuthorClassName = (author) => `author-${author.replace(/[^a-y0-9]/g, (c) => {
if (c === '.') return '-';
return `z${c.charCodeAt(0)}z`;
if (c === '.')
return '-';
return `z${c.charCodeAt(0)}z`;
})}`;
// lineLength is without newline; aline includes newline,
// but may be falsy if lineLength == 0
linestylefilter.getLineStyleFilter = (lineLength, aline, textAndClassFunc, apool) => {
// Plugin Hook to add more Attrib Classes
for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) {
Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses);
}
if (lineLength === 0) return textAndClassFunc;
const nextAfterAuthorColors = textAndClassFunc;
const authorColorFunc = (() => {
const lineEnd = lineLength;
let curIndex = 0;
let extraClasses;
let leftInAuthor;
const attribsToClasses = (attribs) => {
let classes = '';
let isLineAttribMarker = false;
for (const [key, value] of attributes.attribsFromString(attribs, apool)) {
if (!key || !value) continue;
if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) {
isLineAttribMarker = true;
}
if (key === 'author') {
classes += ` ${linestylefilter.getAuthorClassName(value)}`;
} else if (key === 'list') {
classes += ` list:${value}`;
} else if (key === 'start') {
// Needed to introduce the correct Ordered list item start number on import
classes += ` start:${value}`;
} else if (linestylefilter.ATTRIB_CLASSES[key]) {
classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`;
} else {
const results = hooks.callAll('aceAttribsToClasses', {linestylefilter, key, value});
classes += ` ${results.join(' ')}`;
}
}
if (isLineAttribMarker) classes += ` ${lineAttributeMarker}`;
return classes.substring(1);
};
const attrOps = Changeset.deserializeOps(aline);
let attrOpsNext = attrOps.next();
let nextOp, nextOpClasses;
const goNextOp = () => {
nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value;
if (!attrOpsNext.done) attrOpsNext = attrOps.next();
nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
};
goNextOp();
const nextClasses = () => {
if (curIndex < lineEnd) {
extraClasses = nextOpClasses;
leftInAuthor = nextOp.chars;
// Plugin Hook to add more Attrib Classes
for (const attribClasses of hooks.callAll('aceAttribClasses', linestylefilter.ATTRIB_CLASSES)) {
Object.assign(linestylefilter.ATTRIB_CLASSES, attribClasses);
}
if (lineLength === 0)
return textAndClassFunc;
const nextAfterAuthorColors = textAndClassFunc;
const authorColorFunc = (() => {
const lineEnd = lineLength;
let curIndex = 0;
let extraClasses;
let leftInAuthor;
const attribsToClasses = (attribs) => {
let classes = '';
let isLineAttribMarker = false;
for (const [key, value] of attributes.attribsFromString(attribs, apool)) {
if (!key || !value)
continue;
if (!isLineAttribMarker && AttributeManager.lineAttributes.indexOf(key) >= 0) {
isLineAttribMarker = true;
}
if (key === 'author') {
classes += ` ${linestylefilter.getAuthorClassName(value)}`;
}
else if (key === 'list') {
classes += ` list:${value}`;
}
else if (key === 'start') {
// Needed to introduce the correct Ordered list item start number on import
classes += ` start:${value}`;
}
else if (linestylefilter.ATTRIB_CLASSES[key]) {
classes += ` ${linestylefilter.ATTRIB_CLASSES[key]}`;
}
else {
const results = hooks.callAll('aceAttribsToClasses', { linestylefilter, key, value });
classes += ` ${results.join(' ')}`;
}
}
if (isLineAttribMarker)
classes += ` ${lineAttributeMarker}`;
return classes.substring(1);
};
const attrOps = Changeset.deserializeOps(aline);
let attrOpsNext = attrOps.next();
let nextOp, nextOpClasses;
const goNextOp = () => {
nextOp = attrOpsNext.done ? new Changeset.Op() : attrOpsNext.value;
if (!attrOpsNext.done)
attrOpsNext = attrOps.next();
nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
};
goNextOp();
while (nextOp.opcode && nextOpClasses === extraClasses) {
leftInAuthor += nextOp.chars;
goNextOp();
}
}
};
nextClasses();
return (txt, cls) => {
const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', {
linestylefilter,
text: txt,
class: cls,
});
const disableAuthors = (disableAuthColorForThisLine == null ||
disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0];
while (txt.length > 0) {
if (leftInAuthor <= 0 || disableAuthors) {
// prevent infinite loop if something funny's going on
return nextAfterAuthorColors(txt, cls);
}
let spanSize = txt.length;
if (spanSize > leftInAuthor) {
spanSize = leftInAuthor;
}
const curTxt = txt.substring(0, spanSize);
txt = txt.substring(spanSize);
nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses);
curIndex += spanSize;
leftInAuthor -= spanSize;
if (leftInAuthor === 0) {
nextClasses();
}
}
};
})();
return authorColorFunc;
const nextClasses = () => {
if (curIndex < lineEnd) {
extraClasses = nextOpClasses;
leftInAuthor = nextOp.chars;
goNextOp();
while (nextOp.opcode && nextOpClasses === extraClasses) {
leftInAuthor += nextOp.chars;
goNextOp();
}
}
};
nextClasses();
return (txt, cls) => {
const disableAuthColorForThisLine = hooks.callAll('disableAuthorColorsForThisLine', {
linestylefilter,
text: txt,
class: cls,
});
const disableAuthors = (disableAuthColorForThisLine == null ||
disableAuthColorForThisLine.length === 0) ? false : disableAuthColorForThisLine[0];
while (txt.length > 0) {
if (leftInAuthor <= 0 || disableAuthors) {
// prevent infinite loop if something funny's going on
return nextAfterAuthorColors(txt, cls);
}
let spanSize = txt.length;
if (spanSize > leftInAuthor) {
spanSize = leftInAuthor;
}
const curTxt = txt.substring(0, spanSize);
txt = txt.substring(spanSize);
nextAfterAuthorColors(curTxt, (cls && `${cls} `) + extraClasses);
curIndex += spanSize;
leftInAuthor -= spanSize;
if (leftInAuthor === 0) {
nextClasses();
}
}
};
})();
return authorColorFunc;
};
linestylefilter.getAtSignSplitterFilter = (lineText, textAndClassFunc) => {
const at = /@/g;
at.lastIndex = 0;
let splitPoints = null;
let execResult;
while ((execResult = at.exec(lineText))) {
if (!splitPoints) {
splitPoints = [];
const at = /@/g;
at.lastIndex = 0;
let splitPoints = null;
let execResult;
while ((execResult = at.exec(lineText))) {
if (!splitPoints) {
splitPoints = [];
}
splitPoints.push(execResult.index);
}
splitPoints.push(execResult.index);
}
if (!splitPoints) return textAndClassFunc;
return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints);
if (!splitPoints)
return textAndClassFunc;
return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, splitPoints);
};
linestylefilter.getRegexpFilter = (regExp, tag) => (lineText, textAndClassFunc) => {
regExp.lastIndex = 0;
let regExpMatchs = null;
let splitPoints = null;
let execResult;
while ((execResult = regExp.exec(lineText))) {
if (!regExpMatchs) {
regExpMatchs = [];
splitPoints = [];
regExp.lastIndex = 0;
let regExpMatchs = null;
let splitPoints = null;
let execResult;
while ((execResult = regExp.exec(lineText))) {
if (!regExpMatchs) {
regExpMatchs = [];
splitPoints = [];
}
const startIndex = execResult.index;
const regExpMatch = execResult[0];
regExpMatchs.push([startIndex, regExpMatch]);
splitPoints.push(startIndex, startIndex + regExpMatch.length);
}
const startIndex = execResult.index;
const regExpMatch = execResult[0];
regExpMatchs.push([startIndex, regExpMatch]);
splitPoints.push(startIndex, startIndex + regExpMatch.length);
}
if (!regExpMatchs) return textAndClassFunc;
const regExpMatchForIndex = (idx) => {
for (let k = 0; k < regExpMatchs.length; k++) {
const u = regExpMatchs[k];
if (idx >= u[0] && idx < u[0] + u[1].length) {
return u[1];
}
}
return false;
};
const handleRegExpMatchsAfterSplit = (() => {
let curIndex = 0;
return (txt, cls) => {
const txtlen = txt.length;
let newCls = cls;
const regExpMatch = regExpMatchForIndex(curIndex);
if (regExpMatch) {
newCls += ` ${tag}:${regExpMatch}`;
}
textAndClassFunc(txt, newCls);
curIndex += txtlen;
if (!regExpMatchs)
return textAndClassFunc;
const regExpMatchForIndex = (idx) => {
for (let k = 0; k < regExpMatchs.length; k++) {
const u = regExpMatchs[k];
if (idx >= u[0] && idx < u[0] + u[1].length) {
return u[1];
}
}
return false;
};
})();
return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints);
const handleRegExpMatchsAfterSplit = (() => {
let curIndex = 0;
return (txt, cls) => {
const txtlen = txt.length;
let newCls = cls;
const regExpMatch = regExpMatchForIndex(curIndex);
if (regExpMatch) {
newCls += ` ${tag}:${regExpMatch}`;
}
textAndClassFunc(txt, newCls);
curIndex += txtlen;
};
})();
return linestylefilter.textAndClassFuncSplitter(handleRegExpMatchsAfterSplit, splitPoints);
};
linestylefilter.getURLFilter = linestylefilter.getRegexpFilter(padutils.urlRegex, 'url');
linestylefilter.textAndClassFuncSplitter = (func, splitPointsOpt) => {
let nextPointIndex = 0;
let idx = 0;
// don't split at 0
while (splitPointsOpt &&
nextPointIndex < splitPointsOpt.length &&
splitPointsOpt[nextPointIndex] === 0) {
nextPointIndex++;
}
const spanHandler = (txt, cls) => {
if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {
func(txt, cls);
idx += txt.length;
} else {
const splitPoints = splitPointsOpt;
const pointLocInSpan = splitPoints[nextPointIndex] - idx;
const txtlen = txt.length;
if (pointLocInSpan >= txtlen) {
func(txt, cls);
idx += txt.length;
if (pointLocInSpan === txtlen) {
nextPointIndex++;
}
} else {
if (pointLocInSpan > 0) {
func(txt.substring(0, pointLocInSpan), cls);
idx += pointLocInSpan;
}
let nextPointIndex = 0;
let idx = 0;
// don't split at 0
while (splitPointsOpt &&
nextPointIndex < splitPointsOpt.length &&
splitPointsOpt[nextPointIndex] === 0) {
nextPointIndex++;
// recurse
spanHandler(txt.substring(pointLocInSpan), cls);
}
}
};
return spanHandler;
const spanHandler = (txt, cls) => {
if ((!splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {
func(txt, cls);
idx += txt.length;
}
else {
const splitPoints = splitPointsOpt;
const pointLocInSpan = splitPoints[nextPointIndex] - idx;
const txtlen = txt.length;
if (pointLocInSpan >= txtlen) {
func(txt, cls);
idx += txt.length;
if (pointLocInSpan === txtlen) {
nextPointIndex++;
}
}
else {
if (pointLocInSpan > 0) {
func(txt.substring(0, pointLocInSpan), cls);
idx += pointLocInSpan;
}
nextPointIndex++;
// recurse
spanHandler(txt.substring(pointLocInSpan), cls);
}
}
};
return spanHandler;
};
linestylefilter.getFilterStack = (lineText, textAndClassFunc, abrowser) => {
let func = linestylefilter.getURLFilter(lineText, textAndClassFunc);
const hookFilters = hooks.callAll('aceGetFilterStack', {
linestylefilter,
browser: abrowser,
});
hookFilters.map((hookFilter) => {
func = hookFilter(lineText, func);
});
return func;
let func = linestylefilter.getURLFilter(lineText, textAndClassFunc);
const hookFilters = hooks.callAll('aceGetFilterStack', {
linestylefilter,
browser: abrowser,
});
hookFilters.map((hookFilter) => {
func = hookFilter(lineText, func);
});
return func;
};
// domLineObj is like that returned by domline.createDomLine
linestylefilter.populateDomLine = (textLine, aline, apool, domLineObj) => {
// remove final newline from text if any
let text = textLine;
if (text.slice(-1) === '\n') {
text = text.substring(0, text.length - 1);
}
const textAndClassFunc = (tokenText, tokenClass) => {
domLineObj.appendSpan(tokenText, tokenClass);
};
let func = linestylefilter.getFilterStack(text, textAndClassFunc);
func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool);
func(text, '');
// remove final newline from text if any
let text = textLine;
if (text.slice(-1) === '\n') {
text = text.substring(0, text.length - 1);
}
const textAndClassFunc = (tokenText, tokenClass) => {
domLineObj.appendSpan(tokenText, tokenClass);
};
let func = linestylefilter.getFilterStack(text, textAndClassFunc);
func = linestylefilter.getLineStyleFilter(text.length, aline, func, apool);
func(text, '');
};
exports.linestylefilter = linestylefilter;
export { lineAttributeMarker };
export { linestylefilter };

File diff suppressed because it is too large Load diff

View file

@ -1,194 +1,161 @@
'use strict';
exports.showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {
createCountDownElementsIfNecessary($modal);
const timer = createTimerForModal($modal, pad);
$modal.find('#cancelreconnect').one('click', () => {
timer.cancel();
disableAutomaticReconnection($modal);
});
enableAutomaticReconnection($modal);
}
};
const createCountDownElementsIfNecessary = ($modal) => {
const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0;
if (elementsDoNotExist) {
const $defaultMessage = $modal.find('#defaulttext');
const $reconnectButton = $modal.find('#forcereconnect');
// create extra DOM elements, if they don't exist
const $reconnectTimerMessage =
$('<p>')
const elementsDoNotExist = $modal.find('#cancelreconnect').length === 0;
if (elementsDoNotExist) {
const $defaultMessage = $modal.find('#defaulttext');
const $reconnectButton = $modal.find('#forcereconnect');
// create extra DOM elements, if they don't exist
const $reconnectTimerMessage = $('<p>')
.addClass('reconnecttimer')
.append(
$('<span>')
.attr('data-l10n-id', 'pad.modals.reconnecttimer')
.text('Trying to reconnect in'))
.append($('<span>')
.attr('data-l10n-id', 'pad.modals.reconnecttimer')
.text('Trying to reconnect in'))
.append(' ')
.append(
$('<span>')
.addClass('timetoexpire'));
const $cancelReconnect =
$('<button>')
.append($('<span>')
.addClass('timetoexpire'));
const $cancelReconnect = $('<button>')
.attr('id', 'cancelreconnect')
.attr('data-l10n-id', 'pad.modals.cancel')
.text('Cancel');
localize($reconnectTimerMessage);
localize($cancelReconnect);
$reconnectTimerMessage.insertAfter($defaultMessage);
$cancelReconnect.insertAfter($reconnectButton);
}
};
const localize = ($element) => {
html10n.translateElement(html10n.translations, $element.get(0));
};
const createTimerForModal = ($modal, pad) => {
const timeUntilReconnection =
clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry();
const timer = new CountDownTimer(timeUntilReconnection);
timer.onTick((minutes, seconds) => {
updateCountDownTimerMessage($modal, minutes, seconds);
}).onExpire(() => {
const wasANetworkError = $modal.is('.disconnected');
if (wasANetworkError) {
// cannot simply reconnect, client is having issues to establish connection to server
waitUntilClientCanConnectToServerAndThen(() => { forceReconnection($modal); }, pad);
} else {
forceReconnection($modal);
localize($reconnectTimerMessage);
localize($cancelReconnect);
$reconnectTimerMessage.insertAfter($defaultMessage);
$cancelReconnect.insertAfter($reconnectButton);
}
}).start();
return timer;
};
const localize = ($element) => {
html10n.translateElement(html10n.translations, $element.get(0));
};
const createTimerForModal = ($modal, pad) => {
const timeUntilReconnection = clientVars.automaticReconnectionTimeout * reconnectionTries.nextTry();
const timer = new CountDownTimer(timeUntilReconnection);
timer.onTick((minutes, seconds) => {
updateCountDownTimerMessage($modal, minutes, seconds);
}).onExpire(() => {
const wasANetworkError = $modal.is('.disconnected');
if (wasANetworkError) {
// cannot simply reconnect, client is having issues to establish connection to server
waitUntilClientCanConnectToServerAndThen(() => { forceReconnection($modal); }, pad);
}
else {
forceReconnection($modal);
}
}).start();
return timer;
};
const disableAutomaticReconnection = ($modal) => {
toggleAutomaticReconnectionOption($modal, true);
toggleAutomaticReconnectionOption($modal, true);
};
const enableAutomaticReconnection = ($modal) => {
toggleAutomaticReconnectionOption($modal, false);
toggleAutomaticReconnectionOption($modal, false);
};
const toggleAutomaticReconnectionOption = ($modal, disableAutomaticReconnect) => {
$modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect);
$modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect);
$modal.find('#cancelreconnect, .reconnecttimer').toggleClass('hidden', disableAutomaticReconnect);
$modal.find('#defaulttext').toggleClass('hidden', !disableAutomaticReconnect);
};
const waitUntilClientCanConnectToServerAndThen = (callback, pad) => {
whenConnectionIsRestablishedWithServer(callback, pad);
pad.socket.connect();
whenConnectionIsRestablishedWithServer(callback, pad);
pad.socket.connect();
};
const whenConnectionIsRestablishedWithServer = (callback, pad) => {
// only add listener for the first try, don't need to add another listener
// on every unsuccessful try
if (reconnectionTries.counter === 1) {
pad.socket.once('connect', callback);
}
// only add listener for the first try, don't need to add another listener
// on every unsuccessful try
if (reconnectionTries.counter === 1) {
pad.socket.once('connect', callback);
}
};
const forceReconnection = ($modal) => {
$modal.find('#forcereconnect').click();
$modal.find('#forcereconnect').click();
};
const updateCountDownTimerMessage = ($modal, minutes, seconds) => {
minutes = minutes < 10 ? `0${minutes}` : minutes;
seconds = seconds < 10 ? `0${seconds}` : seconds;
$modal.find('.timetoexpire').text(`${minutes}:${seconds}`);
minutes = minutes < 10 ? `0${minutes}` : minutes;
seconds = seconds < 10 ? `0${seconds}` : seconds;
$modal.find('.timetoexpire').text(`${minutes}:${seconds}`);
};
// store number of tries to reconnect to server, in order to increase time to wait
// until next try
const reconnectionTries = {
counter: 0,
nextTry() {
// double the time to try to reconnect on every time reconnection fails
const nextCounterFactor = 2 ** this.counter;
this.counter++;
return nextCounterFactor;
},
counter: 0,
nextTry() {
// double the time to try to reconnect on every time reconnection fails
const nextCounterFactor = 2 ** this.counter;
this.counter++;
return nextCounterFactor;
},
};
// Timer based on http://stackoverflow.com/a/20618517.
// duration: how many **seconds** until the timer ends
// granularity (optional): how many **milliseconds**
// between each 'tick' of timer. Default: 1000ms (1s)
const CountDownTimer = function (duration, granularity) {
this.duration = duration;
this.granularity = granularity || 1000;
this.running = false;
this.onTickCallbacks = [];
this.onExpireCallbacks = [];
this.duration = duration;
this.granularity = granularity || 1000;
this.running = false;
this.onTickCallbacks = [];
this.onExpireCallbacks = [];
};
CountDownTimer.prototype.start = function () {
if (this.running) {
return;
}
this.running = true;
const start = Date.now();
const that = this;
let diff;
const timer = () => {
diff = that.duration - Math.floor((Date.now() - start) / 1000);
if (diff > 0) {
that.timeoutId = setTimeout(timer, that.granularity);
that.tick(diff);
} else {
that.running = false;
that.tick(0);
that.expire();
if (this.running) {
return;
}
};
timer();
this.running = true;
const start = Date.now();
const that = this;
let diff;
const timer = () => {
diff = that.duration - Math.floor((Date.now() - start) / 1000);
if (diff > 0) {
that.timeoutId = setTimeout(timer, that.granularity);
that.tick(diff);
}
else {
that.running = false;
that.tick(0);
that.expire();
}
};
timer();
};
CountDownTimer.prototype.tick = function (diff) {
const obj = CountDownTimer.parse(diff);
this.onTickCallbacks.forEach(function (callback) {
callback.call(this, obj.minutes, obj.seconds);
}, this);
const obj = CountDownTimer.parse(diff);
this.onTickCallbacks.forEach(function (callback) {
callback.call(this, obj.minutes, obj.seconds);
}, this);
};
CountDownTimer.prototype.expire = function () {
this.onExpireCallbacks.forEach(function (callback) {
callback.call(this);
}, this);
this.onExpireCallbacks.forEach(function (callback) {
callback.call(this);
}, this);
};
CountDownTimer.prototype.onTick = function (callback) {
if (typeof callback === 'function') {
this.onTickCallbacks.push(callback);
}
return this;
if (typeof callback === 'function') {
this.onTickCallbacks.push(callback);
}
return this;
};
CountDownTimer.prototype.onExpire = function (callback) {
if (typeof callback === 'function') {
this.onExpireCallbacks.push(callback);
}
return this;
if (typeof callback === 'function') {
this.onExpireCallbacks.push(callback);
}
return this;
};
CountDownTimer.prototype.cancel = function () {
this.running = false;
clearTimeout(this.timeoutId);
return this;
this.running = false;
clearTimeout(this.timeoutId);
return this;
};
CountDownTimer.parse = (seconds) => ({
minutes: (seconds / 60) | 0,
seconds: (seconds % 60) | 0,
minutes: (seconds / 60) | 0,
seconds: (seconds % 60) | 0,
});
export const showCountDownTimerToReconnectOnModal = ($modal, pad) => {
if (clientVars.automaticReconnectionTimeout && $modal.is('.with_reconnect_timer')) {
createCountDownElementsIfNecessary($modal);
const timer = createTimerForModal($modal, pad);
$modal.find('#cancelreconnect').one('click', () => {
timer.cancel();
disableAutomaticReconnection($modal);
});
enableAutomaticReconnection($modal);
}
};

View file

@ -1,11 +1,10 @@
import { padmodals as padmodals$0 } from "./pad_modals.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,72 +20,65 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const padmodals = require('./pad_modals').padmodals;
const padmodals = { padmodals: padmodals$0 }.padmodals;
const padconnectionstatus = (() => {
let status = {
what: 'connecting',
};
const self = {
init: () => {
$('button#forcereconnect').click(() => {
window.location.reload();
});
},
connected: () => {
status = {
what: 'connected',
};
padmodals.showModal('connected');
padmodals.hideOverlay();
},
reconnecting: () => {
status = {
what: 'reconnecting',
};
padmodals.showModal('reconnecting');
padmodals.showOverlay();
},
disconnected: (msg) => {
if (status.what === 'disconnected') return;
status = {
what: 'disconnected',
why: msg,
};
// These message IDs correspond to localized strings that are presented to the user. If a new
// message ID is added here then a new div must be added to src/templates/pad.html and the
// corresponding l10n IDs must be added to the language files in src/locales.
const knownReasons = [
'badChangeset',
'corruptPad',
'deleted',
'disconnected',
'initsocketfail',
'looping',
'rateLimited',
'rejected',
'slowcommit',
'unauth',
'userdup',
];
let k = String(msg);
if (knownReasons.indexOf(k) === -1) {
// Fall back to a generic message.
k = 'disconnected';
}
padmodals.showModal(k);
padmodals.showOverlay();
},
isFullyConnected: () => status.what === 'connected',
getStatus: () => status,
};
return self;
let status = {
what: 'connecting',
};
const self = {
init: () => {
$('button#forcereconnect').click(() => {
window.location.reload();
});
},
connected: () => {
status = {
what: 'connected',
};
padmodals.showModal('connected');
padmodals.hideOverlay();
},
reconnecting: () => {
status = {
what: 'reconnecting',
};
padmodals.showModal('reconnecting');
padmodals.showOverlay();
},
disconnected: (msg) => {
if (status.what === 'disconnected')
return;
status = {
what: 'disconnected',
why: msg,
};
// These message IDs correspond to localized strings that are presented to the user. If a new
// message ID is added here then a new div must be added to src/templates/pad.html and the
// corresponding l10n IDs must be added to the language files in src/locales.
const knownReasons = [
'badChangeset',
'corruptPad',
'deleted',
'disconnected',
'initsocketfail',
'looping',
'rateLimited',
'rejected',
'slowcommit',
'unauth',
'userdup',
];
let k = String(msg);
if (knownReasons.indexOf(k) === -1) {
// Fall back to a generic message.
k = 'disconnected';
}
padmodals.showModal(k);
padmodals.showOverlay();
},
isFullyConnected: () => status.what === 'connected',
getStatus: () => status,
};
return self;
})();
exports.padconnectionstatus = padconnectionstatus;
export { padconnectionstatus };

View file

@ -1,5 +1,5 @@
import * as padUtils from "./pad_utils.js";
'use strict';
/**
* Copyright 2009 Google Inc.
*
@ -15,56 +15,50 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const Cookies = require('./pad_utils').Cookies;
exports.padcookie = new class {
constructor() {
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
}
init() {
const prefs = this.readPrefs_() || {};
delete prefs.userId;
delete prefs.name;
delete prefs.colorId;
this.writePrefs_(prefs);
// Re-read the saved cookie to test if cookies are enabled.
if (this.readPrefs_() == null) {
$.gritter.add({
title: 'Error',
text: html10n.get('pad.noCookie'),
sticky: true,
class_name: 'error',
});
const Cookies = { Cookies: padUtils }.Cookies;
export const padcookie = new class {
constructor() {
this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
}
}
readPrefs_() {
try {
const json = Cookies.get(this.cookieName_);
if (json == null) return null;
return JSON.parse(json);
} catch (e) {
return null;
init() {
const prefs = this.readPrefs_() || {};
delete prefs.userId;
delete prefs.name;
delete prefs.colorId;
this.writePrefs_(prefs);
// Re-read the saved cookie to test if cookies are enabled.
if (this.readPrefs_() == null) {
$.gritter.add({
title: 'Error',
text: html10n.get('pad.noCookie'),
sticky: true,
class_name: 'error',
});
}
}
readPrefs_() {
try {
const json = Cookies.get(this.cookieName_);
if (json == null)
return null;
return JSON.parse(json);
}
catch (e) {
return null;
}
}
writePrefs_(prefs) {
Cookies.set(this.cookieName_, JSON.stringify(prefs), { expires: 365 * 100 });
}
getPref(prefName) {
return this.readPrefs_()[prefName];
}
setPref(prefName, value) {
const prefs = this.readPrefs_();
prefs[prefName] = value;
this.writePrefs_(prefs);
}
clear() {
this.writePrefs_({});
}
}
writePrefs_(prefs) {
Cookies.set(this.cookieName_, JSON.stringify(prefs), {expires: 365 * 100});
}
getPref(prefName) {
return this.readPrefs_()[prefName];
}
setPref(prefName, value) {
const prefs = this.readPrefs_();
prefs[prefName] = value;
this.writePrefs_(prefs);
}
clear() {
this.writePrefs_({});
}
}();

View file

@ -1,481 +1,434 @@
import browser from "./vendors/browser.js";
import * as hooks from "./pluginfw/hooks.js";
import { padutils as padutils$0 } from "./pad_utils.js";
import { padeditor as padeditor$0 } from "./pad_editor.js";
import * as padsavedrevs from "./pad_savedrevs.js";
import _ from "underscore";
import "./vendors/nice-select.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.
*/
const browser = require('./vendors/browser');
const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const padeditor = require('./pad_editor').padeditor;
const padsavedrevs = require('./pad_savedrevs');
const _ = require('underscore');
require('./vendors/nice-select');
const padutils = { padutils: padutils$0 }.padutils;
const padeditor = { padeditor: padeditor$0 }.padeditor;
class ToolbarItem {
constructor(element) {
this.$el = element;
}
getCommand() {
return this.$el.attr('data-key');
}
getValue() {
if (this.isSelect()) {
return this.$el.find('select').val();
constructor(element) {
this.$el = element;
}
}
setValue(val) {
if (this.isSelect()) {
return this.$el.find('select').val(val);
getCommand() {
return this.$el.attr('data-key');
}
}
getType() {
return this.$el.attr('data-type');
}
isSelect() {
return this.getType() === 'select';
}
isButton() {
return this.getType() === 'button';
}
bind(callback) {
if (this.isButton()) {
this.$el.click((event) => {
$(':focus').blur();
callback(this.getCommand(), this);
event.preventDefault();
});
} else if (this.isSelect()) {
this.$el.find('select').change(() => {
callback(this.getCommand(), this);
});
getValue() {
if (this.isSelect()) {
return this.$el.find('select').val();
}
}
setValue(val) {
if (this.isSelect()) {
return this.$el.find('select').val(val);
}
}
getType() {
return this.$el.attr('data-type');
}
isSelect() {
return this.getType() === 'select';
}
isButton() {
return this.getType() === 'button';
}
bind(callback) {
if (this.isButton()) {
this.$el.click((event) => {
$(':focus').blur();
callback(this.getCommand(), this);
event.preventDefault();
});
}
else if (this.isSelect()) {
this.$el.find('select').change(() => {
callback(this.getCommand(), this);
});
}
}
}
}
const syncAnimation = (() => {
const SYNCING = -100;
const DONE = 100;
let state = DONE;
const fps = 25;
const step = 1 / fps;
const T_START = -0.5;
const T_FADE = 1.0;
const T_GONE = 1.5;
const animator = padutils.makeAnimationScheduler(() => {
if (state === SYNCING || state === DONE) {
return false;
} else if (state >= T_GONE) {
state = DONE;
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'none');
return false;
} else if (state < 0) {
state += step;
if (state >= 0) {
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'block').css('opacity', 1);
}
return true;
} else {
state += step;
if (state >= T_FADE) {
$('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
}
return true;
}
}, step * 1000);
return {
syncing: () => {
state = SYNCING;
$('#syncstatussyncing').css('display', 'block');
$('#syncstatusdone').css('display', 'none');
},
done: () => {
state = T_START;
animator.scheduleAnimation();
},
};
})();
exports.padeditbar = new class {
constructor() {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
}
init() {
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
this.enable();
$('#editbar [data-key]').each((i, elt) => {
$(elt).unbind('click');
new ToolbarItem($(elt)).bind((command, item) => {
this.triggerCommand(command, item);
});
});
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
this._bodyKeyEvent(evt);
});
$('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons');
});
this.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
this._registerDefaultCommands();
hooks.callAll('postToolbarInit', {
toolbar: this,
ace: padeditor.ace,
});
/*
* On safari, the dropdown in the toolbar gets hidden because of toolbar
* overflow:hidden property. This is a bug from Safari: any children with
* position:fixed (like the dropdown) should be displayed no matter
* overflow:hidden on parent
*/
if (!browser.safari) {
$('select').niceSelect();
}
// When editor is scrolled, we add a class to style the editbar differently
$('iframe[name="ace_outer"]').contents().scroll((ev) => {
$('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
});
}
isEnabled() { return true; }
disable() {
$('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
}
enable() {
$('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
}
registerCommand(cmd, callback) {
this.commands[cmd] = callback;
return this;
}
registerDropdownCommand(cmd, dropdown) {
dropdown = dropdown || cmd;
this.dropdowns.push(dropdown);
this.registerCommand(cmd, () => {
this.toggleDropDown(dropdown);
});
}
registerAceCommand(cmd, callback) {
this.registerCommand(cmd, (cmd, ace, item) => {
ace.callWithAce((ace) => {
callback(cmd, ace, item);
}, cmd, true);
});
}
triggerCommand(cmd, item) {
if (this.isEnabled() && this.commands[cmd]) {
this.commands[cmd](cmd, padeditor.ace, item);
}
if (padeditor.ace) padeditor.ace.focus();
}
// cb is deprecated (this function is synchronous so a callback is unnecessary).
toggleDropDown(moduleName, cb = null) {
let cbErr = null;
try {
// do nothing if users are sticked
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
return;
}
$('.nice-select').removeClass('open');
$('.toolbar-popup').removeClass('popup-show');
// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
for (const thisModuleName of this.dropdowns) {
// skip the userlist
if (thisModuleName === 'users') continue;
const module = $(`#${thisModuleName}`);
// skip any "force reconnect" message
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
if (isAForceReconnectMessage) continue;
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
const SYNCING = -100;
const DONE = 100;
let state = DONE;
const fps = 25;
const step = 1 / fps;
const T_START = -0.5;
const T_FADE = 1.0;
const T_GONE = 1.5;
const animator = padutils.makeAnimationScheduler(() => {
if (state === SYNCING || state === DONE) {
return false;
}
} else {
// hide all modules that are not selected and remove highlighting
// respectively add highlighting to the corresponding button
for (const thisModuleName of this.dropdowns) {
const module = $(`#${thisModuleName}`);
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
} else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
}
else if (state >= T_GONE) {
state = DONE;
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'none');
return false;
}
}
} catch (err) {
cbErr = err || new Error(err);
} finally {
if (cb) Promise.resolve().then(() => cb(cbErr));
}
}
setSyncStatus(status) {
if (status === 'syncing') {
syncAnimation.syncing();
} else if (status === 'done') {
syncAnimation.done();
}
}
setEmbedLinks() {
const padUrl = window.location.href.split('?')[0];
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';
const props = 'width="100%" height="600" frameborder="0"';
if ($('#readonlyinput').is(':checked')) {
const urlParts = padUrl.split('/');
urlParts.pop();
const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;
$('#embedinput')
.val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#linkinput').val(readonlyLink);
} else {
$('#embedinput')
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl);
}
}
checkAllIconsAreDisplayedInToolbar() {
// reset style
$('.toolbar').removeClass('cropped');
$('body').removeClass('mobile-layout');
const menuLeft = $('.toolbar .menu_left')[0];
// this is approximate, we cannot measure it because on mobile
// Layout it takes the full width on the bottom of the page
const menuRightWidth = 280;
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout');
}
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped');
}
}
_bodyKeyEvent(evt) {
// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already..
// Close any dropdowns we have open..
this.toggleDropDown('none');
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
// Check we're on a pad and not on the timeslider
// Or some other window I haven't thought about!
if (typeof pad === 'undefined') {
// Timeslider probably..
$('#editorcontainerbox').focus(); // Focus back onto the pad
} else {
padeditor.ace.focus(); // Sends focus back to pad
// The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault();
else if (state < 0) {
state += step;
if (state >= 0) {
$('#syncstatussyncing').css('display', 'none');
$('#syncstatusdone').css('display', 'block').css('opacity', 1);
}
return true;
}
} else {
// Focus on the editbar :)
const firstEditbarElement = parent.parent.$('#editbar button').first();
$(evt.currentTarget).blur();
firstEditbarElement.focus();
evt.preventDefault();
}
}
// Are we in the toolbar??
if ($(':focus').parents('.toolbar').length === 1) {
// On arrow keys go to next/previous button item in editbar
if (evt.keyCode !== 39 && evt.keyCode !== 37) return;
// Get all the focusable items in the editbar
const focusItems = $('#editbar').find('button, select');
// On left arrow move to next button in editbar
if (evt.keyCode === 37) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
this._editbarPosition--;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition === -1) this._editbarPosition = focusItems.length - 1;
$(focusItems[this._editbarPosition]).focus();
}
// On right arrow move to next button in editbar
if (evt.keyCode === 39) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input') return;
this._editbarPosition++;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition >= focusItems.length) this._editbarPosition = 0;
$(focusItems[this._editbarPosition]).focus();
}
}
}
_registerDefaultCommands() {
this.registerDropdownCommand('showusers', 'users');
this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
this.registerCommand('settings', () => {
this.toggleDropDown('settings');
$('#options-stickychat').focus();
});
this.registerCommand('import_export', () => {
this.toggleDropDown('import_export');
// If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) {
setTimeout(() => {
$('#importfileinput').focus();
}, 100);
} else {
$('.exportlink').first().focus();
}
});
this.registerCommand('showusers', () => {
this.toggleDropDown('users');
$('#myusernameedit').focus();
});
this.registerCommand('embed', () => {
this.setEmbedLinks();
this.toggleDropDown('embed');
$('#linkinput').focus().select();
});
this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow();
});
this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`;
});
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
else {
state += step;
if (state >= T_FADE) {
$('#syncstatusdone').css('opacity', (T_GONE - state) / (T_GONE - T_FADE));
}
return true;
}
}, step * 1000);
return {
syncing: () => {
state = SYNCING;
$('#syncstatussyncing').css('display', 'block');
$('#syncstatusdone').css('display', 'none');
},
done: () => {
state = T_START;
animator.scheduleAnimation();
},
};
this.registerAceCommand('bold', aceAttributeCommand);
this.registerAceCommand('italic', aceAttributeCommand);
this.registerAceCommand('underline', aceAttributeCommand);
this.registerAceCommand('strikethrough', aceAttributeCommand);
this.registerAceCommand('undo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('redo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('insertunorderedlist', (cmd, ace) => {
ace.ace_doInsertUnorderedList();
});
this.registerAceCommand('insertorderedlist', (cmd, ace) => {
ace.ace_doInsertOrderedList();
});
this.registerAceCommand('indent', (cmd, ace) => {
if (!ace.ace_doIndentOutdent(false)) {
ace.ace_doInsertUnorderedList();
}
});
this.registerAceCommand('outdent', (cmd, ace) => {
ace.ace_doIndentOutdent(true);
});
this.registerAceCommand('clearauthorship', (cmd, ace) => {
// If we have the whole document selected IE control A has been hit
const rep = ace.ace_getRep();
let doPrompt = false;
const lastChar = rep.lines.atIndex(rep.lines.length() - 1).width - 1;
const lastLineIndex = rep.lines.length() - 1;
if (rep.selStart[0] === 0 && rep.selStart[1] === 0) {
// nesting intentionally here to make things readable
if (rep.selEnd[0] === lastLineIndex && rep.selEnd[1] === lastChar) {
doPrompt = true;
})();
export const padeditbar = new class {
constructor() {
this._editbarPosition = 0;
this.commands = {};
this.dropdowns = [];
}
init() {
$('#editbar .editbarbutton').attr('unselectable', 'on'); // for IE
this.enable();
$('#editbar [data-key]').each((i, elt) => {
$(elt).unbind('click');
new ToolbarItem($(elt)).bind((command, item) => {
this.triggerCommand(command, item);
});
});
$('body:not(#editorcontainerbox)').on('keydown', (evt) => {
this._bodyKeyEvent(evt);
});
$('.show-more-icon-btn').click(() => {
$('.toolbar').toggleClass('full-icons');
});
this.checkAllIconsAreDisplayedInToolbar();
$(window).resize(_.debounce(() => this.checkAllIconsAreDisplayedInToolbar(), 100));
this._registerDefaultCommands();
hooks.callAll('postToolbarInit', {
toolbar: this,
ace: padeditor.ace,
});
/*
* On safari, the dropdown in the toolbar gets hidden because of toolbar
* overflow:hidden property. This is a bug from Safari: any children with
* position:fixed (like the dropdown) should be displayed no matter
* overflow:hidden on parent
*/
if (!browser.safari) {
$('select').niceSelect();
}
}
/*
* NOTICE: This command isn't fired on Control Shift C.
* I intentionally didn't create duplicate code because if you are hitting
* Control Shift C we make the assumption you are a "power user"
* and as such we assume you don't need the prompt to bug you each time!
* This does make wonder if it's worth having a checkbox to avoid being
* prompted again but that's probably overkill for this contribution.
*/
// if we don't have any text selected, we have a caret or we have already said to prompt
if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) {
if (window.confirm(html10n.get('pad.editbar.clearcolors'))) {
ace.ace_performDocumentApplyAttributesToCharRange(0, ace.ace_getRep().alltext.length, [
['author', ''],
]);
// When editor is scrolled, we add a class to style the editbar differently
$('iframe[name="ace_outer"]').contents().scroll((ev) => {
$('#editbar').toggleClass('editor-scrolled', $(ev.currentTarget).scrollTop() > 2);
});
}
isEnabled() { return true; }
disable() {
$('#editbar').addClass('disabledtoolbar').removeClass('enabledtoolbar');
}
enable() {
$('#editbar').addClass('enabledtoolbar').removeClass('disabledtoolbar');
}
registerCommand(cmd, callback) {
this.commands[cmd] = callback;
return this;
}
registerDropdownCommand(cmd, dropdown) {
dropdown = dropdown || cmd;
this.dropdowns.push(dropdown);
this.registerCommand(cmd, () => {
this.toggleDropDown(dropdown);
});
}
registerAceCommand(cmd, callback) {
this.registerCommand(cmd, (cmd, ace, item) => {
ace.callWithAce((ace) => {
callback(cmd, ace, item);
}, cmd, true);
});
}
triggerCommand(cmd, item) {
if (this.isEnabled() && this.commands[cmd]) {
this.commands[cmd](cmd, padeditor.ace, item);
}
} else {
ace.ace_setAttributeOnSelection('author', '');
}
});
this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1,
document.referrer.lastIndexOf('/')) === 'p') {
document.location = document.referrer;
} else {
document.location = document.location.href
.substring(0, document.location.href.lastIndexOf('/'));
}
});
}
if (padeditor.ace)
padeditor.ace.focus();
}
// cb is deprecated (this function is synchronous so a callback is unnecessary).
toggleDropDown(moduleName, cb = null) {
let cbErr = null;
try {
// do nothing if users are sticked
if (moduleName === 'users' && $('#users').hasClass('stickyUsers')) {
return;
}
$('.nice-select').removeClass('open');
$('.toolbar-popup').removeClass('popup-show');
// hide all modules and remove highlighting of all buttons
if (moduleName === 'none') {
for (const thisModuleName of this.dropdowns) {
// skip the userlist
if (thisModuleName === 'users')
continue;
const module = $(`#${thisModuleName}`);
// skip any "force reconnect" message
const isAForceReconnectMessage = module.find('button#forcereconnect:visible').length > 0;
if (isAForceReconnectMessage)
continue;
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
}
}
else {
// hide all modules that are not selected and remove highlighting
// respectively add highlighting to the corresponding button
for (const thisModuleName of this.dropdowns) {
const module = $(`#${thisModuleName}`);
if (module.hasClass('popup-show')) {
$(`li[data-key=${thisModuleName}] > a`).removeClass('selected');
module.removeClass('popup-show');
}
else if (thisModuleName === moduleName) {
$(`li[data-key=${thisModuleName}] > a`).addClass('selected');
module.addClass('popup-show');
}
}
}
}
catch (err) {
cbErr = err || new Error(err);
}
finally {
if (cb)
Promise.resolve().then(() => cb(cbErr));
}
}
setSyncStatus(status) {
if (status === 'syncing') {
syncAnimation.syncing();
}
else if (status === 'done') {
syncAnimation.done();
}
}
setEmbedLinks() {
const padUrl = window.location.href.split('?')[0];
const params = '?showControls=true&showChat=true&showLineNumbers=true&useMonospaceFont=false';
const props = 'width="100%" height="600" frameborder="0"';
if ($('#readonlyinput').is(':checked')) {
const urlParts = padUrl.split('/');
urlParts.pop();
const readonlyLink = `${urlParts.join('/')}/${clientVars.readOnlyId}`;
$('#embedinput')
.val(`<iframe name="embed_readonly" src="${readonlyLink}${params}" ${props}></iframe>`);
$('#linkinput').val(readonlyLink);
}
else {
$('#embedinput')
.val(`<iframe name="embed_readwrite" src="${padUrl}${params}" ${props}></iframe>`);
$('#linkinput').val(padUrl);
}
}
checkAllIconsAreDisplayedInToolbar() {
// reset style
$('.toolbar').removeClass('cropped');
$('body').removeClass('mobile-layout');
const menuLeft = $('.toolbar .menu_left')[0];
// this is approximate, we cannot measure it because on mobile
// Layout it takes the full width on the bottom of the page
const menuRightWidth = 280;
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width() - menuRightWidth ||
$('.toolbar').width() < 1000) {
$('body').addClass('mobile-layout');
}
if (menuLeft && menuLeft.scrollWidth > $('.toolbar').width()) {
$('.toolbar').addClass('cropped');
}
}
_bodyKeyEvent(evt) {
// If the event is Alt F9 or Escape & we're already in the editbar menu
// Send the users focus back to the pad
if ((evt.keyCode === 120 && evt.altKey) || evt.keyCode === 27) {
if ($(':focus').parents('.toolbar').length === 1) {
// If we're in the editbar already..
// Close any dropdowns we have open..
this.toggleDropDown('none');
// Shift focus away from any drop downs
$(':focus').blur(); // required to do not try to remove!
// Check we're on a pad and not on the timeslider
// Or some other window I haven't thought about!
if (typeof pad === 'undefined') {
// Timeslider probably..
$('#editorcontainerbox').focus(); // Focus back onto the pad
}
else {
padeditor.ace.focus(); // Sends focus back to pad
// The above focus doesn't always work in FF, you have to hit enter afterwards
evt.preventDefault();
}
}
else {
// Focus on the editbar :)
const firstEditbarElement = parent.parent.$('#editbar button').first();
$(evt.currentTarget).blur();
firstEditbarElement.focus();
evt.preventDefault();
}
}
// Are we in the toolbar??
if ($(':focus').parents('.toolbar').length === 1) {
// On arrow keys go to next/previous button item in editbar
if (evt.keyCode !== 39 && evt.keyCode !== 37)
return;
// Get all the focusable items in the editbar
const focusItems = $('#editbar').find('button, select');
// On left arrow move to next button in editbar
if (evt.keyCode === 37) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input')
return;
this._editbarPosition--;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition === -1)
this._editbarPosition = focusItems.length - 1;
$(focusItems[this._editbarPosition]).focus();
}
// On right arrow move to next button in editbar
if (evt.keyCode === 39) {
// If a dropdown is visible or we're in an input don't move to the next button
if ($('.popup').is(':visible') || evt.target.localName === 'input')
return;
this._editbarPosition++;
// Allow focus to shift back to end of row and start of row
if (this._editbarPosition >= focusItems.length)
this._editbarPosition = 0;
$(focusItems[this._editbarPosition]).focus();
}
}
}
_registerDefaultCommands() {
this.registerDropdownCommand('showusers', 'users');
this.registerDropdownCommand('settings');
this.registerDropdownCommand('connectivity');
this.registerDropdownCommand('import_export');
this.registerDropdownCommand('embed');
this.registerCommand('settings', () => {
this.toggleDropDown('settings');
$('#options-stickychat').focus();
});
this.registerCommand('import_export', () => {
this.toggleDropDown('import_export');
// If Import file input exists then focus on it..
if ($('#importfileinput').length !== 0) {
setTimeout(() => {
$('#importfileinput').focus();
}, 100);
}
else {
$('.exportlink').first().focus();
}
});
this.registerCommand('showusers', () => {
this.toggleDropDown('users');
$('#myusernameedit').focus();
});
this.registerCommand('embed', () => {
this.setEmbedLinks();
this.toggleDropDown('embed');
$('#linkinput').focus().select();
});
this.registerCommand('savedRevision', () => {
padsavedrevs.saveNow();
});
this.registerCommand('showTimeSlider', () => {
document.location = `${document.location.pathname}/timeslider`;
});
const aceAttributeCommand = (cmd, ace) => {
ace.ace_toggleAttributeOnSelection(cmd);
};
this.registerAceCommand('bold', aceAttributeCommand);
this.registerAceCommand('italic', aceAttributeCommand);
this.registerAceCommand('underline', aceAttributeCommand);
this.registerAceCommand('strikethrough', aceAttributeCommand);
this.registerAceCommand('undo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('redo', (cmd, ace) => {
ace.ace_doUndoRedo(cmd);
});
this.registerAceCommand('insertunorderedlist', (cmd, ace) => {
ace.ace_doInsertUnorderedList();
});
this.registerAceCommand('insertorderedlist', (cmd, ace) => {
ace.ace_doInsertOrderedList();
});
this.registerAceCommand('indent', (cmd, ace) => {
if (!ace.ace_doIndentOutdent(false)) {
ace.ace_doInsertUnorderedList();
}
});
this.registerAceCommand('outdent', (cmd, ace) => {
ace.ace_doIndentOutdent(true);
});
this.registerAceCommand('clearauthorship', (cmd, ace) => {
// If we have the whole document selected IE control A has been hit
const rep = ace.ace_getRep();
let doPrompt = false;
const lastChar = rep.lines.atIndex(rep.lines.length() - 1).width - 1;
const lastLineIndex = rep.lines.length() - 1;
if (rep.selStart[0] === 0 && rep.selStart[1] === 0) {
// nesting intentionally here to make things readable
if (rep.selEnd[0] === lastLineIndex && rep.selEnd[1] === lastChar) {
doPrompt = true;
}
}
/*
* NOTICE: This command isn't fired on Control Shift C.
* I intentionally didn't create duplicate code because if you are hitting
* Control Shift C we make the assumption you are a "power user"
* and as such we assume you don't need the prompt to bug you each time!
* This does make wonder if it's worth having a checkbox to avoid being
* prompted again but that's probably overkill for this contribution.
*/
// if we don't have any text selected, we have a caret or we have already said to prompt
if ((!(rep.selStart && rep.selEnd)) || ace.ace_isCaret() || doPrompt) {
if (window.confirm(html10n.get('pad.editbar.clearcolors'))) {
ace.ace_performDocumentApplyAttributesToCharRange(0, ace.ace_getRep().alltext.length, [
['author', ''],
]);
}
}
else {
ace.ace_setAttributeOnSelection('author', '');
}
});
this.registerCommand('timeslider_returnToPad', (cmd) => {
if (document.referrer.length > 0 &&
document.referrer.substring(document.referrer.lastIndexOf('/') - 1, document.referrer.lastIndexOf('/')) === 'p') {
document.location = document.referrer;
}
else {
document.location = document.location.href
.substring(0, document.location.href.lastIndexOf('/'));
}
});
}
}();

View file

@ -1,10 +1,11 @@
import * as padUtils from "./pad_utils.js";
import { padcookie as padcookie$0 } from "./pad_cookie.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -20,191 +21,177 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const Cookies = require('./pad_utils').Cookies;
const padcookie = require('./pad_cookie').padcookie;
const padutils = require('./pad_utils').padutils;
const Cookies = { Cookies: padUtils }.Cookies;
const padcookie = { padcookie: padcookie$0 }.padcookie;
const padutils = { padutils: padUtils }.padutils;
const padeditor = (() => {
let Ace2Editor = undefined;
let pad = undefined;
let settings = undefined;
const self = {
ace: null,
// this is accessed directly from other files
viewZoom: 100,
init: async (initialViewOptions, _pad) => {
Ace2Editor = require('./ace').Ace2Editor;
pad = _pad;
settings = pad.settings;
self.ace = new Ace2Editor();
await self.ace.init('editorcontainer', '');
$('#editorloadingbox').hide();
// Listen for clicks on sidediv items
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
$outerdoc.find('#sidedivinner').on('click', 'div', function () {
const targetLineNumber = $(this).index() + 1;
window.location.hash = `L${targetLineNumber}`;
});
exports.focusOnLine(self.ace);
self.ace.setProperty('wraps', true);
self.initViewOptions();
self.setViewOptions(initialViewOptions);
// view bar
$('#viewbarcontents').show();
},
initViewOptions: () => {
// Line numbers
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));
});
// Author colors
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck'));
pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));
});
// Right to left
padutils.bindCheckboxChange($('#options-rtlcheck'), () => {
pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck')));
});
html10n.bind('localized', () => {
pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));
});
// font family change
$('#viewfontmenu').change(() => {
pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());
});
// Language
html10n.bind('localized', () => {
$('#languagemenu').val(html10n.getLanguage());
// translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
// this does not interfere with html10n's normal value-setting because
// html10n just ingores <input>s
// also, a value which has been set by the user will be not overwritten
// since a user-edited <input> does *not* have the editempty-class
$('input[data-l10n-id]').each((key, input) => {
input = $(input);
if (input.hasClass('editempty')) {
input.val(html10n.get(input.attr('data-l10n-id')));
}
});
});
$('#languagemenu').val(html10n.getLanguage());
$('#languagemenu').change(() => {
Cookies.set('language', $('#languagemenu').val());
window.html10n.localize([$('#languagemenu').val(), 'en']);
if ($('select').niceSelect) {
$('select').niceSelect('update');
}
});
},
setViewOptions: (newOptions) => {
const getOption = (key, defaultValue) => {
const value = String(newOptions[key]);
if (value === 'true') return true;
if (value === 'false') return false;
return defaultValue;
};
let v;
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
}
self.ace.setProperty('textface', newOptions.padFontFamily || '');
},
dispose: () => {
if (self.ace) {
self.ace.destroy();
self.ace = null;
}
},
enable: () => {
if (self.ace) {
self.ace.setEditable(true);
}
},
disable: () => {
if (self.ace) {
self.ace.setEditable(false);
}
},
restoreRevisionText: (dataFromServer) => {
pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);
self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
},
};
return self;
})();
exports.padeditor = padeditor;
exports.focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({top: `${offsetTop}px`}); // Chrome
$outerdocHTML.animate({scrollTop: offsetTop}); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
let Ace2Editor = undefined;
let pad = undefined;
let settings = undefined;
const self = {
ace: null,
// this is accessed directly from other files
viewZoom: 100,
init: async (initialViewOptions, _pad) => {
Ace2Editor = require('./ace').Ace2Editor;
pad = _pad;
settings = pad.settings;
self.ace = new Ace2Editor();
await self.ace.init('editorcontainer', '');
$('#editorloadingbox').hide();
// Listen for clicks on sidediv items
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
$outerdoc.find('#sidedivinner').on('click', 'div', function () {
const targetLineNumber = $(this).index() + 1;
window.location.hash = `L${targetLineNumber}`;
});
exports.focusOnLine(self.ace);
self.ace.setProperty('wraps', true);
self.initViewOptions();
self.setViewOptions(initialViewOptions);
// view bar
$('#viewbarcontents').show();
},
initViewOptions: () => {
// Line numbers
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));
});
// Author colors
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck'));
pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck'));
});
// Right to left
padutils.bindCheckboxChange($('#options-rtlcheck'), () => {
pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck')));
});
html10n.bind('localized', () => {
pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection()));
});
// font family change
$('#viewfontmenu').change(() => {
pad.changeViewOption('padFontFamily', $('#viewfontmenu').val());
});
// Language
html10n.bind('localized', () => {
$('#languagemenu').val(html10n.getLanguage());
// translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist
// this does not interfere with html10n's normal value-setting because
// html10n just ingores <input>s
// also, a value which has been set by the user will be not overwritten
// since a user-edited <input> does *not* have the editempty-class
$('input[data-l10n-id]').each((key, input) => {
input = $(input);
if (input.hasClass('editempty')) {
input.val(html10n.get(input.attr('data-l10n-id')));
}
});
});
$('#languagemenu').val(html10n.getLanguage());
$('#languagemenu').change(() => {
Cookies.set('language', $('#languagemenu').val());
window.html10n.localize([$('#languagemenu').val(), 'en']);
if ($('select').niceSelect) {
$('select').niceSelect('update');
}
});
},
setViewOptions: (newOptions) => {
const getOption = (key, defaultValue) => {
const value = String(newOptions[key]);
if (value === 'true')
return true;
if (value === 'false')
return false;
return defaultValue;
};
ace.ace_setSelection(selection);
});
let v;
v = getOption('rtlIsTrue', ('rtl' === html10n.getDirection()));
self.ace.setProperty('rtlIsTrue', v);
padutils.setCheckbox($('#options-rtlcheck'), v);
v = getOption('showLineNumbers', true);
self.ace.setProperty('showslinenumbers', v);
padutils.setCheckbox($('#options-linenoscheck'), v);
v = getOption('showAuthorColors', true);
self.ace.setProperty('showsauthorcolors', v);
$('#chattext').toggleClass('authorColors', v);
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
}
self.ace.setProperty('textface', newOptions.padFontFamily || '');
},
dispose: () => {
if (self.ace) {
self.ace.destroy();
self.ace = null;
}
},
enable: () => {
if (self.ace) {
self.ace.setEditable(true);
}
},
disable: () => {
if (self.ace) {
self.ace.setEditable(false);
}
},
restoreRevisionText: (dataFromServer) => {
pad.addHistoricalAuthors(dataFromServer.historicalAuthorData);
self.ace.importAText(dataFromServer.atext, dataFromServer.apool, true);
},
};
return self;
})();
export const focusOnLine = (ace) => {
// If a number is in the URI IE #L124 go to that line number
const lineNumber = window.location.hash.substr(1);
if (lineNumber) {
if (lineNumber[0] === 'L') {
const $outerdoc = $('iframe[name="ace_outer"]').contents().find('#outerdocbody');
const lineNumberInt = parseInt(lineNumber.substr(1));
if (lineNumberInt) {
const $inner = $('iframe[name="ace_outer"]').contents().find('iframe')
.contents().find('#innerdocbody');
const line = $inner.find(`div:nth-child(${lineNumberInt})`);
if (line.length !== 0) {
let offsetTop = line.offset().top;
offsetTop += parseInt($outerdoc.css('padding-top').replace('px', ''));
const hasMobileLayout = $('body').hasClass('mobile-layout');
if (!hasMobileLayout) {
offsetTop += parseInt($inner.css('padding-top').replace('px', ''));
}
const $outerdocHTML = $('iframe[name="ace_outer"]').contents()
.find('#outerdocbody').parent();
$outerdoc.css({ top: `${offsetTop}px` }); // Chrome
$outerdocHTML.animate({ scrollTop: offsetTop }); // needed for FF
const node = line[0];
ace.callWithAce((ace) => {
const selection = {
startPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
endPoint: {
index: 0,
focusAtStart: true,
maxIndex: 1,
node,
},
};
ace.ace_setSelection(selection);
});
}
}
}
}
}
}
// End of setSelection / set Y position of editor
// End of setSelection / set Y position of editor
};
export { padeditor };

View file

@ -1,11 +1,9 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,163 +19,154 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const padimpexp = (() => {
let pad;
// /// import
const addImportFrames = () => {
$('#import .importframe').remove();
const iframe = $('<iframe>')
.css('display', 'none')
.attr('name', 'importiframe')
.addClass('importframe');
$('#import').append(iframe);
};
const fileInputUpdated = () => {
$('#importsubmitinput').addClass('throbbold');
$('#importformfilediv').addClass('importformenabled');
$('#importsubmitinput').removeAttr('disabled');
$('#importmessagefail').fadeOut('fast');
};
const fileInputSubmit = function (e) {
e.preventDefault();
$('#importmessagefail').fadeOut('fast');
if (!window.confirm(html10n.get('pad.impexp.confirmimport'))) return;
$('#importsubmitinput').attr({disabled: true}).val(html10n.get('pad.impexp.importing'));
window.setTimeout(() => $('#importfileinput').attr({disabled: true}), 0);
$('#importarrow').stop(true, true).hide();
$('#importstatusball').show();
(async () => {
const {code, message, data: {directDatabaseAccess} = {}} = await $.ajax({
url: `${window.location.href.split('?')[0].split('#')[0]}/import`,
method: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
dataType: 'json',
timeout: 25000,
}).catch((err) => {
if (err.responseJSON) return err.responseJSON;
return {code: 2, message: 'Unknown import error'};
});
if (code !== 0) {
importErrorMessage(message);
} else {
$('#import_export').removeClass('popup-show');
if (directDatabaseAccess) window.location.reload();
}
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);
$('#importstatusball').hide();
addImportFrames();
})();
};
const importErrorMessage = (status) => {
const known = [
'convertFailed',
'uploadFailed',
'padHasData',
'maxFileSize',
'permission',
];
const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);
const showError = (fade) => {
const popup = $('#importmessagefail').empty()
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
popup[(fade ? 'fadeIn' : 'show')]();
let pad;
// /// import
const addImportFrames = () => {
$('#import .importframe').remove();
const iframe = $('<iframe>')
.css('display', 'none')
.attr('name', 'importiframe')
.addClass('importframe');
$('#import').append(iframe);
};
if ($('#importexport .importmessage').is(':visible')) {
$('#importmessagesuccess').fadeOut('fast');
$('#importmessagefail').fadeOut('fast', () => showError(true));
} else {
showError();
const fileInputUpdated = () => {
$('#importsubmitinput').addClass('throbbold');
$('#importformfilediv').addClass('importformenabled');
$('#importsubmitinput').removeAttr('disabled');
$('#importmessagefail').fadeOut('fast');
};
const fileInputSubmit = function (e) {
e.preventDefault();
$('#importmessagefail').fadeOut('fast');
if (!window.confirm(html10n.get('pad.impexp.confirmimport')))
return;
$('#importsubmitinput').attr({ disabled: true }).val(html10n.get('pad.impexp.importing'));
window.setTimeout(() => $('#importfileinput').attr({ disabled: true }), 0);
$('#importarrow').stop(true, true).hide();
$('#importstatusball').show();
(async () => {
const { code, message, data: { directDatabaseAccess } = {} } = await $.ajax({
url: `${window.location.href.split('?')[0].split('#')[0]}/import`,
method: 'POST',
data: new FormData(this),
processData: false,
contentType: false,
dataType: 'json',
timeout: 25000,
}).catch((err) => {
if (err.responseJSON)
return err.responseJSON;
return { code: 2, message: 'Unknown import error' };
});
if (code !== 0) {
importErrorMessage(message);
}
else {
$('#import_export').removeClass('popup-show');
if (directDatabaseAccess)
window.location.reload();
}
$('#importsubmitinput').removeAttr('disabled').val(html10n.get('pad.impexp.importbutton'));
window.setTimeout(() => $('#importfileinput').removeAttr('disabled'), 0);
$('#importstatusball').hide();
addImportFrames();
})();
};
const importErrorMessage = (status) => {
const known = [
'convertFailed',
'uploadFailed',
'padHasData',
'maxFileSize',
'permission',
];
const msg = html10n.get(`pad.impexp.${known.indexOf(status) !== -1 ? status : 'copypaste'}`);
const showError = (fade) => {
const popup = $('#importmessagefail').empty()
.append($('<strong>')
.css('color', 'red')
.text(`${html10n.get('pad.impexp.importfailed')}: `))
.append(document.createTextNode(msg));
popup[(fade ? 'fadeIn' : 'show')]();
};
if ($('#importexport .importmessage').is(':visible')) {
$('#importmessagesuccess').fadeOut('fast');
$('#importmessagefail').fadeOut('fast', () => showError(true));
}
else {
showError();
}
};
// /// export
function cantExport() {
let type = $(this);
if (type.hasClass('exporthrefpdf')) {
type = 'PDF';
}
else if (type.hasClass('exporthrefdoc')) {
type = 'Microsoft Word';
}
else if (type.hasClass('exporthrefodt')) {
type = 'OpenDocument';
}
else {
type = 'this file';
}
alert(html10n.get('pad.impexp.exportdisabled', { type }));
return false;
}
};
// /// export
function cantExport() {
let type = $(this);
if (type.hasClass('exporthrefpdf')) {
type = 'PDF';
} else if (type.hasClass('exporthrefdoc')) {
type = 'Microsoft Word';
} else if (type.hasClass('exporthrefodt')) {
type = 'OpenDocument';
} else {
type = 'this file';
}
alert(html10n.get('pad.impexp.exportdisabled', {type}));
return false;
}
// ///
const self = {
init: (_pad) => {
pad = _pad;
// get /p/padname
// if /p/ isn't available due to a rewrite we use the clientVars padId
const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || clientVars.padId;
// i10l buttom import
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
html10n.bind('localized', () => {
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
});
// build the export links
$('#exporthtmla').attr('href', `${padRootPath}/export/html`);
$('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);
$('#exportplaina').attr('href', `${padRootPath}/export/txt`);
// hide stuff thats not avaible if abiword/soffice is disabled
if (clientVars.exportAvailable === 'no') {
$('#exportworda').remove();
$('#exportpdfa').remove();
$('#exportopena').remove();
$('#importmessageabiword').show();
} else if (clientVars.exportAvailable === 'withoutPDF') {
$('#exportpdfa').remove();
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
$('#importexport').css({height: '142px'});
$('#importexportline').css({height: '142px'});
} else {
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
}
addImportFrames();
$('#importfileinput').change(fileInputUpdated);
$('#importform').unbind('submit').submit(fileInputSubmit);
$('.disabledexport').click(cantExport);
},
disable: () => {
$('#impexp-disabled-clickcatcher').show();
$('#import').css('opacity', 0.5);
$('#impexp-export').css('opacity', 0.5);
},
enable: () => {
$('#impexp-disabled-clickcatcher').hide();
$('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
},
};
return self;
// ///
const self = {
init: (_pad) => {
pad = _pad;
// get /p/padname
// if /p/ isn't available due to a rewrite we use the clientVars padId
const padRootPath = /.*\/p\/[^/]+/.exec(document.location.pathname) || clientVars.padId;
// i10l buttom import
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
html10n.bind('localized', () => {
$('#importsubmitinput').val(html10n.get('pad.impexp.importbutton'));
});
// build the export links
$('#exporthtmla').attr('href', `${padRootPath}/export/html`);
$('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`);
$('#exportplaina').attr('href', `${padRootPath}/export/txt`);
// hide stuff thats not avaible if abiword/soffice is disabled
if (clientVars.exportAvailable === 'no') {
$('#exportworda').remove();
$('#exportpdfa').remove();
$('#exportopena').remove();
$('#importmessageabiword').show();
}
else if (clientVars.exportAvailable === 'withoutPDF') {
$('#exportpdfa').remove();
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
$('#importexport').css({ height: '142px' });
$('#importexportline').css({ height: '142px' });
}
else {
$('#exportworda').attr('href', `${padRootPath}/export/doc`);
$('#exportpdfa').attr('href', `${padRootPath}/export/pdf`);
$('#exportopena').attr('href', `${padRootPath}/export/odt`);
}
addImportFrames();
$('#importfileinput').change(fileInputUpdated);
$('#importform').unbind('submit').submit(fileInputSubmit);
$('.disabledexport').click(cantExport);
},
disable: () => {
$('#impexp-disabled-clickcatcher').show();
$('#import').css('opacity', 0.5);
$('#impexp-export').css('opacity', 0.5);
},
enable: () => {
$('#impexp-disabled-clickcatcher').hide();
$('#import').css('opacity', 1);
$('#impexp-export').css('opacity', 1);
},
};
return self;
})();
exports.padimpexp = padimpexp;
export { padimpexp };

View file

@ -1,11 +1,11 @@
import { padeditbar as padeditbar$0 } from "./pad_editbar.js";
import * as automaticReconnect from "./pad_automatic_reconnect.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,35 +21,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const padeditbar = require('./pad_editbar').padeditbar;
const automaticReconnect = require('./pad_automatic_reconnect');
const padeditbar = { padeditbar: padeditbar$0 }.padeditbar;
const padmodals = (() => {
let pad = undefined;
const self = {
init: (_pad) => {
pad = _pad;
},
showModal: (messageId) => {
padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible');
const $modal = $(`#connectivity .${messageId}`);
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
padeditbar.toggleDropDown('connectivity');
},
showOverlay: () => {
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example
$('#toolbar-overlay').show();
},
hideOverlay: () => {
$('#toolbar-overlay').hide();
},
};
return self;
let pad = undefined;
const self = {
init: (_pad) => {
pad = _pad;
},
showModal: (messageId) => {
padeditbar.toggleDropDown('none');
$('#connectivity .visible').removeClass('visible');
$(`#connectivity .${messageId}`).addClass('visible');
const $modal = $(`#connectivity .${messageId}`);
automaticReconnect.showCountDownTimerToReconnectOnModal($modal, pad);
padeditbar.toggleDropDown('connectivity');
},
showOverlay: () => {
// Prevent the user to interact with the toolbar. Useful when user is disconnected for example
$('#toolbar-overlay').show();
},
hideOverlay: () => {
$('#toolbar-overlay').hide();
},
};
return self;
})();
exports.padmodals = padmodals;
export { padmodals };

View file

@ -1,5 +1,4 @@
'use strict';
/**
* Copyright 2012 Peter 'Pita' Martischka
*
@ -15,24 +14,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
let pad;
exports.saveNow = () => {
pad.collabClient.sendMessage({type: 'SAVE_REVISION'});
$.gritter.add({
// (string | mandatory) the heading of the notification
title: html10n.get('pad.savedrevs.marked'),
// (string | mandatory) the text inside the notification
text: html10n.get('pad.savedrevs.timeslider') ||
'You can view saved revisions in the timeslider',
// (bool | optional) if you want it to fade out on its own or just sit there
sticky: false,
time: 3000,
class_name: 'saved-revision',
});
export const saveNow = () => {
pad.collabClient.sendMessage({ type: 'SAVE_REVISION' });
$.gritter.add({
// (string | mandatory) the heading of the notification
title: html10n.get('pad.savedrevs.marked'),
// (string | mandatory) the text inside the notification
text: html10n.get('pad.savedrevs.timeslider') ||
'You can view saved revisions in the timeslider',
// (bool | optional) if you want it to fade out on its own or just sit there
sticky: false,
time: 3000,
class_name: 'saved-revision',
});
};
exports.init = (_pad) => {
pad = _pad;
export const init = (_pad) => {
pad = _pad;
};

File diff suppressed because it is too large Load diff

View file

@ -1,58 +1,52 @@
import * as pluginUtils from "./shared.js";
import * as defs from "./plugin_defs.js";
'use strict';
const pluginUtils = require('./shared');
const defs = require('./plugin_defs');
exports.baseURL = '';
exports.ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
exports.update = (cb) => {
// It appears that this response (see #620) may interrupt the current thread
// of execution on Firefox. This schedules the response in the run-loop,
// which appears to fix the issue.
const callback = () => setTimeout(cb, 0);
jQuery.getJSON(
`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`
).done((data) => {
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks');
defs.loaded = true;
callback();
}).fail((err) => {
console.error(`Failed to load plugin-definitions: ${err}`);
callback();
});
};
const adoptPluginsFromAncestorsOf = (frame) => {
// Bind plugins with parent;
let parentRequire = null;
try {
while ((frame = frame.parent)) {
if (typeof (frame.require) !== 'undefined') {
parentRequire = frame.require;
break;
}
// Bind plugins with parent;
let parentRequire = null;
try {
while ((frame = frame.parent)) {
if (typeof (frame.require) !== 'undefined') {
parentRequire = frame.require;
break;
}
}
}
} catch (error) {
// Silence (this can only be a XDomain issue).
console.error(error);
}
if (!parentRequire) throw new Error('Parent plugins could not be found.');
const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
defs.hooks = ancestorPluginDefs.hooks;
defs.loaded = ancestorPluginDefs.loaded;
defs.parts = ancestorPluginDefs.parts;
defs.plugins = ancestorPluginDefs.plugins;
const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins');
exports.baseURL = ancestorPlugins.baseURL;
exports.ensure = ancestorPlugins.ensure;
exports.update = ancestorPlugins.update;
catch (error) {
// Silence (this can only be a XDomain issue).
console.error(error);
}
if (!parentRequire)
throw new Error('Parent plugins could not be found.');
const ancestorPluginDefs = parentRequire('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
defs.hooks = ancestorPluginDefs.hooks;
defs.loaded = ancestorPluginDefs.loaded;
defs.parts = ancestorPluginDefs.parts;
defs.plugins = ancestorPluginDefs.plugins;
const ancestorPlugins = parentRequire('ep_etherpad-lite/static/js/pluginfw/client_plugins');
ancestorPlugins.baseURL;
ancestorPlugins.ensure;
ancestorPlugins.update;
};
exports.adoptPluginsFromAncestorsOf = adoptPluginsFromAncestorsOf;
export const baseURL = '';
export const ensure = (cb) => !defs.loaded ? exports.update(cb) : cb();
export const update = (cb) => {
// It appears that this response (see #620) may interrupt the current thread
// of execution on Firefox. This schedules the response in the run-loop,
// which appears to fix the issue.
const callback = () => setTimeout(cb, 0);
jQuery.getJSON(`${exports.baseURL}pluginfw/plugin-definitions.json?v=${clientVars.randomVersionString}`).done((data) => {
defs.plugins = data.plugins;
defs.parts = data.parts;
defs.hooks = pluginUtils.extractHooks(defs.parts, 'client_hooks');
defs.loaded = true;
callback();
}).fail((err) => {
console.error(`Failed to load plugin-definitions: ${err}`);
callback();
});
};
export { adoptPluginsFromAncestorsOf as baseURL };
export { adoptPluginsFromAncestorsOf as ensure };
export { adoptPluginsFromAncestorsOf as update };
export { adoptPluginsFromAncestorsOf };

View file

@ -2,4 +2,4 @@
// Provides a require'able version of jQuery without leaking $ and jQuery;
window.$ = require('./vendors/jquery');
const jq = window.$.noConflict(true);
exports.jQuery = exports.$ = jq;
export { jq as $ };

View file

@ -1,351 +1,297 @@
import * as caretPosition from "./caretPosition.js";
'use strict';
/*
This file handles scroll on edition or when user presses arrow keys.
In this file we have two representations of line (browser and rep line).
Rep Line = a line in the way is represented by Etherpad(rep) (each <div> is a line)
Browser Line = each vertical line. A <div> can be break into more than one
browser line.
*/
const caretPosition = require('./caretPosition');
function Scroll(outerWin) {
// scroll settings
this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
this.outerWin = outerWin;
this.doc = this.outerWin.document;
this.rootDocument = parent.parent.document;
// scroll settings
this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
this.outerWin = outerWin;
this.doc = this.outerWin.document;
this.rootDocument = parent.parent.document;
}
Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary =
function (rep, isScrollableEvent, innerHeight) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
const shouldScrollWhenCaretIsAtBottomOfViewport =
this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;
if (shouldScrollWhenCaretIsAtBottomOfViewport) {
// avoid scrolling when selection includes multiple lines --
// user can potentially be selecting more lines
// than it fits on viewport
const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];
// avoid scrolling when pad loads
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {
// when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0
const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);
this._scrollYPage(pixelsToScroll);
}
}
};
function (rep, isScrollableEvent, innerHeight) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
const shouldScrollWhenCaretIsAtBottomOfViewport = this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport;
if (shouldScrollWhenCaretIsAtBottomOfViewport) {
// avoid scrolling when selection includes multiple lines --
// user can potentially be selecting more lines
// than it fits on viewport
const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0];
// avoid scrolling when pad loads
if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) {
// when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0
const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight);
this._scrollYPage(pixelsToScroll);
}
}
};
Scroll.prototype.scrollWhenPressArrowKeys = function (arrowUp, rep, innerHeight) {
// if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous
// rep line on the top of the viewport
if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {
const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made
// when we apply a second scroll, we made it immediately (without animation)
this._scrollYPageWithoutAnimation(-pixelsToScroll);
} else {
this.scrollNodeVerticallyIntoView(rep, innerHeight);
}
// if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous
// rep line on the top of the viewport
if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) {
const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight);
// by default, the browser scrolls to the middle of the viewport. To avoid the twist made
// when we apply a second scroll, we made it immediately (without animation)
this._scrollYPageWithoutAnimation(-pixelsToScroll);
}
else {
this.scrollNodeVerticallyIntoView(rep, innerHeight);
}
};
// Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking
// if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are
// other lines after caretLine(), and all of them are out of viewport.
Scroll.prototype._isCaretAtTheBottomOfViewport = function (rep) {
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
// caret is in the bottom of viewport
const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineAfterCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = caretPosition.getPosition();
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
return nextLineIsBelowViewportBottom;
}
return false;
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
// caret is in the bottom of viewport
const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineAfterCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = caretPosition.getPosition();
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
return nextLineIsBelowViewportBottom;
}
return false;
};
Scroll.prototype._isLinePartiallyVisibleOnViewport = function (lineNumber, rep) {
const lineNode = rep.lines.atIndex(lineNumber);
const linePosition = this._getLineEntryTopBottom(lineNode);
const lineTop = linePosition.top;
const lineBottom = linePosition.bottom;
const viewport = this._getViewPortTopBottom();
const viewportBottom = viewport.bottom;
const viewportTop = viewport.top;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
const topOfLineIsBelowViewportTop = lineTop >= viewportTop;
const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
(bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
const lineNode = rep.lines.atIndex(lineNumber);
const linePosition = this._getLineEntryTopBottom(lineNode);
const lineTop = linePosition.top;
const lineBottom = linePosition.bottom;
const viewport = this._getViewPortTopBottom();
const viewportBottom = viewport.bottom;
const viewportTop = viewport.top;
const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
const topOfLineIsBelowViewportTop = lineTop >= viewportTop;
const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
(topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
(bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
};
Scroll.prototype._getViewPortTopBottom = function () {
const theTop = this.getScrollY();
const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding
// we have to get the exactly height of the viewport.
// So it has to subtract all the values which changes
// the viewport height (E.g. padding, position top)
const viewportExtraSpacesAndPosition =
this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
const theTop = this.getScrollY();
const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding
// we have to get the exactly height of the viewport.
// So it has to subtract all the values which changes
// the viewport height (E.g. padding, position top)
const viewportExtraSpacesAndPosition = this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
};
Scroll.prototype._getEditorPositionTop = function () {
const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop;
return editorPositionTop;
const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop;
return editorPositionTop;
};
// ep_page_view adds padding-top, which makes the viewport smaller
Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () {
const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop;
const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop;
};
Scroll.prototype._getScrollXY = function () {
const win = this.outerWin;
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
};
}
const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') {
return {
x: docel.scrollLeft,
y: docel.scrollTop,
};
}
};
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
};
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
};
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
};
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
const win = this.outerWin;
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
};
}
const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') {
return {
x: docel.scrollLeft,
y: docel.scrollTop,
};
}
}
return false;
};
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
};
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
};
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
};
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
};
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine = caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport = caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
}
return false;
};
// By default, when user makes an edition in a line out of viewport, this line goes
// to the edge of viewport. This function gets the extra pixels necessary to get the
// caret line in a position X relative to Y% viewport.
Scroll.prototype._getPixelsRelativeToPercentageOfViewport =
function (innerHeight, aboveOfViewport) {
let pixels = 0;
const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);
if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {
pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport);
}
return pixels;
};
function (innerHeight, aboveOfViewport) {
let pixels = 0;
const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport);
if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) {
pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport);
}
return pixels;
};
// we use different percentages when change selection. It depends on if it is
// either above the top or below the bottom of the page
Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) {
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
}
return percentageToScroll;
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
}
return percentageToScroll;
};
Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) {
let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(innerHeight * percentageToScrollUp);
}
return pixels;
let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(innerHeight * percentageToScrollUp);
}
return pixels;
};
Scroll.prototype._scrollYPage = function (pixelsToScroll) {
const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
} else {
this._scrollYPageWithoutAnimation(pixelsToScroll);
}
const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
}
else {
this._scrollYPageWithoutAnimation(pixelsToScroll);
}
};
Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) {
this.outerWin.scrollBy(0, pixelsToScroll);
this.outerWin.scrollBy(0, pixelsToScroll);
};
Scroll.prototype._scrollYPageWithAnimation =
function (pixelsToScroll, durationOfAnimationToShowFocusline) {
const outerDocBody = this.doc.getElementById('outerdocbody');
// it works on later versions of Chrome
const $outerDocBody = $(outerDocBody);
this._triggerScrollWithAnimation(
$outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
// it works on Firefox and earlier versions of Chrome
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation(
$outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
function (pixelsToScroll, durationOfAnimationToShowFocusline) {
const outerDocBody = this.doc.getElementById('outerdocbody');
// it works on later versions of Chrome
const $outerDocBody = $(outerDocBody);
this._triggerScrollWithAnimation($outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
// it works on Firefox and earlier versions of Chrome
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation($outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
// using a custom queue and clearing it, we avoid creating a queue of scroll animations.
// So if this function is called twice quickly, only the last one runs.
Scroll.prototype._triggerScrollWithAnimation =
function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) {
// clear the queue of animation
$elem.stop('scrollanimation');
$elem.animate({
scrollTop: `+=${pixelsToScroll}`,
}, {
duration: durationOfAnimationToShowFocusline,
queue: 'scrollanimation',
}).dequeue('scrollanimation');
};
function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) {
// clear the queue of animation
$elem.stop('scrollanimation');
$elem.animate({
scrollTop: `+=${pixelsToScroll}`,
}, {
duration: durationOfAnimationToShowFocusline,
queue: 'scrollanimation',
}).dequeue('scrollanimation');
};
// scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance
// needed to be completely in view. If the value is greater than 0 and less than or equal to 1,
// besides of scrolling the minimum needed to be visible, it scrolls additionally
// (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels
Scroll.prototype.scrollNodeVerticallyIntoView = function (rep, innerHeight) {
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = caretPosition.getPosition();
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
if (caretIsAboveOfViewport) {
const pixelsToScroll =
distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
this._scrollYPage(pixelsToScroll);
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
// Details at: https://github.com/ether/etherpad-lite/pull/4639/files
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = caretPosition.getPosition();
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
const caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
if (caretIsAboveOfViewport) {
const pixelsToScroll = distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
this._scrollYPage(pixelsToScroll);
}
else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
// Details at: https://github.com/ether/etherpad-lite/pull/4639/files
}
}
}
};
Scroll.prototype._partOfRepLineIsOutOfViewport = function (viewportPosition, rep) {
const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
const line = rep.lines.atIndex(focusLine);
const linePosition = this._getLineEntryTopBottom(line);
const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
const line = rep.lines.atIndex(focusLine);
const linePosition = this._getLineEntryTopBottom(line);
const lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
return lineIsBelowOfViewport || lineIsAboveOfViewport;
};
Scroll.prototype._getLineEntryTopBottom = function (entry, destObj) {
const dom = entry.lineNode;
const top = dom.offsetTop;
const height = dom.offsetHeight;
const obj = (destObj || {});
obj.top = top;
obj.bottom = (top + height);
return obj;
const dom = entry.lineNode;
const top = dom.offsetTop;
const height = dom.offsetHeight;
const obj = (destObj || {});
obj.top = top;
obj.bottom = (top + height);
return obj;
};
Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) {
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
};
Scroll.prototype.getVisibleLineRange = function (rep) {
const viewport = this._getViewPortTopBottom();
// console.log("viewport top/bottom: %o", viewport);
const obj = {};
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
// return the first line that the top position is greater or equal than
// the viewport. That is the first line that is below the viewport bottom.
// So the line that is in the bottom of the viewport is the very previous one.
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start) end = start; // unlikely
// top.console.log(start+","+(end -1));
return [start, end - 1];
const viewport = this._getViewPortTopBottom();
// console.log("viewport top/bottom: %o", viewport);
const obj = {};
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
// return the first line that the top position is greater or equal than
// the viewport. That is the first line that is below the viewport bottom.
// So the line that is in the bottom of the viewport is the very previous one.
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start)
end = start; // unlikely
// top.console.log(start+","+(end -1));
return [start, end - 1];
};
Scroll.prototype.getVisibleCharRange = function (rep) {
const lineRange = this.getVisibleLineRange(rep);
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
const lineRange = this.getVisibleLineRange(rep);
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
};
exports.init = (outerWin) => new Scroll(outerWin);
export const init = (outerWin) => new Scroll(outerWin);

View file

@ -1,19 +1,2 @@
'use strict';
/**
* 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.
*/
module.exports = require('security');
export * from "security";

View file

@ -1,55 +1,45 @@
'use strict';
// Specific hash to display the skin variants builder popup
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') {
$('#skin-variants').addClass('popup-show');
const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];
// add corresponding classes when config change
const updateSkinVariantsClasses = () => {
const domsToUpdate = [
$('html'),
$('iframe[name=ace_outer]').contents().find('html'),
$('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'),
];
colors.forEach((color) => {
containers.forEach((container) => {
domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); });
});
$('#skin-variants').addClass('popup-show');
const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];
// add corresponding classes when config change
const updateSkinVariantsClasses = () => {
const domsToUpdate = [
$('html'),
$('iframe[name=ace_outer]').contents().find('html'),
$('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'),
];
colors.forEach((color) => {
containers.forEach((container) => {
domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); });
});
});
domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });
const newClasses = [];
$('select.skin-variant-color').each(function () {
newClasses.push(`${$(this).val()}-${$(this).data('container')}`);
});
if ($('#skin-variant-full-width').is(':checked'))
newClasses.push('full-width-editor');
domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });
$('#skin-variants-result').val(`"skinVariants": "${newClasses.join(' ')}",`);
};
// run on init
const updateCheckboxFromSkinClasses = () => {
$('html').attr('class').split(' ').forEach((classItem) => {
const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length);
if (containers.indexOf(container) > -1) {
const color = classItem.substring(0, classItem.lastIndexOf('-'));
$(`.skin-variant-color[data-container="${container}"`).val(color);
}
});
$('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));
};
$('.skin-variant').change(() => {
updateSkinVariantsClasses();
});
domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });
const newClasses = [];
$('select.skin-variant-color').each(function () {
newClasses.push(`${$(this).val()}-${$(this).data('container')}`);
});
if ($('#skin-variant-full-width').is(':checked')) newClasses.push('full-width-editor');
domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });
$('#skin-variants-result').val(`"skinVariants": "${newClasses.join(' ')}",`);
};
// run on init
const updateCheckboxFromSkinClasses = () => {
$('html').attr('class').split(' ').forEach((classItem) => {
const container = classItem.substring(classItem.lastIndexOf('-') + 1, classItem.length);
if (containers.indexOf(container) > -1) {
const color = classItem.substring(0, classItem.lastIndexOf('-'));
$(`.skin-variant-color[data-container="${container}"`).val(color);
}
});
$('#skin-variant-full-width').prop('checked', $('html').hasClass('full-width-editor'));
};
$('.skin-variant').change(() => {
updateCheckboxFromSkinClasses();
updateSkinVariantsClasses();
});
updateCheckboxFromSkinClasses();
updateSkinVariantsClasses();
}

View file

@ -1,11 +1,9 @@
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc.
*
@ -21,310 +19,310 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const _entryWidth = (e) => (e && e.width) || 0;
class Node {
constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) {
this.key = entry != null ? entry.key : null;
this.entry = entry;
this.levels = levels;
this.upPtrs = Array(levels).fill(null);
this.downPtrs = Array(levels).fill(null);
this.downSkips = Array(levels).fill(downSkips);
this.downSkipWidths = Array(levels).fill(downSkipWidths);
}
propagateWidthChange() {
const oldWidth = this.downSkipWidths[0];
const newWidth = _entryWidth(this.entry);
const widthChange = newWidth - oldWidth;
let n = this;
let lvl = 0;
while (lvl < n.levels) {
n.downSkipWidths[lvl] += widthChange;
lvl++;
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
n = n.upPtrs[lvl - 1];
}
constructor(entry, levels = 0, downSkips = 1, downSkipWidths = 0) {
this.key = entry != null ? entry.key : null;
this.entry = entry;
this.levels = levels;
this.upPtrs = Array(levels).fill(null);
this.downPtrs = Array(levels).fill(null);
this.downSkips = Array(levels).fill(downSkips);
this.downSkipWidths = Array(levels).fill(downSkipWidths);
}
propagateWidthChange() {
const oldWidth = this.downSkipWidths[0];
const newWidth = _entryWidth(this.entry);
const widthChange = newWidth - oldWidth;
let n = this;
let lvl = 0;
while (lvl < n.levels) {
n.downSkipWidths[lvl] += widthChange;
lvl++;
while (lvl >= n.levels && n.upPtrs[lvl - 1]) {
n = n.upPtrs[lvl - 1];
}
}
return widthChange;
}
return widthChange;
}
}
// A "point" object at index x allows modifications immediately after the first x elements of the
// skiplist, such as multiple inserts or deletes. After an insert or delete using point P, the point
// is still valid and points to the same index in the skiplist. Other operations with other points
// invalidate this point.
class Point {
constructor(skipList, loc) {
this._skipList = skipList;
this.loc = loc;
const numLevels = this._skipList._start.levels;
let lvl = numLevels - 1;
let i = -1;
let ws = 0;
const nodes = new Array(numLevels);
const idxs = new Array(numLevels);
const widthSkips = new Array(numLevels);
nodes[lvl] = this._skipList._start;
idxs[lvl] = -1;
widthSkips[lvl] = 0;
while (lvl >= 0) {
let n = nodes[lvl];
while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) {
i += n.downSkips[lvl];
ws += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
nodes[lvl] = n;
idxs[lvl] = i;
widthSkips[lvl] = ws;
lvl--;
if (lvl >= 0) {
nodes[lvl] = n;
}
constructor(skipList, loc) {
this._skipList = skipList;
this.loc = loc;
const numLevels = this._skipList._start.levels;
let lvl = numLevels - 1;
let i = -1;
let ws = 0;
const nodes = new Array(numLevels);
const idxs = new Array(numLevels);
const widthSkips = new Array(numLevels);
nodes[lvl] = this._skipList._start;
idxs[lvl] = -1;
widthSkips[lvl] = 0;
while (lvl >= 0) {
let n = nodes[lvl];
while (n.downPtrs[lvl] && (i + n.downSkips[lvl] < this.loc)) {
i += n.downSkips[lvl];
ws += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
nodes[lvl] = n;
idxs[lvl] = i;
widthSkips[lvl] = ws;
lvl--;
if (lvl >= 0) {
nodes[lvl] = n;
}
}
this.idxs = idxs;
this.nodes = nodes;
this.widthSkips = widthSkips;
}
this.idxs = idxs;
this.nodes = nodes;
this.widthSkips = widthSkips;
}
toString() {
return `Point(${this.loc})`;
}
insert(entry) {
if (entry.key == null) throw new Error('entry.key must not be null');
if (this._skipList.containsKey(entry.key)) {
throw new Error(`an entry with key ${entry.key} already exists`);
toString() {
return `Point(${this.loc})`;
}
const newNode = new Node(entry);
const pNodes = this.nodes;
const pIdxs = this.idxs;
const pLoc = this.loc;
const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];
const newWidth = _entryWidth(entry);
// The new node will have at least level 1
// With a proability of 0.01^(n-1) the nodes level will be >= n
while (newNode.levels === 0 || Math.random() < 0.01) {
const lvl = newNode.levels;
newNode.levels++;
if (lvl === pNodes.length) {
// assume we have just passed the end of this.nodes, and reached one level greater
// than the skiplist currently supports
pNodes[lvl] = this._skipList._start;
pIdxs[lvl] = -1;
this._skipList._start.levels++;
this._skipList._end.levels++;
this._skipList._start.downPtrs[lvl] = this._skipList._end;
this._skipList._end.upPtrs[lvl] = this._skipList._start;
this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1;
this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth;
this.widthSkips[lvl] = 0;
}
const me = newNode;
const up = pNodes[lvl];
const down = up.downPtrs[lvl];
const skip1 = pLoc - pIdxs[lvl];
const skip2 = up.downSkips[lvl] + 1 - skip1;
up.downSkips[lvl] = skip1;
up.downPtrs[lvl] = me;
me.downSkips[lvl] = skip2;
me.upPtrs[lvl] = up;
me.downPtrs[lvl] = down;
down.upPtrs[lvl] = me;
const widthSkip1 = widthLoc - this.widthSkips[lvl];
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
up.downSkipWidths[lvl] = widthSkip1;
me.downSkipWidths[lvl] = widthSkip2;
insert(entry) {
if (entry.key == null)
throw new Error('entry.key must not be null');
if (this._skipList.containsKey(entry.key)) {
throw new Error(`an entry with key ${entry.key} already exists`);
}
const newNode = new Node(entry);
const pNodes = this.nodes;
const pIdxs = this.idxs;
const pLoc = this.loc;
const widthLoc = this.widthSkips[0] + this.nodes[0].downSkipWidths[0];
const newWidth = _entryWidth(entry);
// The new node will have at least level 1
// With a proability of 0.01^(n-1) the nodes level will be >= n
while (newNode.levels === 0 || Math.random() < 0.01) {
const lvl = newNode.levels;
newNode.levels++;
if (lvl === pNodes.length) {
// assume we have just passed the end of this.nodes, and reached one level greater
// than the skiplist currently supports
pNodes[lvl] = this._skipList._start;
pIdxs[lvl] = -1;
this._skipList._start.levels++;
this._skipList._end.levels++;
this._skipList._start.downPtrs[lvl] = this._skipList._end;
this._skipList._end.upPtrs[lvl] = this._skipList._start;
this._skipList._start.downSkips[lvl] = this._skipList._keyToNodeMap.size + 1;
this._skipList._start.downSkipWidths[lvl] = this._skipList._totalWidth;
this.widthSkips[lvl] = 0;
}
const me = newNode;
const up = pNodes[lvl];
const down = up.downPtrs[lvl];
const skip1 = pLoc - pIdxs[lvl];
const skip2 = up.downSkips[lvl] + 1 - skip1;
up.downSkips[lvl] = skip1;
up.downPtrs[lvl] = me;
me.downSkips[lvl] = skip2;
me.upPtrs[lvl] = up;
me.downPtrs[lvl] = down;
down.upPtrs[lvl] = me;
const widthSkip1 = widthLoc - this.widthSkips[lvl];
const widthSkip2 = up.downSkipWidths[lvl] + newWidth - widthSkip1;
up.downSkipWidths[lvl] = widthSkip1;
me.downSkipWidths[lvl] = widthSkip2;
}
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
const up = pNodes[lvl];
up.downSkips[lvl]++;
up.downSkipWidths[lvl] += newWidth;
}
this._skipList._keyToNodeMap.set(newNode.key, newNode);
this._skipList._totalWidth += newWidth;
}
for (let lvl = newNode.levels; lvl < pNodes.length; lvl++) {
const up = pNodes[lvl];
up.downSkips[lvl]++;
up.downSkipWidths[lvl] += newWidth;
delete() {
const elem = this.nodes[0].downPtrs[0];
const elemWidth = _entryWidth(elem.entry);
for (let i = 0; i < this.nodes.length; i++) {
if (i < elem.levels) {
const up = elem.upPtrs[i];
const down = elem.downPtrs[i];
const totalSkip = up.downSkips[i] + elem.downSkips[i] - 1;
up.downPtrs[i] = down;
down.upPtrs[i] = up;
up.downSkips[i] = totalSkip;
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
up.downSkipWidths[i] = totalWidthSkip;
}
else {
const up = this.nodes[i];
up.downSkips[i]--;
up.downSkipWidths[i] -= elemWidth;
}
}
this._skipList._keyToNodeMap.delete(elem.key);
this._skipList._totalWidth -= elemWidth;
}
this._skipList._keyToNodeMap.set(newNode.key, newNode);
this._skipList._totalWidth += newWidth;
}
delete() {
const elem = this.nodes[0].downPtrs[0];
const elemWidth = _entryWidth(elem.entry);
for (let i = 0; i < this.nodes.length; i++) {
if (i < elem.levels) {
const up = elem.upPtrs[i];
const down = elem.downPtrs[i];
const totalSkip = up.downSkips[i] + elem.downSkips[i] - 1;
up.downPtrs[i] = down;
down.upPtrs[i] = up;
up.downSkips[i] = totalSkip;
const totalWidthSkip = up.downSkipWidths[i] + elem.downSkipWidths[i] - elemWidth;
up.downSkipWidths[i] = totalWidthSkip;
} else {
const up = this.nodes[i];
up.downSkips[i]--;
up.downSkipWidths[i] -= elemWidth;
}
getNode() {
return this.nodes[0].downPtrs[0];
}
this._skipList._keyToNodeMap.delete(elem.key);
this._skipList._totalWidth -= elemWidth;
}
getNode() {
return this.nodes[0].downPtrs[0];
}
}
/**
* The skip-list contains "entries", JavaScript objects that each must have a unique "key"
* property that is a string.
*/
class SkipList {
constructor() {
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N
this._start = new Node(null, 1);
this._end = new Node(null, 1, null, null);
this._totalWidth = 0;
this._keyToNodeMap = new Map();
this._start.downPtrs[0] = this._end;
this._end.upPtrs[0] = this._start;
}
_getNodeAtOffset(targetOffset) {
let i = 0;
let n = this._start;
let lvl = this._start.levels - 1;
while (lvl >= 0 && n.downPtrs[lvl]) {
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
i += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
lvl--;
constructor() {
// if there are N elements in the skiplist, "start" is element -1 and "end" is element N
this._start = new Node(null, 1);
this._end = new Node(null, 1, null, null);
this._totalWidth = 0;
this._keyToNodeMap = new Map();
this._start.downPtrs[0] = this._end;
this._end.upPtrs[0] = this._start;
}
if (n === this._start) return (this._start.downPtrs[0] || null);
if (n === this._end) {
return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null;
_getNodeAtOffset(targetOffset) {
let i = 0;
let n = this._start;
let lvl = this._start.levels - 1;
while (lvl >= 0 && n.downPtrs[lvl]) {
while (n.downPtrs[lvl] && (i + n.downSkipWidths[lvl] <= targetOffset)) {
i += n.downSkipWidths[lvl];
n = n.downPtrs[lvl];
}
lvl--;
}
if (n === this._start)
return (this._start.downPtrs[0] || null);
if (n === this._end) {
return targetOffset === this._totalWidth ? (this._end.upPtrs[0] || null) : null;
}
return n;
}
return n;
}
_getNodeIndex(node, byWidth) {
let dist = (byWidth ? 0 : -1);
let n = node;
while (n !== this._start) {
const lvl = n.levels - 1;
n = n.upPtrs[lvl];
if (byWidth) dist += n.downSkipWidths[lvl];
else dist += n.downSkips[lvl];
_getNodeIndex(node, byWidth) {
let dist = (byWidth ? 0 : -1);
let n = node;
while (n !== this._start) {
const lvl = n.levels - 1;
n = n.upPtrs[lvl];
if (byWidth)
dist += n.downSkipWidths[lvl];
else
dist += n.downSkips[lvl];
}
return dist;
}
return dist;
}
// Returns index of first entry such that entryFunc(entry) is truthy,
// or length() if no such entry. Assumes all falsy entries come before
// all truthy entries.
search(entryFunc) {
let low = this._start;
let lvl = this._start.levels - 1;
let lowIndex = -1;
const f = (node) => {
if (node === this._start) return false;
else if (node === this._end) return true;
else return entryFunc(node.entry);
};
while (lvl >= 0) {
let nextLow = low.downPtrs[lvl];
while (!f(nextLow)) {
lowIndex += low.downSkips[lvl];
low = nextLow;
nextLow = low.downPtrs[lvl];
}
lvl--;
// Returns index of first entry such that entryFunc(entry) is truthy,
// or length() if no such entry. Assumes all falsy entries come before
// all truthy entries.
search(entryFunc) {
let low = this._start;
let lvl = this._start.levels - 1;
let lowIndex = -1;
const f = (node) => {
if (node === this._start)
return false;
else if (node === this._end)
return true;
else
return entryFunc(node.entry);
};
while (lvl >= 0) {
let nextLow = low.downPtrs[lvl];
while (!f(nextLow)) {
lowIndex += low.downSkips[lvl];
low = nextLow;
nextLow = low.downPtrs[lvl];
}
lvl--;
}
return lowIndex + 1;
}
return lowIndex + 1;
}
length() { return this._keyToNodeMap.size; }
atIndex(i) {
if (i < 0) console.warn(`atIndex(${i})`);
if (i >= this._keyToNodeMap.size) console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
return (new Point(this, i)).getNode().entry;
}
// differs from Array.splice() in that new elements are in an array, not varargs
splice(start, deleteCount, newEntryArray) {
if (start < 0) console.warn(`splice(${start}, ...)`);
if (start + deleteCount > this._keyToNodeMap.size) {
console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`);
console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size);
console.trace();
length() { return this._keyToNodeMap.size; }
atIndex(i) {
if (i < 0)
console.warn(`atIndex(${i})`);
if (i >= this._keyToNodeMap.size)
console.warn(`atIndex(${i}>=${this._keyToNodeMap.size})`);
return (new Point(this, i)).getNode().entry;
}
if (!newEntryArray) newEntryArray = [];
const pt = new Point(this, start);
for (let i = 0; i < deleteCount; i++) pt.delete();
for (let i = (newEntryArray.length - 1); i >= 0; i--) {
const entry = newEntryArray[i];
pt.insert(entry);
// differs from Array.splice() in that new elements are in an array, not varargs
splice(start, deleteCount, newEntryArray) {
if (start < 0)
console.warn(`splice(${start}, ...)`);
if (start + deleteCount > this._keyToNodeMap.size) {
console.warn(`splice(${start}, ${deleteCount}, ...), N=${this._keyToNodeMap.size}`);
console.warn('%s %s %s', typeof start, typeof deleteCount, typeof this._keyToNodeMap.size);
console.trace();
}
if (!newEntryArray)
newEntryArray = [];
const pt = new Point(this, start);
for (let i = 0; i < deleteCount; i++)
pt.delete();
for (let i = (newEntryArray.length - 1); i >= 0; i--) {
const entry = newEntryArray[i];
pt.insert(entry);
}
}
}
next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; }
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; }
push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); }
slice(start, end) {
// act like Array.slice()
if (start === undefined) start = 0;
else if (start < 0) start += this._keyToNodeMap.size;
if (end === undefined) end = this._keyToNodeMap.size;
else if (end < 0) end += this._keyToNodeMap.size;
if (start < 0) start = 0;
if (start > this._keyToNodeMap.size) start = this._keyToNodeMap.size;
if (end < 0) end = 0;
if (end > this._keyToNodeMap.size) end = this._keyToNodeMap.size;
if (end <= start) return [];
let n = this.atIndex(start);
const array = [n];
for (let i = 1; i < (end - start); i++) {
n = this.next(n);
array.push(n);
next(entry) { return this._keyToNodeMap.get(entry.key).downPtrs[0].entry || null; }
prev(entry) { return this._keyToNodeMap.get(entry.key).upPtrs[0].entry || null; }
push(entry) { this.splice(this._keyToNodeMap.size, 0, [entry]); }
slice(start, end) {
// act like Array.slice()
if (start === undefined)
start = 0;
else if (start < 0)
start += this._keyToNodeMap.size;
if (end === undefined)
end = this._keyToNodeMap.size;
else if (end < 0)
end += this._keyToNodeMap.size;
if (start < 0)
start = 0;
if (start > this._keyToNodeMap.size)
start = this._keyToNodeMap.size;
if (end < 0)
end = 0;
if (end > this._keyToNodeMap.size)
end = this._keyToNodeMap.size;
if (end <= start)
return [];
let n = this.atIndex(start);
const array = [n];
for (let i = 1; i < (end - start); i++) {
n = this.next(n);
array.push(n);
}
return array;
}
atKey(key) { return this._keyToNodeMap.get(key).entry; }
indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); }
indexOfEntry(entry) { return this.indexOfKey(entry.key); }
containsKey(key) { return this._keyToNodeMap.has(key); }
// gets the last entry starting at or before the offset
atOffset(offset) { return this._getNodeAtOffset(offset).entry; }
keyAtOffset(offset) { return this.atOffset(offset).key; }
offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); }
offsetOfEntry(entry) { return this.offsetOfKey(entry.key); }
setEntryWidth(entry, width) {
entry.width = width;
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
}
totalWidth() { return this._totalWidth; }
offsetOfIndex(i) {
if (i < 0)
return 0;
if (i >= this._keyToNodeMap.size)
return this._totalWidth;
return this.offsetOfEntry(this.atIndex(i));
}
indexOfOffset(offset) {
if (offset <= 0)
return 0;
if (offset >= this._totalWidth)
return this._keyToNodeMap.size;
return this.indexOfEntry(this.atOffset(offset));
}
return array;
}
atKey(key) { return this._keyToNodeMap.get(key).entry; }
indexOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key)); }
indexOfEntry(entry) { return this.indexOfKey(entry.key); }
containsKey(key) { return this._keyToNodeMap.has(key); }
// gets the last entry starting at or before the offset
atOffset(offset) { return this._getNodeAtOffset(offset).entry; }
keyAtOffset(offset) { return this.atOffset(offset).key; }
offsetOfKey(key) { return this._getNodeIndex(this._keyToNodeMap.get(key), true); }
offsetOfEntry(entry) { return this.offsetOfKey(entry.key); }
setEntryWidth(entry, width) {
entry.width = width;
this._totalWidth += this._keyToNodeMap.get(entry.key).propagateWidthChange();
}
totalWidth() { return this._totalWidth; }
offsetOfIndex(i) {
if (i < 0) return 0;
if (i >= this._keyToNodeMap.size) return this._totalWidth;
return this.offsetOfEntry(this.atIndex(i));
}
indexOfOffset(offset) {
if (offset <= 0) return 0;
if (offset >= this._totalWidth) return this._keyToNodeMap.size;
return this.indexOfEntry(this.atOffset(offset));
}
}
module.exports = SkipList;
export default SkipList;

View file

@ -1,5 +1,4 @@
'use strict';
/**
* Creates a socket.io connection.
* @param etherpadBaseUrl - Etherpad URL. If relative, it is assumed to be relative to
@ -10,20 +9,20 @@
* @return socket.io Socket object
*/
const connect = (etherpadBaseUrl, namespace = '/', options = {}) => {
// The API for socket.io's io() function is awkward. The documentation says that the first
// argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used
// as the name of the socket.io namespace to join, and the rest of the URL (including query
// parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but
// is overridden here to allow users to host Etherpad at something like '/etherpad') to get the
// URL of the socket.io endpoint.
const baseUrl = new URL(etherpadBaseUrl, window.location);
const socketioUrl = new URL('socket.io', baseUrl);
const namespaceUrl = new URL(namespace, new URL('/', baseUrl));
return io(namespaceUrl.href, Object.assign({path: socketioUrl.pathname}, options));
// The API for socket.io's io() function is awkward. The documentation says that the first
// argument is a URL, but it is not the URL of the socket.io endpoint. The URL's path part is used
// as the name of the socket.io namespace to join, and the rest of the URL (including query
// parameters, if present) is combined with the `path` option (which defaults to '/socket.io', but
// is overridden here to allow users to host Etherpad at something like '/etherpad') to get the
// URL of the socket.io endpoint.
const baseUrl = new URL(etherpadBaseUrl, window.location);
const socketioUrl = new URL('socket.io', baseUrl);
const namespaceUrl = new URL(namespace, new URL('/', baseUrl));
return io(namespaceUrl.href, Object.assign({ path: socketioUrl.pathname }, options));
};
if (typeof exports === 'object') {
exports.connect = connect;
} else {
window.socketio = {connect};
}
else {
window.socketio = { connect };
}
export { connect };

View file

@ -1,168 +1,124 @@
import "./vendors/jquery.js";
import * as padUtils from "./pad_utils.js";
import * as hooks from "./pluginfw/hooks.js";
import * as socketio from "./socketio.js";
import { loadBroadcastRevisionsJS } from "./broadcast_revisions.js";
import { padimpexp } from "./pad_impexp.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.
*/
// These jQuery things should create local references, but for now `require()`
// assigns to the global `$` and augments it with plugins.
require('./vendors/jquery');
const Cookies = require('./pad_utils').Cookies;
const randomString = require('./pad_utils').randomString;
const hooks = require('./pluginfw/hooks');
const padutils = require('./pad_utils').padutils;
const socketio = require('./socketio');
const Cookies = { Cookies: padUtils }.Cookies;
const randomString = { randomString: padUtils }.randomString;
const padutils = { padutils: padUtils }.padutils;
let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
const init = () => {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
// start the custom js
if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef
// get the padId out of the url
const urlParts = document.location.pathname.split('/');
padId = decodeURIComponent(urlParts[urlParts.length - 2]);
// set the title
document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`;
// ensure we have a token
token = Cookies.get('token');
if (token == null) {
token = `t.${randomString()}`;
Cookies.set('token', token, {expires: 60});
}
socket = socketio.connect(exports.baseURL, '/', {query: {padId}});
// send the ready message once we're connected
socket.on('connect', () => {
sendSocketMsg('CLIENT_READY', {});
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
// start the custom js
if (typeof customStart === 'function')
customStart(); // eslint-disable-line no-undef
// get the padId out of the url
const urlParts = document.location.pathname.split('/');
padId = decodeURIComponent(urlParts[urlParts.length - 2]);
// set the title
document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`;
// ensure we have a token
token = Cookies.get('token');
if (token == null) {
token = `t.${randomString()}`;
Cookies.set('token', token, { expires: 60 });
}
socket = socketio.connect(exports.baseURL, '/', { query: { padId } });
// send the ready message once we're connected
socket.on('connect', () => {
sendSocketMsg('CLIENT_READY', {});
});
socket.on('disconnect', (reason) => {
BroadcastSlider.showReconnectUI();
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect')
socket.connect();
});
// route the incoming messages
socket.on('message', (message) => {
if (message.type === 'CLIENT_VARS') {
handleClientVars(message);
}
else if (message.accessStatus) {
$('body').html('<h2>You have no permission to access this pad</h2>');
}
else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {
changesetLoader.handleMessageFromServer(message);
}
});
// get all the export links
exportLinks = $('#export > .exportlink');
$('button#forcereconnect').click(() => {
window.location.reload();
});
socket; // make the socket available
BroadcastSlider; // Make the slider available
hooks.aCallAll('postTimesliderInit');
});
socket.on('disconnect', (reason) => {
BroadcastSlider.showReconnectUI();
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
if (reason === 'io server disconnect') socket.connect();
});
// route the incoming messages
socket.on('message', (message) => {
if (message.type === 'CLIENT_VARS') {
handleClientVars(message);
} else if (message.accessStatus) {
$('body').html('<h2>You have no permission to access this pad</h2>');
} else if (message.type === 'CHANGESET_REQ' || message.type === 'COLLABROOM') {
changesetLoader.handleMessageFromServer(message);
}
});
// get all the export links
exportLinks = $('#export > .exportlink');
$('button#forcereconnect').click(() => {
window.location.reload();
});
exports.socket = socket; // make the socket available
exports.BroadcastSlider = BroadcastSlider; // Make the slider available
hooks.aCallAll('postTimesliderInit');
});
};
// sends a message over the socket
const sendSocketMsg = (type, data) => {
socket.json.send({
component: 'pad', // FIXME: Remove this stupidity!
type,
data,
padId,
token,
sessionID: Cookies.get('sessionID'),
});
};
const fireWhenAllScriptsAreLoaded = [];
const handleClientVars = (message) => {
// save the client Vars
window.clientVars = message.data;
if (window.clientVars.sessionRefreshInterval) {
const ping =
() => $.ajax('../../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
require('./broadcast_revisions').loadBroadcastRevisionsJS();
changesetLoader = require('./broadcast')
.loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);
// initialize export ui
require('./pad_impexp').padimpexp.init();
// Create a base URI used for timeslider exports
const baseURI = document.location.pathname;
// change export urls when the slider moves
BroadcastSlider.onSlider((revno) => {
// exportLinks is a jQuery Array, so .each is allowed.
exportLinks.each(function () {
// Modified from regular expression to fix:
// https://github.com/ether/etherpad-lite/issues/4071
// Where a padId that was numeric would create the wrong export link
if (this.href) {
const type = this.href.split('export/')[1];
let href = baseURI.split('timeslider')[0];
href += `${revno}/export/${type}`;
this.setAttribute('href', href);
}
socket.json.send({
component: 'pad',
type,
data,
padId,
token,
sessionID: Cookies.get('sessionID'),
});
});
// fire all start functions of these scripts, formerly fired with window.load
for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) {
fireWhenAllScriptsAreLoaded[i]();
}
$('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2);
// Translate some strings where we only want to set the title not the actual values
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
// font family change
$('#viewfontmenu').change(function () {
$('#innerdocbody').css('font-family', $(this).val() || '');
});
};
exports.baseURL = '';
exports.init = init;
const fireWhenAllScriptsAreLoaded = [];
const handleClientVars = (message) => {
// save the client Vars
window.clientVars = message.data;
if (window.clientVars.sessionRefreshInterval) {
const ping = () => $.ajax('../../_extendExpressSessionLifetime', { method: 'PUT' }).catch(() => { });
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
({ loadBroadcastRevisionsJS }.loadBroadcastRevisionsJS());
changesetLoader = require('./broadcast')
.loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, BroadcastSlider);
// initialize export ui
({ padimpexp }.padimpexp.init());
// Create a base URI used for timeslider exports
const baseURI = document.location.pathname;
// change export urls when the slider moves
BroadcastSlider.onSlider((revno) => {
// exportLinks is a jQuery Array, so .each is allowed.
exportLinks.each(function () {
// Modified from regular expression to fix:
// https://github.com/ether/etherpad-lite/issues/4071
// Where a padId that was numeric would create the wrong export link
if (this.href) {
const type = this.href.split('export/')[1];
let href = baseURI.split('timeslider')[0];
href += `${revno}/export/${type}`;
this.setAttribute('href', href);
}
});
});
// fire all start functions of these scripts, formerly fired with window.load
for (let i = 0; i < fireWhenAllScriptsAreLoaded.length; i++) {
fireWhenAllScriptsAreLoaded[i]();
}
$('#ui-slider-handle').css('left', $('#ui-slider-bar').width() - 2);
// Translate some strings where we only want to set the title not the actual values
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
// font family change
$('#viewfontmenu').change(function () {
$('#innerdocbody').css('font-family', $(this).val() || '');
});
};
export const baseURL = '';
export { init as socket };
export { init as BroadcastSlider };
export { init };

View file

@ -1,3 +1,2 @@
'use strict';
module.exports = require('underscore');
export { default } from "underscore";

View file

@ -1,285 +1,245 @@
import * as Changeset from "./Changeset.js";
import * as _ from "./underscore.js";
'use strict';
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* 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.
*/
const Changeset = require('./Changeset');
const _ = require('./underscore');
const undoModule = (() => {
const stack = (() => {
const stackElements = [];
// two types of stackElements:
// 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: <changeset>,]
// [selStart: <char number>, selEnd: <char number>, selFocusAtStart: <boolean>] }
// 2) { elementType: EXTERNAL_CHANGE, changeset: <changeset> }
// invariant: no two consecutive EXTERNAL_CHANGEs
let numUndoableEvents = 0;
const UNDOABLE_EVENT = 'undoableEvent';
const EXTERNAL_CHANGE = 'externalChange';
const clearStack = () => {
stackElements.length = 0;
stackElements.push(
{
elementType: UNDOABLE_EVENT,
eventType: 'bottom',
});
numUndoableEvents = 1;
};
clearStack();
const pushEvent = (event) => {
const e = _.extend(
{}, event);
e.elementType = UNDOABLE_EVENT;
stackElements.push(e);
numUndoableEvents++;
};
const pushExternalChange = (cs) => {
const idx = stackElements.length - 1;
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
stackElements[idx].changeset =
Changeset.compose(stackElements[idx].changeset, cs, getAPool());
} else {
stackElements.push(
{
elementType: EXTERNAL_CHANGE,
changeset: cs,
const stack = (() => {
const stackElements = [];
// two types of stackElements:
// 1) { elementType: UNDOABLE_EVENT, eventType: "anything", [backset: <changeset>,]
// [selStart: <char number>, selEnd: <char number>, selFocusAtStart: <boolean>] }
// 2) { elementType: EXTERNAL_CHANGE, changeset: <changeset> }
// invariant: no two consecutive EXTERNAL_CHANGEs
let numUndoableEvents = 0;
const UNDOABLE_EVENT = 'undoableEvent';
const EXTERNAL_CHANGE = 'externalChange';
const clearStack = () => {
stackElements.length = 0;
stackElements.push({
elementType: UNDOABLE_EVENT,
eventType: 'bottom',
});
}
};
const _exposeEvent = (nthFromTop) => {
// precond: 0 <= nthFromTop < numUndoableEvents
const targetIndex = stackElements.length - 1 - nthFromTop;
let idx = stackElements.length - 1;
while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) {
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
const ex = stackElements[idx];
const un = stackElements[idx - 1];
if (un.backset) {
const excs = ex.changeset;
const unbs = un.backset;
un.backset = Changeset.follow(excs, un.backset, false, getAPool());
ex.changeset = Changeset.follow(unbs, ex.changeset, true, getAPool());
if ((typeof un.selStart) === 'number') {
const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd);
un.selStart = newSel[0];
un.selEnd = newSel[1];
if (un.selStart === un.selEnd) {
un.selFocusAtStart = false;
}
numUndoableEvents = 1;
};
clearStack();
const pushEvent = (event) => {
const e = _.extend({}, event);
e.elementType = UNDOABLE_EVENT;
stackElements.push(e);
numUndoableEvents++;
};
const pushExternalChange = (cs) => {
const idx = stackElements.length - 1;
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
stackElements[idx].changeset =
Changeset.compose(stackElements[idx].changeset, cs, getAPool());
}
else {
stackElements.push({
elementType: EXTERNAL_CHANGE,
changeset: cs,
});
}
};
const _exposeEvent = (nthFromTop) => {
// precond: 0 <= nthFromTop < numUndoableEvents
const targetIndex = stackElements.length - 1 - nthFromTop;
let idx = stackElements.length - 1;
while (idx > targetIndex || stackElements[idx].elementType === EXTERNAL_CHANGE) {
if (stackElements[idx].elementType === EXTERNAL_CHANGE) {
const ex = stackElements[idx];
const un = stackElements[idx - 1];
if (un.backset) {
const excs = ex.changeset;
const unbs = un.backset;
un.backset = Changeset.follow(excs, un.backset, false, getAPool());
ex.changeset = Changeset.follow(unbs, ex.changeset, true, getAPool());
if ((typeof un.selStart) === 'number') {
const newSel = Changeset.characterRangeFollow(excs, un.selStart, un.selEnd);
un.selStart = newSel[0];
un.selEnd = newSel[1];
if (un.selStart === un.selEnd) {
un.selFocusAtStart = false;
}
}
}
stackElements[idx - 1] = ex;
stackElements[idx] = un;
if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) {
ex.changeset =
Changeset.compose(stackElements[idx - 2].changeset, ex.changeset, getAPool());
stackElements.splice(idx - 2, 1);
idx--;
}
}
else {
idx--;
}
}
};
const getNthFromTop = (n) => {
// precond: 0 <= n < numEvents()
_exposeEvent(n);
return stackElements[stackElements.length - 1 - n];
};
const numEvents = () => numUndoableEvents;
const popEvent = () => {
// precond: numEvents() > 0
_exposeEvent(0);
numUndoableEvents--;
return stackElements.pop();
};
return {
numEvents,
popEvent,
pushEvent,
pushExternalChange,
clearStack,
getNthFromTop,
};
})();
// invariant: stack always has at least one undoable event
let undoPtr = 0; // zero-index from top of stack, 0 == top
const clearHistory = () => {
stack.clearStack();
undoPtr = 0;
};
const _charOccurrences = (str, c) => {
let i = 0;
let count = 0;
while (i >= 0 && i < str.length) {
i = str.indexOf(c, i);
if (i >= 0) {
count++;
i++;
}
}
stackElements[idx - 1] = ex;
stackElements[idx] = un;
if (idx >= 2 && stackElements[idx - 2].elementType === EXTERNAL_CHANGE) {
ex.changeset =
Changeset.compose(stackElements[idx - 2].changeset, ex.changeset, getAPool());
stackElements.splice(idx - 2, 1);
idx--;
}
} else {
idx--;
}
}
return count;
};
const getNthFromTop = (n) => {
// precond: 0 <= n < numEvents()
_exposeEvent(n);
return stackElements[stackElements.length - 1 - n];
const _opcodeOccurrences = (cs, opcode) => _charOccurrences(Changeset.unpack(cs).ops, opcode);
const _mergeChangesets = (cs1, cs2) => {
if (!cs1)
return cs2;
if (!cs2)
return cs1;
// Rough heuristic for whether changesets should be considered one action:
// each does exactly one insertion, no dels, and the composition does also; or
// each does exactly one deletion, no ins, and the composition does also.
// A little weird in that it won't merge "make bold" with "insert char"
// but will merge "make bold and insert char" with "insert char",
// though that isn't expected to come up.
const plusCount1 = _opcodeOccurrences(cs1, '+');
const plusCount2 = _opcodeOccurrences(cs2, '+');
const minusCount1 = _opcodeOccurrences(cs1, '-');
const minusCount2 = _opcodeOccurrences(cs2, '-');
if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 1 && minusCount3 === 0) {
return merge;
}
}
else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 0 && minusCount3 === 1) {
return merge;
}
}
return null;
};
const numEvents = () => numUndoableEvents;
const popEvent = () => {
// precond: numEvents() > 0
_exposeEvent(0);
numUndoableEvents--;
return stackElements.pop();
const reportEvent = (event) => {
const topEvent = stack.getNthFromTop(0);
const applySelectionToTop = () => {
if ((typeof event.selStart) === 'number') {
topEvent.selStart = event.selStart;
topEvent.selEnd = event.selEnd;
topEvent.selFocusAtStart = event.selFocusAtStart;
}
};
if ((!event.backset) || Changeset.isIdentity(event.backset)) {
applySelectionToTop();
}
else {
let merged = false;
if (topEvent.eventType === event.eventType) {
const merge = _mergeChangesets(event.backset, topEvent.backset);
if (merge) {
topEvent.backset = merge;
applySelectionToTop();
merged = true;
}
}
if (!merged) {
/*
* Push the event on the undo stack only if it exists, and if it's
* not a "clearauthorship". This disallows undoing the removal of the
* authorship colors, but is a necessary stopgap measure against
* https://github.com/ether/etherpad-lite/issues/2802
*/
if (event && (event.eventType !== 'clearauthorship')) {
stack.pushEvent(event);
}
}
undoPtr = 0;
}
};
const reportExternalChange = (changeset) => {
if (changeset && !Changeset.isIdentity(changeset)) {
stack.pushExternalChange(changeset);
}
};
const _getSelectionInfo = (event) => {
if ((typeof event.selStart) !== 'number') {
return null;
}
else {
return {
selStart: event.selStart,
selEnd: event.selEnd,
selFocusAtStart: event.selFocusAtStart,
};
}
};
// For "undo" and "redo", the change event must be returned
// by eventFunc and NOT reported through the normal mechanism.
// "eventFunc" should take a changeset and an optional selection info object,
// or can be called with no arguments to mean that no undo is possible.
// "eventFunc" will be called exactly once.
const performUndo = (eventFunc) => {
if (undoPtr < stack.numEvents() - 1) {
const backsetEvent = stack.getNthFromTop(undoPtr);
const selectionEvent = stack.getNthFromTop(undoPtr + 1);
const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.pushEvent(undoEvent);
undoPtr += 2;
}
else {
eventFunc();
}
};
const performRedo = (eventFunc) => {
if (undoPtr >= 2) {
const backsetEvent = stack.getNthFromTop(0);
const selectionEvent = stack.getNthFromTop(1);
eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.popEvent();
undoPtr -= 2;
}
else {
eventFunc();
}
};
const getAPool = () => undoModule.apool;
return {
numEvents,
popEvent,
pushEvent,
pushExternalChange,
clearStack,
getNthFromTop,
};
})();
// invariant: stack always has at least one undoable event
let undoPtr = 0; // zero-index from top of stack, 0 == top
const clearHistory = () => {
stack.clearStack();
undoPtr = 0;
};
const _charOccurrences = (str, c) => {
let i = 0;
let count = 0;
while (i >= 0 && i < str.length) {
i = str.indexOf(c, i);
if (i >= 0) {
count++;
i++;
}
}
return count;
};
const _opcodeOccurrences = (cs, opcode) => _charOccurrences(Changeset.unpack(cs).ops, opcode);
const _mergeChangesets = (cs1, cs2) => {
if (!cs1) return cs2;
if (!cs2) return cs1;
// Rough heuristic for whether changesets should be considered one action:
// each does exactly one insertion, no dels, and the composition does also; or
// each does exactly one deletion, no ins, and the composition does also.
// A little weird in that it won't merge "make bold" with "insert char"
// but will merge "make bold and insert char" with "insert char",
// though that isn't expected to come up.
const plusCount1 = _opcodeOccurrences(cs1, '+');
const plusCount2 = _opcodeOccurrences(cs2, '+');
const minusCount1 = _opcodeOccurrences(cs1, '-');
const minusCount2 = _opcodeOccurrences(cs2, '-');
if (plusCount1 === 1 && plusCount2 === 1 && minusCount1 === 0 && minusCount2 === 0) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 1 && minusCount3 === 0) {
return merge;
}
} else if (plusCount1 === 0 && plusCount2 === 0 && minusCount1 === 1 && minusCount2 === 1) {
const merge = Changeset.compose(cs1, cs2, getAPool());
const plusCount3 = _opcodeOccurrences(merge, '+');
const minusCount3 = _opcodeOccurrences(merge, '-');
if (plusCount3 === 0 && minusCount3 === 1) {
return merge;
}
}
return null;
};
const reportEvent = (event) => {
const topEvent = stack.getNthFromTop(0);
const applySelectionToTop = () => {
if ((typeof event.selStart) === 'number') {
topEvent.selStart = event.selStart;
topEvent.selEnd = event.selEnd;
topEvent.selFocusAtStart = event.selFocusAtStart;
}
};
if ((!event.backset) || Changeset.isIdentity(event.backset)) {
applySelectionToTop();
} else {
let merged = false;
if (topEvent.eventType === event.eventType) {
const merge = _mergeChangesets(event.backset, topEvent.backset);
if (merge) {
topEvent.backset = merge;
applySelectionToTop();
merged = true;
}
}
if (!merged) {
/*
* Push the event on the undo stack only if it exists, and if it's
* not a "clearauthorship". This disallows undoing the removal of the
* authorship colors, but is a necessary stopgap measure against
* https://github.com/ether/etherpad-lite/issues/2802
*/
if (event && (event.eventType !== 'clearauthorship')) {
stack.pushEvent(event);
}
}
undoPtr = 0;
}
};
const reportExternalChange = (changeset) => {
if (changeset && !Changeset.isIdentity(changeset)) {
stack.pushExternalChange(changeset);
}
};
const _getSelectionInfo = (event) => {
if ((typeof event.selStart) !== 'number') {
return null;
} else {
return {
selStart: event.selStart,
selEnd: event.selEnd,
selFocusAtStart: event.selFocusAtStart,
};
}
};
// For "undo" and "redo", the change event must be returned
// by eventFunc and NOT reported through the normal mechanism.
// "eventFunc" should take a changeset and an optional selection info object,
// or can be called with no arguments to mean that no undo is possible.
// "eventFunc" will be called exactly once.
const performUndo = (eventFunc) => {
if (undoPtr < stack.numEvents() - 1) {
const backsetEvent = stack.getNthFromTop(undoPtr);
const selectionEvent = stack.getNthFromTop(undoPtr + 1);
const undoEvent = eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.pushEvent(undoEvent);
undoPtr += 2;
} else { eventFunc(); }
};
const performRedo = (eventFunc) => {
if (undoPtr >= 2) {
const backsetEvent = stack.getNthFromTop(0);
const selectionEvent = stack.getNthFromTop(1);
eventFunc(backsetEvent.backset, _getSelectionInfo(selectionEvent));
stack.popEvent();
undoPtr -= 2;
} else { eventFunc(); }
};
const getAPool = () => undoModule.apool;
return {
clearHistory,
reportEvent,
reportExternalChange,
performUndo,
performRedo,
enabled: true,
apool: null,
}; // apool is filled in by caller
clearHistory,
reportEvent,
reportExternalChange,
performUndo,
performRedo,
enabled: true,
apool: null,
}; // apool is filled in by caller
})();
exports.undoModule = undoModule;
export { undoModule };

View file

@ -1,310 +1,294 @@
// WARNING: This file may have been modified from original.
// TODO: Check requirement of this file, this afaik was to cover weird edge cases
// that have probably been fixed in browsers.
/*!
* Bowser - a browser detector
* https://github.com/ded/bowser
* MIT License | (c) Dustin Diaz 2015
*/
!function (name, definition) {
if (typeof module != 'undefined' && module.exports) module.exports = definition()
else if (typeof define == 'function' && define.amd) define(definition)
else this[name] = definition()
if (typeof module != 'undefined' && module.exports)
;
else if (typeof define == 'function' && define.amd)
define(definition);
else
this[name] = definition();
}('bowser', function () {
/**
* See useragents.js for examples of navigator.userAgent
*/
var t = true
function detect(ua) {
function getFirstMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()
, likeAndroid = /like android/i.test(ua)
, android = !likeAndroid && /android/i.test(ua)
, chromeos = /CrOS/.test(ua)
, silk = /silk/i.test(ua)
, sailfish = /sailfish/i.test(ua)
, tizen = /tizen/i.test(ua)
, webos = /(web|hpw)os/i.test(ua)
, windowsphone = /windows phone/i.test(ua)
, windows = !windowsphone && /windows/i.test(ua)
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
, edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i)
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i)
, tablet = /tablet/i.test(ua)
, mobile = !tablet && /[^-]mobi/i.test(ua)
, result
if (/opera|opr/i.test(ua)) {
result = {
name: 'Opera'
, opera: t
, version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser'
, yandexbrowser: t
, version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (windowsphone) {
result = {
name: 'Windows Phone'
, windowsphone: t
}
if (edgeVersion) {
result.msedge = t
result.version = edgeVersion
}
else {
result.msie = t
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i)
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer'
, msie: t
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
}
} else if (chromeos) {
result = {
name: 'Chrome'
, chromeos: t
, chromeBook: t
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
} else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge'
, msedge: t
, version: edgeVersion
}
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome'
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
}
else if (iosdevice) {
result = {
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
}
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier
}
}
else if (sailfish) {
result = {
name: 'Sailfish'
, sailfish: t
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
}
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey'
, seamonkey: t
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
}
}
else if (/firefox|iceweasel/i.test(ua)) {
result = {
name: 'Firefox'
, firefox: t
, version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i)
}
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t
}
}
else if (silk) {
result = {
name: 'Amazon Silk'
, silk: t
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
}
}
else if (android) {
result = {
name: 'Android'
, version: versionIdentifier
}
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS'
, phantom: t
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
}
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry'
, blackberry: t
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
}
}
else if (webos) {
result = {
name: 'WebOS'
, webos: t
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t)
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada'
, bada: t
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen'
, tizen: t
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/safari/i.test(ua)) {
result = {
name: 'Safari'
, safari: t
, version: versionIdentifier
}
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
result.name = result.name || "Webkit"
result.webkit = t
if (!result.version && versionIdentifier) {
result.version = versionIdentifier
}
} else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko"
result.gecko = t
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i)
}
// set OS flags for platforms that have multiple browsers
if (!result.msedge && (android || result.silk)) {
result.android = t
} else if (iosdevice) {
result[iosdevice] = t
result.ios = t
} else if (windows) {
result.windows = t
} else if (mac) {
result.mac = t
} else if (linux) {
result.linux = t
}
// OS version extraction
var osVersion = '';
if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
} else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
} else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
} else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
} else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
} else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
} else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = osVersion.split('.')[0];
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {
result.tablet = t
} else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {
result.mobile = t
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.chrome && result.version >= 20) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)
) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)
) {
result.c = t
} else result.x = t
return result
}
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '')
bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
var browserItem = browserList[i];
if (typeof browserItem=== 'string') {
if (browserItem in bowser) {
return true;
/**
* See useragents.js for examples of navigator.userAgent
*/
var t = true;
function detect(ua) {
function getFirstMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
}
function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase(), likeAndroid = /like android/i.test(ua), android = !likeAndroid && /android/i.test(ua), chromeos = /CrOS/.test(ua), silk = /silk/i.test(ua), sailfish = /sailfish/i.test(ua), tizen = /tizen/i.test(ua), webos = /(web|hpw)os/i.test(ua), windowsphone = /windows phone/i.test(ua), windows = !windowsphone && /windows/i.test(ua), mac = !iosdevice && !silk && /macintosh/i.test(ua), linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua), edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i), versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i), tablet = /tablet/i.test(ua), mobile = !tablet && /[^-]mobi/i.test(ua), result;
if (/opera|opr/i.test(ua)) {
result = {
name: 'Opera',
opera: t,
version: versionIdentifier || getFirstMatch(/(?:opera|opr)[\s\/](\d+(\.\d+)?)/i)
};
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser',
yandexbrowser: t,
version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
};
}
else if (windowsphone) {
result = {
name: 'Windows Phone',
windowsphone: t
};
if (edgeVersion) {
result.msedge = t;
result.version = edgeVersion;
}
else {
result.msie = t;
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i);
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer',
msie: t,
version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
};
}
else if (chromeos) {
result = {
name: 'Chrome',
chromeos: t,
chromeBook: t,
chrome: t,
version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
};
}
else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge',
msedge: t,
version: edgeVersion
};
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome',
chrome: t,
version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
};
}
else if (iosdevice) {
result = {
name: iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
};
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier;
}
}
else if (sailfish) {
result = {
name: 'Sailfish',
sailfish: t,
version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
};
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey',
seamonkey: t,
version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
};
}
else if (/firefox|iceweasel/i.test(ua)) {
result = {
name: 'Firefox',
firefox: t,
version: getFirstMatch(/(?:firefox|iceweasel)[ \/](\d+(\.\d+)?)/i)
};
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t;
}
}
else if (silk) {
result = {
name: 'Amazon Silk',
silk: t,
version: getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
};
}
else if (android) {
result = {
name: 'Android',
version: versionIdentifier
};
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS',
phantom: t,
version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
};
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry',
blackberry: t,
version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
};
}
else if (webos) {
result = {
name: 'WebOS',
webos: t,
version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t);
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada',
bada: t,
version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen',
tizen: t,
version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/safari/i.test(ua)) {
result = {
name: 'Safari',
safari: t,
version: versionIdentifier
};
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
result.name = result.name || "Webkit";
result.webkit = t;
if (!result.version && versionIdentifier) {
result.version = versionIdentifier;
}
}
else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko";
result.gecko = t;
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i);
}
// set OS flags for platforms that have multiple browsers
if (!result.msedge && (android || result.silk)) {
result.android = t;
}
else if (iosdevice) {
result[iosdevice] = t;
result.ios = t;
}
else if (windows) {
result.windows = t;
}
else if (mac) {
result.mac = t;
}
else if (linux) {
result.linux = t;
}
// OS version extraction
var osVersion = '';
if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
}
else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
}
else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
}
else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
}
else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
}
else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
}
else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = osVersion.split('.')[0];
if (tablet || iosdevice == 'ipad' || (android && (osMajorVersion == 3 || (osMajorVersion == 4 && !mobile))) || result.silk) {
result.tablet = t;
}
else if (mobile || iosdevice == 'iphone' || iosdevice == 'ipod' || android || result.blackberry || result.webos || result.bada) {
result.mobile = t;
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.chrome && result.version >= 20) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)) {
result.c = t;
}
else
result.x = t;
return result;
}
return false;
}
/*
* Set our detect method to the main bowser object so we can
* reuse it to test other user agents.
* This is needed to implement future tests.
*/
bowser._detect = detect;
return bowser
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent : '');
bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
var browserItem = browserList[i];
if (typeof browserItem === 'string') {
if (browserItem in bowser) {
return true;
}
}
}
return false;
};
/*
* Set our detect method to the main bowser object so we can
* reuse it to test other user agents.
* This is needed to implement future tests.
*/
bowser._detect = detect;
return bowser;
});
export default definition();

View file

@ -1,6 +1,5 @@
// WARNING: This file has been modified from original.
// TODO: Replace with https://github.com/Simonwep/pickr
// Farbtastic 2.0 alpha
// Original can be found at:
// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/src/farbtastic.js
@ -8,525 +7,456 @@
// https://github.com/mattfarina/farbtastic/blob/71ca15f4a09c8e5a08a1b0d1cf37ef028adf22f0/LICENSE.txt
// edited by Sebastian Castro <sebastian.castro@protonmail.com> on 2020-04-06
(function ($) {
var __debug = false;
var __factor = 1;
$.fn.farbtastic = function (options) {
$.farbtastic(this, options);
return this;
};
$.farbtastic = function (container, options) {
var container = $(container)[0];
return container.farbtastic || (container.farbtastic = new $._farbtastic(container, options));
}
$._farbtastic = function (container, options) {
var fb = this;
/////////////////////////////////////////////////////
/**
* Link to the given element(s) or callback.
*/
fb.linkTo = function (callback) {
// Unbind previous nodes
if (typeof fb.callback == 'object') {
$(fb.callback).unbind('keyup', fb.updateValue);
}
// Reset color
fb.color = null;
// Bind callback or elements
if (typeof callback == 'function') {
fb.callback = callback;
}
else if (typeof callback == 'object' || typeof callback == 'string') {
fb.callback = $(callback);
fb.callback.bind('keyup', fb.updateValue);
if (fb.callback[0].value) {
fb.setColor(fb.callback[0].value);
}
}
return this;
}
fb.updateValue = function (event) {
if (this.value && this.value != fb.color) {
fb.setColor(this.value);
}
}
/**
* Change color with HTML syntax #123456
*/
fb.setColor = function (color) {
var unpack = fb.unpack(color);
if (fb.color != color && unpack) {
fb.color = color;
fb.rgb = unpack;
fb.hsl = fb.RGBToHSL(fb.rgb);
fb.updateDisplay();
}
return this;
}
/**
* Change color with HSL triplet [0..1, 0..1, 0..1]
*/
fb.setHSL = function (hsl) {
fb.hsl = hsl;
var convertedHSL = [hsl[0]]
convertedHSL[1] = hsl[1]*__factor+((1-__factor)/2);
convertedHSL[2] = hsl[2]*__factor+((1-__factor)/2);
fb.rgb = fb.HSLToRGB(convertedHSL);
fb.color = fb.pack(fb.rgb);
fb.updateDisplay();
return this;
}
/////////////////////////////////////////////////////
/**
* Initialize the color picker widget.
*/
fb.initWidget = function () {
// Insert markup and size accordingly.
var dim = {
width: options.width,
height: options.width
var __debug = false;
var __factor = 1;
$.fn.farbtastic = function (options) {
$.farbtastic(this, options);
return this;
};
$(container)
.html(
'<div class="farbtastic" style="position: relative">' +
'<div class="farbtastic-solid"></div>' +
'<canvas class="farbtastic-mask"></canvas>' +
'<canvas class="farbtastic-overlay"></canvas>' +
'</div>'
)
.find('*').attr(dim).css(dim).end()
.find('div>*').css('position', 'absolute');
// IE Fix: Recreate canvas elements with doc.createElement and excanvas.
browser.msie && $('canvas', container).each(function () {
// Fetch info.
var attr = { 'class': $(this).attr('class'), style: this.getAttribute('style') },
e = document.createElement('canvas');
// Replace element.
$(this).before($(e).attr(attr)).remove();
// Init with explorerCanvas.
G_vmlCanvasManager && G_vmlCanvasManager.initElement(e);
// Set explorerCanvas elements dimensions and absolute positioning.
$(e).attr(dim).css(dim).css('position', 'absolute')
.find('*').attr(dim).css(dim);
});
// Determine layout
fb.radius = (options.width - options.wheelWidth) / 2 - 1;
fb.square = Math.floor((fb.radius - options.wheelWidth / 2) * 0.7) - 1;
fb.mid = Math.floor(options.width / 2);
fb.markerSize = options.wheelWidth * 0.3;
fb.solidFill = $('.farbtastic-solid', container).css({
width: fb.square * 2 - 1,
height: fb.square * 2 - 1,
left: fb.mid - fb.square,
top: fb.mid - fb.square
});
// Set up drawing context.
fb.cnvMask = $('.farbtastic-mask', container);
fb.ctxMask = fb.cnvMask[0].getContext('2d');
fb.cnvOverlay = $('.farbtastic-overlay', container);
fb.ctxOverlay = fb.cnvOverlay[0].getContext('2d');
fb.ctxMask.translate(fb.mid, fb.mid);
fb.ctxOverlay.translate(fb.mid, fb.mid);
// Draw widget base layers.
fb.drawCircle();
fb.drawMask();
}
/**
* Draw the color wheel.
*/
fb.drawCircle = function () {
var tm = +(new Date());
// Draw a hue circle with a bunch of gradient-stroked beziers.
// Have to use beziers, as gradient-stroked arcs don't work.
var n = 24,
r = fb.radius,
w = options.wheelWidth,
nudge = 8 / r / n * Math.PI, // Fudge factor for seams.
m = fb.ctxMask,
angle1 = 0, color1, d1;
m.save();
m.lineWidth = w / r;
m.scale(r, r);
// Each segment goes from angle1 to angle2.
for (var i = 0; i <= n; ++i) {
var d2 = i / n,
angle2 = d2 * Math.PI * 2,
// Endpoints
x1 = Math.sin(angle1), y1 = -Math.cos(angle1);
x2 = Math.sin(angle2), y2 = -Math.cos(angle2),
// Midpoint chosen so that the endpoints are tangent to the circle.
am = (angle1 + angle2) / 2,
tan = 1 / Math.cos((angle2 - angle1) / 2),
xm = Math.sin(am) * tan, ym = -Math.cos(am) * tan,
// New color
color2 = fb.pack(fb.HSLToRGB([d2, 1, 0.5]));
if (i > 0) {
if (browser.msie) {
// IE's gradient calculations mess up the colors. Correct along the diagonals.
var corr = (1 + Math.min(Math.abs(Math.tan(angle1)), Math.abs(Math.tan(Math.PI / 2 - angle1)))) / n;
color1 = fb.pack(fb.HSLToRGB([d1 - 0.15 * corr, 1, 0.5]));
color2 = fb.pack(fb.HSLToRGB([d2 + 0.15 * corr, 1, 0.5]));
// Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, color1);
grad.addColorStop(1, color2);
m.fillStyle = grad;
// Draw quadratic curve segment as a fill.
var r1 = (r + w / 2) / r, r2 = (r - w / 2) / r; // inner/outer radius.
m.beginPath();
m.moveTo(x1 * r1, y1 * r1);
m.quadraticCurveTo(xm * r1, ym * r1, x2 * r1, y2 * r1);
m.lineTo(x2 * r2, y2 * r2);
m.quadraticCurveTo(xm * r2, ym * r2, x1 * r2, y1 * r2);
m.fill();
}
else {
// Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, color1);
grad.addColorStop(1, color2);
m.strokeStyle = grad;
// Draw quadratic curve segment.
m.beginPath();
m.moveTo(x1, y1);
m.quadraticCurveTo(xm, ym, x2, y2);
m.stroke();
}
}
// Prevent seams where curves join.
angle1 = angle2 - nudge; color1 = color2; d1 = d2;
}
m.restore();
__debug && $('body').append('<div>drawCircle '+ (+(new Date()) - tm) +'ms');
};
/**
* Draw the saturation/luminance mask.
*/
fb.drawMask = function () {
var tm = +(new Date());
// Iterate over sat/lum space and calculate appropriate mask pixel values.
var size = fb.square * 2, sq = fb.square;
function calculateMask(sizex, sizey, outputPixel) {
var isx = 1 / sizex, isy = 1 / sizey;
for (var y = 0; y <= sizey; ++y) {
var l = 1 - y * isy;
for (var x = 0; x <= sizex; ++x) {
var s = 1 - x * isx;
// From sat/lum to alpha and color (grayscale)
var a = 1 - 2 * Math.min(l * s, (1 - l) * s);
var c = (a > 0) ? ((2 * l - 1 + a) * .5 / a) : 0;
a = a*__factor+(1-__factor)/2;
c = c*__factor+(1-__factor)/2;
outputPixel(x, y, c, a);
}
}
}
// Method #1: direct pixel access (new Canvas).
if (fb.ctxMask.getImageData) {
// Create half-resolution buffer.
var sz = Math.floor(size / 2);
var buffer = document.createElement('canvas');
buffer.width = buffer.height = sz + 1;
var ctx = buffer.getContext('2d');
var frame = ctx.getImageData(0, 0, sz + 1, sz + 1);
var i = 0;
calculateMask(sz, sz, function (x, y, c, a) {
frame.data[i++] = frame.data[i++] = frame.data[i++] = c * 255;
frame.data[i++] = a * 255;
});
ctx.putImageData(frame, 0, 0);
fb.ctxMask.drawImage(buffer, 0, 0, sz + 1, sz + 1, -sq, -sq, sq * 2, sq * 2);
}
// Method #2: drawing commands (old Canvas).
else if (!browser.msie) {
// Render directly at half-resolution
var sz = Math.floor(size / 2);
calculateMask(sz, sz, function (x, y, c, a) {
c = Math.round(c * 255);
fb.ctxMask.fillStyle = 'rgba(' + c + ', ' + c + ', ' + c + ', ' + a +')';
fb.ctxMask.fillRect(x * 2 - sq - 1, y * 2 - sq - 1, 2, 2);
});
}
// Method #3: vertical DXImageTransform gradient strips (IE).
else {
var cache_last, cache, w = 6; // Each strip is 6 pixels wide.
var sizex = Math.floor(size / w);
// 6 vertical pieces of gradient per strip.
calculateMask(sizex, 6, function (x, y, c, a) {
if (x == 0) {
cache_last = cache;
cache = [];
}
c = Math.round(c * 255);
a = Math.round(a * 255);
// We can only start outputting gradients once we have two rows of pixels.
if (y > 0) {
var c_last = cache_last[x][0],
a_last = cache_last[x][1],
color1 = fb.packDX(c_last, a_last),
color2 = fb.packDX(c, a),
y1 = Math.round(fb.mid + ((y - 1) * .333 - 1) * sq),
y2 = Math.round(fb.mid + (y * .333 - 1) * sq);
$('<div>').css({
position: 'absolute',
filter: "progid:DXImageTransform.Microsoft.Gradient(StartColorStr="+ color1 +", EndColorStr="+ color2 +", GradientType=0)",
top: y1,
height: y2 - y1,
// Avoid right-edge sticking out.
left: fb.mid + (x * w - sq - 1),
width: w - (x == sizex ? Math.round(w / 2) : 0)
}).appendTo(fb.cnvMask);
}
cache.push([c, a]);
});
}
__debug && $('body').append('<div>drawMask '+ (+(new Date()) - tm) +'ms');
}
/**
* Draw the selection markers.
*/
fb.drawMarkers = function () {
// Determine marker dimensions
var sz = options.width;
var angle = fb.hsl[0] * 6.28,
x1 = Math.sin(angle) * fb.radius,
y1 = -Math.cos(angle) * fb.radius,
x2 = 2 * fb.square * (.5 - fb.hsl[1]),
y2 = 2 * fb.square * (.5 - fb.hsl[2]);
var circles = [
{ x: x1, y: y1, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
{ x: x1, y: y1, r: fb.markerSize, c: '#fff', lw: 2 },
{ x: x2, y: y2, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
{ x: x2, y: y2, r: fb.markerSize, c: '#fff', lw: 2 },
];
// Update the overlay canvas.
fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz);
for (i in circles) {
var c = circles[i];
fb.ctxOverlay.lineWidth = c.lw;
fb.ctxOverlay.strokeStyle = c.c;
fb.ctxOverlay.beginPath();
fb.ctxOverlay.arc(c.x, c.y, c.r, 0, Math.PI * 2, true);
fb.ctxOverlay.stroke();
}
}
/**
* Update the markers and styles
*/
fb.updateDisplay = function () {
// Determine whether labels/markers should invert.
fb.invert = (fb.rgb[0] * 0.3 + fb.rgb[1] * .59 + fb.rgb[2] * .11) <= 0.6;
// Update the solid background fill.
fb.solidFill.css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5])));
// Draw markers
fb.drawMarkers();
// Linked elements or callback
if (typeof fb.callback == 'object') {
// Set background/foreground color
$(fb.callback).css({
backgroundColor: fb.color,
color: fb.invert ? '#fff' : '#000'
});
// Change linked value
$(fb.callback).each(function() {
if ((typeof this.value == 'string') && this.value != fb.color) {
this.value = fb.color;
}
});
}
else if (typeof fb.callback == 'function') {
fb.callback.call(fb, fb.color);
}
}
/**
* Helper for returning coordinates relative to the center.
*/
fb.widgetCoords = function (event) {
return {
x: event.pageX - fb.offset.left - fb.mid,
y: event.pageY - fb.offset.top - fb.mid
$.farbtastic = function (container, options) {
var container = $(container)[0];
return container.farbtastic || (container.farbtastic = new $._farbtastic(container, options));
};
$._farbtastic = function (container, options) {
var fb = this;
/////////////////////////////////////////////////////
/**
* Link to the given element(s) or callback.
*/
fb.linkTo = function (callback) {
// Unbind previous nodes
if (typeof fb.callback == 'object') {
$(fb.callback).unbind('keyup', fb.updateValue);
}
// Reset color
fb.color = null;
// Bind callback or elements
if (typeof callback == 'function') {
fb.callback = callback;
}
else if (typeof callback == 'object' || typeof callback == 'string') {
fb.callback = $(callback);
fb.callback.bind('keyup', fb.updateValue);
if (fb.callback[0].value) {
fb.setColor(fb.callback[0].value);
}
}
return this;
};
fb.updateValue = function (event) {
if (this.value && this.value != fb.color) {
fb.setColor(this.value);
}
};
/**
* Change color with HTML syntax #123456
*/
fb.setColor = function (color) {
var unpack = fb.unpack(color);
if (fb.color != color && unpack) {
fb.color = color;
fb.rgb = unpack;
fb.hsl = fb.RGBToHSL(fb.rgb);
fb.updateDisplay();
}
return this;
};
/**
* Change color with HSL triplet [0..1, 0..1, 0..1]
*/
fb.setHSL = function (hsl) {
fb.hsl = hsl;
var convertedHSL = [hsl[0]];
convertedHSL[1] = hsl[1] * __factor + ((1 - __factor) / 2);
convertedHSL[2] = hsl[2] * __factor + ((1 - __factor) / 2);
fb.rgb = fb.HSLToRGB(convertedHSL);
fb.color = fb.pack(fb.rgb);
fb.updateDisplay();
return this;
};
/////////////////////////////////////////////////////
/**
* Initialize the color picker widget.
*/
fb.initWidget = function () {
// Insert markup and size accordingly.
var dim = {
width: options.width,
height: options.width
};
$(container)
.html('<div class="farbtastic" style="position: relative">' +
'<div class="farbtastic-solid"></div>' +
'<canvas class="farbtastic-mask"></canvas>' +
'<canvas class="farbtastic-overlay"></canvas>' +
'</div>')
.find('*').attr(dim).css(dim).end()
.find('div>*').css('position', 'absolute');
// IE Fix: Recreate canvas elements with doc.createElement and excanvas.
browser.msie && $('canvas', container).each(function () {
// Fetch info.
var attr = { 'class': $(this).attr('class'), style: this.getAttribute('style') }, e = document.createElement('canvas');
// Replace element.
$(this).before($(e).attr(attr)).remove();
// Init with explorerCanvas.
G_vmlCanvasManager && G_vmlCanvasManager.initElement(e);
// Set explorerCanvas elements dimensions and absolute positioning.
$(e).attr(dim).css(dim).css('position', 'absolute')
.find('*').attr(dim).css(dim);
});
// Determine layout
fb.radius = (options.width - options.wheelWidth) / 2 - 1;
fb.square = Math.floor((fb.radius - options.wheelWidth / 2) * 0.7) - 1;
fb.mid = Math.floor(options.width / 2);
fb.markerSize = options.wheelWidth * 0.3;
fb.solidFill = $('.farbtastic-solid', container).css({
width: fb.square * 2 - 1,
height: fb.square * 2 - 1,
left: fb.mid - fb.square,
top: fb.mid - fb.square
});
// Set up drawing context.
fb.cnvMask = $('.farbtastic-mask', container);
fb.ctxMask = fb.cnvMask[0].getContext('2d');
fb.cnvOverlay = $('.farbtastic-overlay', container);
fb.ctxOverlay = fb.cnvOverlay[0].getContext('2d');
fb.ctxMask.translate(fb.mid, fb.mid);
fb.ctxOverlay.translate(fb.mid, fb.mid);
// Draw widget base layers.
fb.drawCircle();
fb.drawMask();
};
/**
* Draw the color wheel.
*/
fb.drawCircle = function () {
var tm = +(new Date());
// Draw a hue circle with a bunch of gradient-stroked beziers.
// Have to use beziers, as gradient-stroked arcs don't work.
var n = 24, r = fb.radius, w = options.wheelWidth, nudge = 8 / r / n * Math.PI, // Fudge factor for seams.
m = fb.ctxMask, angle1 = 0, color1, d1;
m.save();
m.lineWidth = w / r;
m.scale(r, r);
// Each segment goes from angle1 to angle2.
for (var i = 0; i <= n; ++i) {
var d2 = i / n, angle2 = d2 * Math.PI * 2,
// Endpoints
x1 = Math.sin(angle1), y1 = -Math.cos(angle1);
x2 = Math.sin(angle2), y2 = -Math.cos(angle2),
// Midpoint chosen so that the endpoints are tangent to the circle.
am = (angle1 + angle2) / 2,
tan = 1 / Math.cos((angle2 - angle1) / 2),
xm = Math.sin(am) * tan, ym = -Math.cos(am) * tan,
// New color
color2 = fb.pack(fb.HSLToRGB([d2, 1, 0.5]));
if (i > 0) {
if (browser.msie) {
// IE's gradient calculations mess up the colors. Correct along the diagonals.
var corr = (1 + Math.min(Math.abs(Math.tan(angle1)), Math.abs(Math.tan(Math.PI / 2 - angle1)))) / n;
color1 = fb.pack(fb.HSLToRGB([d1 - 0.15 * corr, 1, 0.5]));
color2 = fb.pack(fb.HSLToRGB([d2 + 0.15 * corr, 1, 0.5]));
// Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, color1);
grad.addColorStop(1, color2);
m.fillStyle = grad;
// Draw quadratic curve segment as a fill.
var r1 = (r + w / 2) / r, r2 = (r - w / 2) / r; // inner/outer radius.
m.beginPath();
m.moveTo(x1 * r1, y1 * r1);
m.quadraticCurveTo(xm * r1, ym * r1, x2 * r1, y2 * r1);
m.lineTo(x2 * r2, y2 * r2);
m.quadraticCurveTo(xm * r2, ym * r2, x1 * r2, y1 * r2);
m.fill();
}
else {
// Create gradient fill between the endpoints.
var grad = m.createLinearGradient(x1, y1, x2, y2);
grad.addColorStop(0, color1);
grad.addColorStop(1, color2);
m.strokeStyle = grad;
// Draw quadratic curve segment.
m.beginPath();
m.moveTo(x1, y1);
m.quadraticCurveTo(xm, ym, x2, y2);
m.stroke();
}
}
// Prevent seams where curves join.
angle1 = angle2 - nudge;
color1 = color2;
d1 = d2;
}
m.restore();
__debug && $('body').append('<div>drawCircle ' + (+(new Date()) - tm) + 'ms');
};
/**
* Draw the saturation/luminance mask.
*/
fb.drawMask = function () {
var tm = +(new Date());
// Iterate over sat/lum space and calculate appropriate mask pixel values.
var size = fb.square * 2, sq = fb.square;
function calculateMask(sizex, sizey, outputPixel) {
var isx = 1 / sizex, isy = 1 / sizey;
for (var y = 0; y <= sizey; ++y) {
var l = 1 - y * isy;
for (var x = 0; x <= sizex; ++x) {
var s = 1 - x * isx;
// From sat/lum to alpha and color (grayscale)
var a = 1 - 2 * Math.min(l * s, (1 - l) * s);
var c = (a > 0) ? ((2 * l - 1 + a) * .5 / a) : 0;
a = a * __factor + (1 - __factor) / 2;
c = c * __factor + (1 - __factor) / 2;
outputPixel(x, y, c, a);
}
}
}
// Method #1: direct pixel access (new Canvas).
if (fb.ctxMask.getImageData) {
// Create half-resolution buffer.
var sz = Math.floor(size / 2);
var buffer = document.createElement('canvas');
buffer.width = buffer.height = sz + 1;
var ctx = buffer.getContext('2d');
var frame = ctx.getImageData(0, 0, sz + 1, sz + 1);
var i = 0;
calculateMask(sz, sz, function (x, y, c, a) {
frame.data[i++] = frame.data[i++] = frame.data[i++] = c * 255;
frame.data[i++] = a * 255;
});
ctx.putImageData(frame, 0, 0);
fb.ctxMask.drawImage(buffer, 0, 0, sz + 1, sz + 1, -sq, -sq, sq * 2, sq * 2);
}
// Method #2: drawing commands (old Canvas).
else if (!browser.msie) {
// Render directly at half-resolution
var sz = Math.floor(size / 2);
calculateMask(sz, sz, function (x, y, c, a) {
c = Math.round(c * 255);
fb.ctxMask.fillStyle = 'rgba(' + c + ', ' + c + ', ' + c + ', ' + a + ')';
fb.ctxMask.fillRect(x * 2 - sq - 1, y * 2 - sq - 1, 2, 2);
});
}
// Method #3: vertical DXImageTransform gradient strips (IE).
else {
var cache_last, cache, w = 6; // Each strip is 6 pixels wide.
var sizex = Math.floor(size / w);
// 6 vertical pieces of gradient per strip.
calculateMask(sizex, 6, function (x, y, c, a) {
if (x == 0) {
cache_last = cache;
cache = [];
}
c = Math.round(c * 255);
a = Math.round(a * 255);
// We can only start outputting gradients once we have two rows of pixels.
if (y > 0) {
var c_last = cache_last[x][0], a_last = cache_last[x][1], color1 = fb.packDX(c_last, a_last), color2 = fb.packDX(c, a), y1 = Math.round(fb.mid + ((y - 1) * .333 - 1) * sq), y2 = Math.round(fb.mid + (y * .333 - 1) * sq);
$('<div>').css({
position: 'absolute',
filter: "progid:DXImageTransform.Microsoft.Gradient(StartColorStr=" + color1 + ", EndColorStr=" + color2 + ", GradientType=0)",
top: y1,
height: y2 - y1,
// Avoid right-edge sticking out.
left: fb.mid + (x * w - sq - 1),
width: w - (x == sizex ? Math.round(w / 2) : 0)
}).appendTo(fb.cnvMask);
}
cache.push([c, a]);
});
}
__debug && $('body').append('<div>drawMask ' + (+(new Date()) - tm) + 'ms');
};
/**
* Draw the selection markers.
*/
fb.drawMarkers = function () {
// Determine marker dimensions
var sz = options.width;
var angle = fb.hsl[0] * 6.28, x1 = Math.sin(angle) * fb.radius, y1 = -Math.cos(angle) * fb.radius, x2 = 2 * fb.square * (.5 - fb.hsl[1]), y2 = 2 * fb.square * (.5 - fb.hsl[2]);
var circles = [
{ x: x1, y: y1, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
{ x: x1, y: y1, r: fb.markerSize, c: '#fff', lw: 2 },
{ x: x2, y: y2, r: fb.markerSize + 1, c: 'rgb(0,0,0,.4)', lw: 2 },
{ x: x2, y: y2, r: fb.markerSize, c: '#fff', lw: 2 },
];
// Update the overlay canvas.
fb.ctxOverlay.clearRect(-fb.mid, -fb.mid, sz, sz);
for (i in circles) {
var c = circles[i];
fb.ctxOverlay.lineWidth = c.lw;
fb.ctxOverlay.strokeStyle = c.c;
fb.ctxOverlay.beginPath();
fb.ctxOverlay.arc(c.x, c.y, c.r, 0, Math.PI * 2, true);
fb.ctxOverlay.stroke();
}
};
/**
* Update the markers and styles
*/
fb.updateDisplay = function () {
// Determine whether labels/markers should invert.
fb.invert = (fb.rgb[0] * 0.3 + fb.rgb[1] * .59 + fb.rgb[2] * .11) <= 0.6;
// Update the solid background fill.
fb.solidFill.css('backgroundColor', fb.pack(fb.HSLToRGB([fb.hsl[0], 1, 0.5])));
// Draw markers
fb.drawMarkers();
// Linked elements or callback
if (typeof fb.callback == 'object') {
// Set background/foreground color
$(fb.callback).css({
backgroundColor: fb.color,
color: fb.invert ? '#fff' : '#000'
});
// Change linked value
$(fb.callback).each(function () {
if ((typeof this.value == 'string') && this.value != fb.color) {
this.value = fb.color;
}
});
}
else if (typeof fb.callback == 'function') {
fb.callback.call(fb, fb.color);
}
};
/**
* Helper for returning coordinates relative to the center.
*/
fb.widgetCoords = function (event) {
return {
x: event.pageX - fb.offset.left - fb.mid,
y: event.pageY - fb.offset.top - fb.mid
};
};
/**
* Mousedown handler
*/
fb.mousedown = function (event) {
// Capture mouse
if (!$._farbtastic.dragging) {
$(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
$._farbtastic.dragging = true;
}
// Update the stored offset for the widget.
fb.offset = $(container).offset();
// Check which area is being dragged
var pos = fb.widgetCoords(event);
fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) > (fb.square + 2);
// Process
fb.mousemove(event);
return false;
};
/**
* Mousemove handler
*/
fb.mousemove = function (event) {
// Get coordinates relative to color picker center
var pos = fb.widgetCoords(event);
// Set new HSL parameters
if (fb.circleDrag) {
var hue = Math.atan2(pos.x, -pos.y) / 6.28;
fb.setHSL([(hue + 1) % 1, fb.hsl[1], fb.hsl[2]]);
}
else {
var sat = Math.max(0, Math.min(1, -(pos.x / fb.square / 2) + .5));
var lum = Math.max(0, Math.min(1, -(pos.y / fb.square / 2) + .5));
fb.setHSL([fb.hsl[0], sat, lum]);
}
return false;
};
/**
* Mouseup handler
*/
fb.mouseup = function () {
// Uncapture mouse
$(document).unbind('mousemove', fb.mousemove);
$(document).unbind('mouseup', fb.mouseup);
$._farbtastic.dragging = false;
};
/* Various color utility functions */
fb.dec2hex = function (x) {
return (x < 16 ? '0' : '') + x.toString(16);
};
fb.packDX = function (c, a) {
return '#' + fb.dec2hex(a) + fb.dec2hex(c) + fb.dec2hex(c) + fb.dec2hex(c);
};
fb.pack = function (rgb) {
var r = Math.round(rgb[0] * 255);
var g = Math.round(rgb[1] * 255);
var b = Math.round(rgb[2] * 255);
return '#' + fb.dec2hex(r) + fb.dec2hex(g) + fb.dec2hex(b);
};
fb.unpack = function (color) {
if (color.length == 7) {
function x(i) {
return parseInt(color.substring(i, i + 2), 16) / 255;
}
return [x(1), x(3), x(5)];
}
else if (color.length == 4) {
function x(i) {
return parseInt(color.substring(i, i + 1), 16) / 15;
}
return [x(1), x(2), x(3)];
}
};
fb.HSLToRGB = function (hsl) {
var m1, m2, r, g, b;
var h = hsl[0], s = hsl[1], l = hsl[2];
m2 = (l <= 0.5) ? l * (s + 1) : l + s - l * s;
m1 = l * 2 - m2;
return [
this.hueToRGB(m1, m2, h + 0.33333),
this.hueToRGB(m1, m2, h),
this.hueToRGB(m1, m2, h - 0.33333)
];
};
fb.hueToRGB = function (m1, m2, h) {
h = (h + 1) % 1;
if (h * 6 < 1)
return m1 + (m2 - m1) * h * 6;
if (h * 2 < 1)
return m2;
if (h * 3 < 2)
return m1 + (m2 - m1) * (0.66666 - h) * 6;
return m1;
};
fb.RGBToHSL = function (rgb) {
var r = rgb[0], g = rgb[1], b = rgb[2], min = Math.min(r, g, b), max = Math.max(r, g, b), delta = max - min, h = 0, s = 0, l = (min + max) / 2;
if (l > 0 && l < 1) {
s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));
}
if (delta > 0) {
if (max == r && max != g)
h += (g - b) / delta;
if (max == g && max != b)
h += (2 + (b - r) / delta);
if (max == b && max != r)
h += (4 + (r - g) / delta);
h /= 6;
}
return [h, s, l];
};
// Parse options.
if (!options.callback) {
options = { callback: options };
}
options = $.extend({
width: 300,
wheelWidth: (options.width || 300) / 10,
callback: null
}, options);
// Initialize.
fb.initWidget();
// Install mousedown handler (the others are set on the document on-demand)
$('canvas.farbtastic-overlay', container).mousedown(fb.mousedown);
// Set linked elements/callback
if (options.callback) {
fb.linkTo(options.callback);
}
// Set to gray.
fb.setColor('#808080');
};
}
/**
* Mousedown handler
*/
fb.mousedown = function (event) {
// Capture mouse
if (!$._farbtastic.dragging) {
$(document).bind('mousemove', fb.mousemove).bind('mouseup', fb.mouseup);
$._farbtastic.dragging = true;
}
// Update the stored offset for the widget.
fb.offset = $(container).offset();
// Check which area is being dragged
var pos = fb.widgetCoords(event);
fb.circleDrag = Math.max(Math.abs(pos.x), Math.abs(pos.y)) > (fb.square + 2);
// Process
fb.mousemove(event);
return false;
}
/**
* Mousemove handler
*/
fb.mousemove = function (event) {
// Get coordinates relative to color picker center
var pos = fb.widgetCoords(event);
// Set new HSL parameters
if (fb.circleDrag) {
var hue = Math.atan2(pos.x, -pos.y) / 6.28;
fb.setHSL([(hue + 1) % 1, fb.hsl[1], fb.hsl[2]]);
}
else {
var sat = Math.max(0, Math.min(1, -(pos.x / fb.square / 2) + .5));
var lum = Math.max(0, Math.min(1, -(pos.y / fb.square / 2) + .5));
fb.setHSL([fb.hsl[0], sat, lum]);
}
return false;
}
/**
* Mouseup handler
*/
fb.mouseup = function () {
// Uncapture mouse
$(document).unbind('mousemove', fb.mousemove);
$(document).unbind('mouseup', fb.mouseup);
$._farbtastic.dragging = false;
}
/* Various color utility functions */
fb.dec2hex = function (x) {
return (x < 16 ? '0' : '') + x.toString(16);
}
fb.packDX = function (c, a) {
return '#' + fb.dec2hex(a) + fb.dec2hex(c) + fb.dec2hex(c) + fb.dec2hex(c);
};
fb.pack = function (rgb) {
var r = Math.round(rgb[0] * 255);
var g = Math.round(rgb[1] * 255);
var b = Math.round(rgb[2] * 255);
return '#' + fb.dec2hex(r) + fb.dec2hex(g) + fb.dec2hex(b);
};
fb.unpack = function (color) {
if (color.length == 7) {
function x(i) {
return parseInt(color.substring(i, i + 2), 16) / 255;
}
return [ x(1), x(3), x(5) ];
}
else if (color.length == 4) {
function x(i) {
return parseInt(color.substring(i, i + 1), 16) / 15;
}
return [ x(1), x(2), x(3) ];
}
};
fb.HSLToRGB = function (hsl) {
var m1, m2, r, g, b;
var h = hsl[0], s = hsl[1], l = hsl[2];
m2 = (l <= 0.5) ? l * (s + 1) : l + s - l * s;
m1 = l * 2 - m2;
return [
this.hueToRGB(m1, m2, h + 0.33333),
this.hueToRGB(m1, m2, h),
this.hueToRGB(m1, m2, h - 0.33333)
];
};
fb.hueToRGB = function (m1, m2, h) {
h = (h + 1) % 1;
if (h * 6 < 1) return m1 + (m2 - m1) * h * 6;
if (h * 2 < 1) return m2;
if (h * 3 < 2) return m1 + (m2 - m1) * (0.66666 - h) * 6;
return m1;
};
fb.RGBToHSL = function (rgb) {
var r = rgb[0], g = rgb[1], b = rgb[2],
min = Math.min(r, g, b),
max = Math.max(r, g, b),
delta = max - min,
h = 0,
s = 0,
l = (min + max) / 2;
if (l > 0 && l < 1) {
s = delta / (l < 0.5 ? (2 * l) : (2 - 2 * l));
}
if (delta > 0) {
if (max == r && max != g) h += (g - b) / delta;
if (max == g && max != b) h += (2 + (b - r) / delta);
if (max == b && max != r) h += (4 + (r - g) / delta);
h /= 6;
}
return [h, s, l];
};
// Parse options.
if (!options.callback) {
options = { callback: options };
}
options = $.extend({
width: 300,
wheelWidth: (options.width || 300) / 10,
callback: null
}, options);
// Initialize.
fb.initWidget();
// Install mousedown handler (the others are set on the document on-demand)
$('canvas.farbtastic-overlay', container).mousedown(fb.mousedown);
// Set linked elements/callback
if (options.callback) {
fb.linkTo(options.callback);
}
// Set to gray.
fb.setColor('#808080');
}
})(jQuery);

View file

@ -1,5 +1,4 @@
// WARNING: This file has been modified from the Original
/*
* Gritter for jQuery
* http://www.boedesign.com/
@ -16,342 +15,267 @@
* notification title and text, and to treat plain strings as text instead of HTML (to avoid XSS
* vunlerabilities).
*/
(function($){
/**
* Set it up as an object under the jQuery namespace
*/
$.gritter = {};
/**
* Set up global options that the user can over-ride
*/
$.gritter.options = {
position: '',
class_name: '', // could be set to 'gritter-light' to use white notifications
time: 3000 // hang on the screen for...
}
/**
* Add a gritter notification to the screen
* @see Gritter#add();
*/
$.gritter.add = function(params){
try {
return Gritter.add(params || {});
} catch(e) {
var err = 'Gritter Error: ' + e;
(typeof(console) != 'undefined' && console.error) ?
console.error(err, params) :
alert(err);
}
}
/**
* Remove a gritter notification from the screen
* @see Gritter#removeSpecific();
*/
$.gritter.remove = function(id, params){
Gritter.removeSpecific(id.split('gritter-item-')[1], params || {});
}
/**
* Remove all notifications
* @see Gritter#stop();
*/
$.gritter.removeAll = function(params){
Gritter.stop(params || {});
}
/**
* Big fat Gritter object
* @constructor (not really since its object literal)
*/
var Gritter = {
// Public - options to over-ride with $.gritter.options in "add"
time: '',
// Private - no touchy the private parts
_custom_timer: 0,
_item_count: 0,
_is_setup: 0,
_tpl_wrap_top: '<div id="gritter-container" class="top"></div>',
_tpl_wrap_bottom: '<div id="gritter-container" class="bottom"></div>',
_tpl_close: '',
_tpl_title: $('<h3>').addClass('gritter-title'),
_tpl_item: ($('<div>').addClass('popup gritter-item')
.append($('<div>').addClass('popup-content')
.append($('<div>').addClass('gritter-content'))
.append($('<div>').addClass('gritter-close')
.append($('<i>').addClass('buttonicon buttonicon-times'))))),
/**
* Add a gritter notification to the screen
* @param {Object} params The object that contains all the options for drawing the notification
* @return {Integer} The specific numeric id to that gritter notification
*/
add: function(params){
// Handle straight text
if(typeof(params) == 'string'){
params = {text:params};
}
// We might have some issues if we don't have a title or text!
if(!params.text){
throw 'You must supply "text" parameter.';
}
// Check the options and set them once
if(!this._is_setup){
this._runSetup();
}
// Basics
var title = params.title,
text = params.text,
image = params.image || '',
position = params.position || 'top',
sticky = params.sticky || false,
item_class = params.class_name || $.gritter.options.class_name,
time_alive = params.time || '';
this._verifyWrapper();
if (sticky) {
item_class += " sticky";
}
this._item_count++;
var number = this._item_count;
// Assign callbacks
$(['before_open', 'after_open', 'before_close', 'after_close']).each(function(i, val){
Gritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function(){}
});
// Reset
this._custom_timer = 0;
// A custom fade time set
if(time_alive){
this._custom_timer = time_alive;
}
// String replacements on the template
if(title){
title = this._tpl_title.clone().append(
typeof title === 'string' ? document.createTextNode(title) : title);
}else{
title = '';
}
const tmp = this._tpl_item.clone();
tmp.attr('id', `gritter-item-${number}`);
tmp.addClass(item_class);
tmp.find('.gritter-content')
.append(title)
.append(typeof text === 'string' ? $('<p>').text(text) : text);
// If it's false, don't show another gritter message
if(this['_before_open_' + number]() === false){
return false;
}
if (['top', 'bottom'].indexOf(position) == -1) {
position = 'top';
}
$('#gritter-container.' + position).append(tmp);
var item = $('#gritter-item-' + this._item_count);
setTimeout(function() { item.addClass('popup-show'); }, 0);
Gritter['_after_open_' + number](item);
if(!sticky){
this._setFadeTimer(item, number);
// Bind the hover/unhover states
$(item).on('mouseenter', function(event) {
Gritter._restoreItemIfFading($(this), number);
});
$(item).on('mouseleave', function(event) {
Gritter._setFadeTimer($(this), number);
});
}
// Clicking (X) makes the perdy thing close
$(item).find('.gritter-close').click(function(){
Gritter.removeSpecific(number, {}, null, true);
});
return number;
},
/**
* If we don't have any more gritter notifications, get rid of the wrapper using this check
* @private
* @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback
* @param {Object} e The jQuery element that we're going to perform the remove() action on
* @param {Boolean} manual_close Did we close the gritter dialog with the (X) button
*/
_countRemoveWrapper: function(unique_id, e, manual_close){
// Remove it then run the callback function
e.remove();
this['_after_close_' + unique_id](e, manual_close);
// Remove container if empty
$('#gritter-container').each(function() {
if ($(this).find('.gritter-item').length == 0) {
$(this).remove();
}
})
},
/**
* Fade out an element after it's been on the screen for x amount of time
* @private
* @param {Object} e The jQuery element to get rid of
* @param {Integer} unique_id The id of the element to remove
* @param {Object} params An optional list of params.
* @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X)
*/
_fade: function(e, unique_id, params, unbind_events){
var params = params || {},
fade = (typeof(params.fade) != 'undefined') ? params.fade : true,
manual_close = unbind_events;
this['_before_close_' + unique_id](e, manual_close);
// If this is true, then we are coming from clicking the (X)
if(unbind_events){
e.unbind('mouseenter mouseleave');
}
// Fade it out or remove it
if(fade){
e.removeClass('popup-show');
setTimeout(function() {
Gritter._countRemoveWrapper(unique_id, e, manual_close);
}, 300)
}
else {
this._countRemoveWrapper(unique_id, e);
}
},
/**
* Remove a specific notification based on an ID
* @param {Integer} unique_id The ID used to delete a specific notification
* @param {Object} params A set of options passed in to determine how to get rid of it
* @param {Object} e The jQuery element that we're "fading" then removing
* @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave
*/
removeSpecific: function(unique_id, params, e, unbind_events){
if(!e){
var e = $('#gritter-item-' + unique_id);
}
// We set the fourth param to let the _fade function know to
// unbind the "mouseleave" event. Once you click (X) there's no going back!
this._fade(e, unique_id, params || {}, unbind_events);
},
/**
* If the item is fading out and we hover over it, restore it!
* @private
* @param {Object} e The HTML element to remove
* @param {Integer} unique_id The ID of the element
*/
_restoreItemIfFading: function(e, unique_id){
clearTimeout(this['_int_id_' + unique_id]);
e.stop().css({ opacity: '', height: '' });
},
/**
* Setup the global options - only once
* @private
*/
_runSetup: function(){
for(opt in $.gritter.options){
this[opt] = $.gritter.options[opt];
}
this._is_setup = 1;
},
/**
* Set the notification to fade out after a certain amount of time
* @private
* @param {Object} item The HTML element we're dealing with
* @param {Integer} unique_id The ID of the element
*/
_setFadeTimer: function(item, unique_id){
var timer_str = (this._custom_timer) ? this._custom_timer : this.time;
this['_int_id_' + unique_id] = setTimeout(function(){
Gritter._fade(item, unique_id);
}, timer_str);
},
/**
* Bring everything to a halt
* @param {Object} params A list of callback functions to pass when all notifications are removed
*/
stop: function(params){
// callbacks (if passed)
var before_close = ($.isFunction(params.before_close)) ? params.before_close : function(){};
var after_close = ($.isFunction(params.after_close)) ? params.after_close : function(){};
var wrap = $('#gritter-container');
before_close(wrap);
wrap.fadeOut(function(){
$(this).remove();
after_close();
});
},
/**
* A check to make sure we have something to wrap our notices with
* @private
*/
_verifyWrapper: function(){
if ($('#gritter-container.top').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_top);
}
if ($('#gritter-container.bottom').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_bottom);
}
}
}
(function ($) {
/**
* Set it up as an object under the jQuery namespace
*/
$.gritter = {};
/**
* Set up global options that the user can over-ride
*/
$.gritter.options = {
position: '',
class_name: '',
time: 3000 // hang on the screen for...
};
/**
* Add a gritter notification to the screen
* @see Gritter#add();
*/
$.gritter.add = function (params) {
try {
return Gritter.add(params || {});
}
catch (e) {
var err = 'Gritter Error: ' + e;
(typeof (console) != 'undefined' && console.error) ?
console.error(err, params) :
alert(err);
}
};
/**
* Remove a gritter notification from the screen
* @see Gritter#removeSpecific();
*/
$.gritter.remove = function (id, params) {
Gritter.removeSpecific(id.split('gritter-item-')[1], params || {});
};
/**
* Remove all notifications
* @see Gritter#stop();
*/
$.gritter.removeAll = function (params) {
Gritter.stop(params || {});
};
/**
* Big fat Gritter object
* @constructor (not really since its object literal)
*/
var Gritter = {
// Public - options to over-ride with $.gritter.options in "add"
time: '',
// Private - no touchy the private parts
_custom_timer: 0,
_item_count: 0,
_is_setup: 0,
_tpl_wrap_top: '<div id="gritter-container" class="top"></div>',
_tpl_wrap_bottom: '<div id="gritter-container" class="bottom"></div>',
_tpl_close: '',
_tpl_title: $('<h3>').addClass('gritter-title'),
_tpl_item: ($('<div>').addClass('popup gritter-item')
.append($('<div>').addClass('popup-content')
.append($('<div>').addClass('gritter-content'))
.append($('<div>').addClass('gritter-close')
.append($('<i>').addClass('buttonicon buttonicon-times'))))),
/**
* Add a gritter notification to the screen
* @param {Object} params The object that contains all the options for drawing the notification
* @return {Integer} The specific numeric id to that gritter notification
*/
add: function (params) {
// Handle straight text
if (typeof (params) == 'string') {
params = { text: params };
}
// We might have some issues if we don't have a title or text!
if (!params.text) {
throw 'You must supply "text" parameter.';
}
// Check the options and set them once
if (!this._is_setup) {
this._runSetup();
}
// Basics
var title = params.title, text = params.text, image = params.image || '', position = params.position || 'top', sticky = params.sticky || false, item_class = params.class_name || $.gritter.options.class_name, time_alive = params.time || '';
this._verifyWrapper();
if (sticky) {
item_class += " sticky";
}
this._item_count++;
var number = this._item_count;
// Assign callbacks
$(['before_open', 'after_open', 'before_close', 'after_close']).each(function (i, val) {
Gritter['_' + val + '_' + number] = ($.isFunction(params[val])) ? params[val] : function () { };
});
// Reset
this._custom_timer = 0;
// A custom fade time set
if (time_alive) {
this._custom_timer = time_alive;
}
// String replacements on the template
if (title) {
title = this._tpl_title.clone().append(typeof title === 'string' ? document.createTextNode(title) : title);
}
else {
title = '';
}
const tmp = this._tpl_item.clone();
tmp.attr('id', `gritter-item-${number}`);
tmp.addClass(item_class);
tmp.find('.gritter-content')
.append(title)
.append(typeof text === 'string' ? $('<p>').text(text) : text);
// If it's false, don't show another gritter message
if (this['_before_open_' + number]() === false) {
return false;
}
if (['top', 'bottom'].indexOf(position) == -1) {
position = 'top';
}
$('#gritter-container.' + position).append(tmp);
var item = $('#gritter-item-' + this._item_count);
setTimeout(function () { item.addClass('popup-show'); }, 0);
Gritter['_after_open_' + number](item);
if (!sticky) {
this._setFadeTimer(item, number);
// Bind the hover/unhover states
$(item).on('mouseenter', function (event) {
Gritter._restoreItemIfFading($(this), number);
});
$(item).on('mouseleave', function (event) {
Gritter._setFadeTimer($(this), number);
});
}
// Clicking (X) makes the perdy thing close
$(item).find('.gritter-close').click(function () {
Gritter.removeSpecific(number, {}, null, true);
});
return number;
},
/**
* If we don't have any more gritter notifications, get rid of the wrapper using this check
* @private
* @param {Integer} unique_id The ID of the element that was just deleted, use it for a callback
* @param {Object} e The jQuery element that we're going to perform the remove() action on
* @param {Boolean} manual_close Did we close the gritter dialog with the (X) button
*/
_countRemoveWrapper: function (unique_id, e, manual_close) {
// Remove it then run the callback function
e.remove();
this['_after_close_' + unique_id](e, manual_close);
// Remove container if empty
$('#gritter-container').each(function () {
if ($(this).find('.gritter-item').length == 0) {
$(this).remove();
}
});
},
/**
* Fade out an element after it's been on the screen for x amount of time
* @private
* @param {Object} e The jQuery element to get rid of
* @param {Integer} unique_id The id of the element to remove
* @param {Object} params An optional list of params.
* @param {Boolean} unbind_events Unbind the mouseenter/mouseleave events if they click (X)
*/
_fade: function (e, unique_id, params, unbind_events) {
var params = params || {}, fade = (typeof (params.fade) != 'undefined') ? params.fade : true, manual_close = unbind_events;
this['_before_close_' + unique_id](e, manual_close);
// If this is true, then we are coming from clicking the (X)
if (unbind_events) {
e.unbind('mouseenter mouseleave');
}
// Fade it out or remove it
if (fade) {
e.removeClass('popup-show');
setTimeout(function () {
Gritter._countRemoveWrapper(unique_id, e, manual_close);
}, 300);
}
else {
this._countRemoveWrapper(unique_id, e);
}
},
/**
* Remove a specific notification based on an ID
* @param {Integer} unique_id The ID used to delete a specific notification
* @param {Object} params A set of options passed in to determine how to get rid of it
* @param {Object} e The jQuery element that we're "fading" then removing
* @param {Boolean} unbind_events If we clicked on the (X) we set this to true to unbind mouseenter/mouseleave
*/
removeSpecific: function (unique_id, params, e, unbind_events) {
if (!e) {
var e = $('#gritter-item-' + unique_id);
}
// We set the fourth param to let the _fade function know to
// unbind the "mouseleave" event. Once you click (X) there's no going back!
this._fade(e, unique_id, params || {}, unbind_events);
},
/**
* If the item is fading out and we hover over it, restore it!
* @private
* @param {Object} e The HTML element to remove
* @param {Integer} unique_id The ID of the element
*/
_restoreItemIfFading: function (e, unique_id) {
clearTimeout(this['_int_id_' + unique_id]);
e.stop().css({ opacity: '', height: '' });
},
/**
* Setup the global options - only once
* @private
*/
_runSetup: function () {
for (opt in $.gritter.options) {
this[opt] = $.gritter.options[opt];
}
this._is_setup = 1;
},
/**
* Set the notification to fade out after a certain amount of time
* @private
* @param {Object} item The HTML element we're dealing with
* @param {Integer} unique_id The ID of the element
*/
_setFadeTimer: function (item, unique_id) {
var timer_str = (this._custom_timer) ? this._custom_timer : this.time;
this['_int_id_' + unique_id] = setTimeout(function () {
Gritter._fade(item, unique_id);
}, timer_str);
},
/**
* Bring everything to a halt
* @param {Object} params A list of callback functions to pass when all notifications are removed
*/
stop: function (params) {
// callbacks (if passed)
var before_close = ($.isFunction(params.before_close)) ? params.before_close : function () { };
var after_close = ($.isFunction(params.after_close)) ? params.after_close : function () { };
var wrap = $('#gritter-container');
before_close(wrap);
wrap.fadeOut(function () {
$(this).remove();
after_close();
});
},
/**
* A check to make sure we have something to wrap our notices with
* @private
*/
_verifyWrapper: function () {
if ($('#gritter-container.top').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_top);
}
if ($('#gritter-container.bottom').length === 0) {
$('#editorcontainerbox').append(this._tpl_wrap_bottom);
}
}
};
})(jQuery);
// For Emacs:
// Local Variables:
// tab-width: 2
// indent-tabs-mode: t
// End:
// vi: ts=2:noet:sw=2

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,212 +1,187 @@
// WARNING: This file has been modified from the Original
// TODO: Nice Select seems relatively abandoned, we should consider other options.
/* jQuery Nice Select - v1.1.0
https://github.com/hernansartorio/jquery-nice-select
Made by Hernán Sartorio */
(function($) {
$.fn.niceSelect = function(method) {
// Methods
if (typeof method == 'string') {
if (method == 'update') {
this.each(function() {
var $select = $(this);
var $dropdown = $(this).next('.nice-select');
var open = $dropdown.hasClass('open');
if ($dropdown.length) {
$dropdown.remove();
create_nice_select($select);
if (open) {
$select.next().trigger('click');
(function ($) {
$.fn.niceSelect = function (method) {
// Methods
if (typeof method == 'string') {
if (method == 'update') {
this.each(function () {
var $select = $(this);
var $dropdown = $(this).next('.nice-select');
var open = $dropdown.hasClass('open');
if ($dropdown.length) {
$dropdown.remove();
create_nice_select($select);
if (open) {
$select.next().trigger('click');
}
}
});
}
else if (method == 'destroy') {
this.each(function () {
var $select = $(this);
var $dropdown = $(this).next('.nice-select');
if ($dropdown.length) {
$dropdown.remove();
$select.css('display', '');
}
});
if ($('.nice-select').length == 0) {
$(document).off('.nice_select');
}
}
else {
console.log('Method "' + method + '" does not exist.');
}
return this;
}
// Hide native select
this.hide();
// Create custom markup
this.each(function () {
var $select = $(this);
if (!$select.next().hasClass('nice-select')) {
create_nice_select($select);
}
}
});
} else if (method == 'destroy') {
this.each(function() {
var $select = $(this);
var $dropdown = $(this).next('.nice-select');
if ($dropdown.length) {
$dropdown.remove();
$select.css('display', '');
}
function create_nice_select($select) {
$select.after($('<div></div>')
.addClass('nice-select')
.addClass($select.attr('class') || '')
.addClass($select.attr('disabled') ? 'disabled' : '')
.attr('tabindex', $select.attr('disabled') ? null : '0')
.html('<span class="current"></span><ul class="list thin-scrollbar"></ul>'));
var $dropdown = $select.next();
var $options = $select.find('option');
var $selected = $select.find('option:selected');
$dropdown.find('.current').html($selected.data('display') || $selected.text());
$options.each(function (i) {
var $option = $(this);
var display = $option.data('display');
$dropdown.find('ul').append($('<li></li>')
.attr('data-value', $option.val())
.attr('data-display', (display || null))
.addClass('option' +
($option.is(':selected') ? ' selected' : '') +
($option.is(':disabled') ? ' disabled' : ''))
.html($option.text()));
});
}
/* Event listeners */
// Unbind existing events in case that the plugin has been initialized before
$(document).off('.nice_select');
// Open/close
$(document).on('click.nice_select', '.nice-select', function (event) {
var $dropdown = $(this);
$('.nice-select').not($dropdown).removeClass('open');
$dropdown.toggleClass('open');
if ($dropdown.hasClass('open')) {
$dropdown.find('.option');
$dropdown.find('.focus').removeClass('focus');
$dropdown.find('.selected').addClass('focus');
if ($dropdown.closest('.toolbar').length > 0) {
$dropdown.find('.list').css('left', $dropdown.offset().left);
$dropdown.find('.list').css('top', $dropdown.offset().top + $dropdown.outerHeight());
$dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');
}
$listHeight = $dropdown.find('.list').outerHeight();
$top = $dropdown.parent().offset().top;
$bottom = $('body').height() - $top;
$maxListHeight = $bottom - $dropdown.outerHeight() - 20;
if ($maxListHeight < 200) {
$dropdown.addClass('reverse');
$maxListHeight = 250;
}
else {
$dropdown.removeClass('reverse');
}
$dropdown.find('.list').css('max-height', $maxListHeight + 'px');
}
else {
$dropdown.focus();
}
});
if ($('.nice-select').length == 0) {
$(document).off('.nice_select');
// Close when clicking outside
$(document).on('click.nice_select', function (event) {
if ($(event.target).closest('.nice-select').length === 0) {
$('.nice-select').removeClass('open').find('.option');
}
});
// Option click
$(document).on('click.nice_select', '.nice-select .option:not(.disabled)', function (event) {
var $option = $(this);
var $dropdown = $option.closest('.nice-select');
$dropdown.find('.selected').removeClass('selected');
$option.addClass('selected');
var text = $option.data('display') || $option.text();
$dropdown.find('.current').text(text);
$dropdown.prev('select').val($option.data('value')).trigger('change');
});
// Keyboard events
$(document).on('keydown.nice_select', '.nice-select', function (event) {
var $dropdown = $(this);
var $focused_option = $($dropdown.find('.focus') || $dropdown.find('.list .option.selected'));
// Space or Enter
if (event.keyCode == 32 || event.keyCode == 13) {
if ($dropdown.hasClass('open')) {
$focused_option.trigger('click');
}
else {
$dropdown.trigger('click');
}
return false;
// Down
}
else if (event.keyCode == 40) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
else {
var $next = $focused_option.nextAll('.option:not(.disabled)').first();
if ($next.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$next.addClass('focus');
}
}
return false;
// Up
}
else if (event.keyCode == 38) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
else {
var $prev = $focused_option.prevAll('.option:not(.disabled)').first();
if ($prev.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$prev.addClass('focus');
}
}
return false;
// Esc
}
else if (event.keyCode == 27) {
if ($dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
// Tab
}
else if (event.keyCode == 9) {
if ($dropdown.hasClass('open')) {
return false;
}
}
});
// Detect CSS pointer-events support, for IE <= 10. From Modernizr.
var style = document.createElement('a').style;
style.cssText = 'pointer-events:auto';
if (style.pointerEvents !== 'auto') {
$('html').addClass('no-csspointerevents');
}
} else {
console.log('Method "' + method + '" does not exist.')
}
return this;
}
// Hide native select
this.hide();
// Create custom markup
this.each(function() {
var $select = $(this);
if (!$select.next().hasClass('nice-select')) {
create_nice_select($select);
}
});
function create_nice_select($select) {
$select.after($('<div></div>')
.addClass('nice-select')
.addClass($select.attr('class') || '')
.addClass($select.attr('disabled') ? 'disabled' : '')
.attr('tabindex', $select.attr('disabled') ? null : '0')
.html('<span class="current"></span><ul class="list thin-scrollbar"></ul>')
);
var $dropdown = $select.next();
var $options = $select.find('option');
var $selected = $select.find('option:selected');
$dropdown.find('.current').html($selected.data('display') || $selected.text());
$options.each(function(i) {
var $option = $(this);
var display = $option.data('display');
$dropdown.find('ul').append($('<li></li>')
.attr('data-value', $option.val())
.attr('data-display', (display || null))
.addClass('option' +
($option.is(':selected') ? ' selected' : '') +
($option.is(':disabled') ? ' disabled' : ''))
.html($option.text())
);
});
}
/* Event listeners */
// Unbind existing events in case that the plugin has been initialized before
$(document).off('.nice_select');
// Open/close
$(document).on('click.nice_select', '.nice-select', function(event) {
var $dropdown = $(this);
$('.nice-select').not($dropdown).removeClass('open');
$dropdown.toggleClass('open');
if ($dropdown.hasClass('open')) {
$dropdown.find('.option');
$dropdown.find('.focus').removeClass('focus');
$dropdown.find('.selected').addClass('focus');
if ($dropdown.closest('.toolbar').length > 0) {
$dropdown.find('.list').css('left', $dropdown.offset().left);
$dropdown.find('.list').css('top', $dropdown.offset().top + $dropdown.outerHeight());
$dropdown.find('.list').css('min-width', $dropdown.outerWidth() + 'px');
}
$listHeight = $dropdown.find('.list').outerHeight();
$top = $dropdown.parent().offset().top;
$bottom = $('body').height() - $top;
$maxListHeight = $bottom - $dropdown.outerHeight() - 20;
if ($maxListHeight < 200) {
$dropdown.addClass('reverse');
$maxListHeight = 250;
} else {
$dropdown.removeClass('reverse')
}
$dropdown.find('.list').css('max-height', $maxListHeight + 'px');
} else {
$dropdown.focus();
}
});
// Close when clicking outside
$(document).on('click.nice_select', function(event) {
if ($(event.target).closest('.nice-select').length === 0) {
$('.nice-select').removeClass('open').find('.option');
}
});
// Option click
$(document).on('click.nice_select', '.nice-select .option:not(.disabled)', function(event) {
var $option = $(this);
var $dropdown = $option.closest('.nice-select');
$dropdown.find('.selected').removeClass('selected');
$option.addClass('selected');
var text = $option.data('display') || $option.text();
$dropdown.find('.current').text(text);
$dropdown.prev('select').val($option.data('value')).trigger('change');
});
// Keyboard events
$(document).on('keydown.nice_select', '.nice-select', function(event) {
var $dropdown = $(this);
var $focused_option = $($dropdown.find('.focus') || $dropdown.find('.list .option.selected'));
// Space or Enter
if (event.keyCode == 32 || event.keyCode == 13) {
if ($dropdown.hasClass('open')) {
$focused_option.trigger('click');
} else {
$dropdown.trigger('click');
}
return false;
// Down
} else if (event.keyCode == 40) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
} else {
var $next = $focused_option.nextAll('.option:not(.disabled)').first();
if ($next.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$next.addClass('focus');
}
}
return false;
// Up
} else if (event.keyCode == 38) {
if (!$dropdown.hasClass('open')) {
$dropdown.trigger('click');
} else {
var $prev = $focused_option.prevAll('.option:not(.disabled)').first();
if ($prev.length > 0) {
$dropdown.find('.focus').removeClass('focus');
$prev.addClass('focus');
}
}
return false;
// Esc
} else if (event.keyCode == 27) {
if ($dropdown.hasClass('open')) {
$dropdown.trigger('click');
}
// Tab
} else if (event.keyCode == 9) {
if ($dropdown.hasClass('open')) {
return false;
}
}
});
// Detect CSS pointer-events support, for IE <= 10. From Modernizr.
var style = document.createElement('a').style;
style.cssText = 'pointer-events:auto';
if (style.pointerEvents !== 'auto') {
$('html').addClass('no-csspointerevents');
}
return this;
};
return this;
};
}(jQuery));

View file

@ -1,7 +1,6 @@
'use strict';
window.customStart = () => {
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
};

View file

@ -1,7 +1,6 @@
'use strict';
window.customStart = () => {
$('#pad_title').show();
$('.buttonicon').mousedown(function () { $(this).parent().addClass('pressed'); });
$('.buttonicon').mouseup(function () { $(this).parent().removeClass('pressed'); });
$('#pad_title').show();
$('.buttonicon').mousedown(function () { $(this).parent().addClass('pressed'); });
$('.buttonicon').mouseup(function () { $(this).parent().removeClass('pressed'); });
};

View file

@ -1,4 +1,3 @@
'use strict';
window.customStart = () => {
};

View file

@ -1,7 +1,6 @@
'use strict';
window.customStart = () => {
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
};

View file

@ -1,7 +1,6 @@
'use strict';
window.customStart = () => {
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
};

View file

@ -1,7 +1,6 @@
'use strict';
window.customStart = () => {
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
};

View file

@ -1,67 +1,59 @@
import assert$0 from "assert";
import * as common from "../common.js";
import * as exportEtherpad from "../../../node/utils/ExportEtherpad.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 exportEtherpad = require('../../../node/utils/ExportEtherpad');
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 padId;
beforeEach(async function () {
padId = common.randomString();
assert(!await padManager.doesPadExist(padId));
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];
let padId;
beforeEach(async function () {
padId = common.randomString();
assert(!await padManager.doesPadExist(padId));
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{ hook_fn: () => ['custom'] }];
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
});
it('exports custom records', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert.equal(data[`custom:${padId}`], 'a');
assert.equal(data[`custom:${padId}:`], 'b');
assert.equal(data[`custom:${padId}:foo`], 'c');
});
it('export from read-only pad uses read-only ID', async function () {
const pad = await padManager.getPad(padId);
const readOnlyId = await readOnlyManager.getReadOnlyId(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(padId, readOnlyId);
assert.equal(data[`custom:${readOnlyId}`], 'a');
assert.equal(data[`custom:${readOnlyId}:`], 'b');
assert.equal(data[`custom:${readOnlyId}:foo`], 'c');
assert(!(`custom:${padId}` in data));
assert(!(`custom:${padId}:` in data));
assert(!(`custom:${padId}:foo` in data));
});
it('does not export records from pad with similar ID', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}x`, 'a');
await pad.db.set(`custom:${padId}x:`, 'b');
await pad.db.set(`custom:${padId}x:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert(!(`custom:${padId}x` in data));
assert(!(`custom:${padId}x:` in data));
assert(!(`custom:${padId}x:foo` in data));
});
});
it('exports custom records', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert.equal(data[`custom:${padId}`], 'a');
assert.equal(data[`custom:${padId}:`], 'b');
assert.equal(data[`custom:${padId}:foo`], 'c');
});
it('export from read-only pad uses read-only ID', async function () {
const pad = await padManager.getPad(padId);
const readOnlyId = await readOnlyManager.getReadOnlyId(padId);
await pad.db.set(`custom:${padId}`, 'a');
await pad.db.set(`custom:${padId}:`, 'b');
await pad.db.set(`custom:${padId}:foo`, 'c');
const data = await exportEtherpad.getPadRaw(padId, readOnlyId);
assert.equal(data[`custom:${readOnlyId}`], 'a');
assert.equal(data[`custom:${readOnlyId}:`], 'b');
assert.equal(data[`custom:${readOnlyId}:foo`], 'c');
assert(!(`custom:${padId}` in data));
assert(!(`custom:${padId}:` in data));
assert(!(`custom:${padId}:foo` in data));
});
it('does not export records from pad with similar ID', async function () {
const pad = await padManager.getPad(padId);
await pad.db.set(`custom:${padId}x`, 'a');
await pad.db.set(`custom:${padId}x:`, 'b');
await pad.db.set(`custom:${padId}x:foo`, 'c');
const data = await exportEtherpad.getPadRaw(pad.id, null);
assert(!(`custom:${padId}x` in data));
assert(!(`custom:${padId}x:` in data));
assert(!(`custom:${padId}x:foo` in data));
});
});
});

View file

@ -1,208 +1,185 @@
import assert$0 from "assert";
import * as authorManager from "../../../node/db/AuthorManager.js";
import * as db from "../../../node/db/DB.js";
import * as importEtherpad from "../../../node/utils/ImportEtherpad.js";
import * as padManager from "../../../node/db/PadManager.js";
import * as plugins from "../../../static/js/pluginfw/plugin_defs.js";
import * as padUtils from "../../../static/js/pad_utils.js";
'use strict';
const assert = require('assert').strict;
const authorManager = require('../../../node/db/AuthorManager');
const db = require('../../../node/db/DB');
const importEtherpad = require('../../../node/utils/ImportEtherpad');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const {randomString} = require('../../../static/js/pad_utils');
const assert = assert$0.strict;
const { randomString } = padUtils;
describe(__filename, function () {
let padId;
const makeAuthorId = () => `a.${randomString(16)}`;
const makeExport = (authorId) => ({
'pad:testing': {
atext: {
text: 'foo\n',
attribs: '|1+4',
},
pool: {
numToAttrib: {},
nextNum: 0,
},
head: 0,
savedRevisions: [],
},
[`globalAuthor:${authorId}`]: {
colorId: '#000000',
name: 'new',
timestamp: 1598747784631,
padIDs: 'testing',
},
'pad:testing:revs:0': {
changeset: 'Z:1>3+3$foo',
meta: {
author: '',
timestamp: 1597632398288,
pool: {
numToAttrib: {},
nextNum: 0,
let padId;
const makeAuthorId = () => `a.${randomString(16)}`;
const makeExport = (authorId) => ({
'pad:testing': {
atext: {
text: 'foo\n',
attribs: '|1+4',
},
pool: {
numToAttrib: {},
nextNum: 0,
},
head: 0,
savedRevisions: [],
},
atext: {
text: 'foo\n',
attribs: '|1+4',
[`globalAuthor:${authorId}`]: {
colorId: '#000000',
name: 'new',
timestamp: 1598747784631,
padIDs: 'testing',
},
},
},
});
beforeEach(async function () {
padId = randomString(10);
assert(!await padManager.doesPadExist(padId));
});
it('unknown db records are ignored', async function () {
const badKey = `maliciousDbKey${randomString(10)}`;
await importEtherpad.setPadRaw(padId, JSON.stringify({
[badKey]: 'value',
...makeExport(makeAuthorId()),
}));
assert(await db.get(badKey) == null);
});
it('changes are all or nothing', async function () {
const authorId = makeAuthorId();
const data = makeExport(authorId);
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
assert(!await authorManager.doesAuthorExist(authorId));
assert(!await padManager.doesPadExist(padId));
});
describe('author pad IDs', function () {
let existingAuthorId;
let newAuthorId;
'pad:testing:revs:0': {
changeset: 'Z:1>3+3$foo',
meta: {
author: '',
timestamp: 1597632398288,
pool: {
numToAttrib: {},
nextNum: 0,
},
atext: {
text: 'foo\n',
attribs: '|1+4',
},
},
},
});
beforeEach(async function () {
existingAuthorId = (await authorManager.createAuthor('existing')).authorID;
assert(await authorManager.doesAuthorExist(existingAuthorId));
assert.deepEqual((await authorManager.listPadsOfAuthor(existingAuthorId)).padIDs, []);
newAuthorId = makeAuthorId();
assert.notEqual(newAuthorId, existingAuthorId);
assert(!await authorManager.doesAuthorExist(newAuthorId));
padId = randomString(10);
assert(!await padManager.doesPadExist(padId));
});
it('author does not yet exist', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'new');
assert.equal(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
it('unknown db records are ignored', async function () {
const badKey = `maliciousDbKey${randomString(10)}`;
await importEtherpad.setPadRaw(padId, JSON.stringify({
[badKey]: 'value',
...makeExport(makeAuthorId()),
}));
assert(await db.get(badKey) == null);
});
it('author already exists, no pads', async function () {
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, on different pad', async function () {
const otherPadId = randomString(10);
await authorManager.addPad(existingAuthorId, otherPadId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual(
(await authorManager.listPadsOfAuthor(newAuthorId)).padIDs.sort(),
[otherPadId, padId].sort());
});
it('author already exists, on same pad', async function () {
await authorManager.addPad(existingAuthorId, padId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
});
describe('enforces consistent pad ID', function () {
it('pad record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId'] = data['pad:testing'];
delete data['pad:testing'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('globalAuthor record has different pad ID', async function () {
const authorId = makeAuthorId();
const data = makeExport(authorId);
data[`globalAuthor:${authorId}`].padIDs = 'differentPadId';
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('pad rev record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
});
describe('order of records does not matter', function () {
for (const perm of [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]) {
it(JSON.stringify(perm), async function () {
it('changes are all or nothing', async function () {
const authorId = makeAuthorId();
const records = Object.entries(makeExport(authorId));
assert.equal(records.length, 3);
await importEtherpad.setPadRaw(
padId, JSON.stringify(Object.fromEntries(perm.map((i) => records[i]))));
assert.deepEqual((await authorManager.listPadsOfAuthor(authorId)).padIDs, [padId]);
const pad = await padManager.getPad(padId);
assert.equal(pad.text(), 'foo\n');
});
}
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{hook_fn: () => ['custom']}];
const data = makeExport(authorId);
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
assert(!await authorManager.doesAuthorExist(authorId));
assert(!await padManager.doesPadExist(padId));
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
describe('author pad IDs', function () {
let existingAuthorId;
let newAuthorId;
beforeEach(async function () {
existingAuthorId = (await authorManager.createAuthor('existing')).authorID;
assert(await authorManager.doesAuthorExist(existingAuthorId));
assert.deepEqual((await authorManager.listPadsOfAuthor(existingAuthorId)).padIDs, []);
newAuthorId = makeAuthorId();
assert.notEqual(newAuthorId, existingAuthorId);
assert(!await authorManager.doesAuthorExist(newAuthorId));
});
it('author does not yet exist', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'new');
assert.equal(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, no pads', async function () {
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
it('author already exists, on different pad', async function () {
const otherPadId = randomString(10);
await authorManager.addPad(existingAuthorId, otherPadId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs.sort(), [otherPadId, padId].sort());
});
it('author already exists, on same pad', async function () {
await authorManager.addPad(existingAuthorId, padId);
newAuthorId = existingAuthorId;
await importEtherpad.setPadRaw(padId, JSON.stringify(makeExport(newAuthorId)));
assert(await authorManager.doesAuthorExist(newAuthorId));
const author = await authorManager.getAuthor(newAuthorId);
assert.equal(author.name, 'existing');
assert.notEqual(author.colorId, '#000000');
assert.deepEqual((await authorManager.listPadsOfAuthor(newAuthorId)).padIDs, [padId]);
});
});
it('imports from custom prefix', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testing': 'a',
'custom:testing:foo': 'b',
}));
const pad = await padManager.getPad(padId);
assert.equal(await pad.db.get(`custom:${padId}`), 'a');
assert.equal(await pad.db.get(`custom:${padId}:foo`), 'b');
describe('enforces consistent pad ID', function () {
it('pad record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId'] = data['pad:testing'];
delete data['pad:testing'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('globalAuthor record has different pad ID', async function () {
const authorId = makeAuthorId();
const data = makeExport(authorId);
data[`globalAuthor:${authorId}`].padIDs = 'differentPadId';
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
it('pad rev record has different pad ID', async function () {
const data = makeExport(makeAuthorId());
data['pad:differentPadId:revs:0'] = data['pad:testing:revs:0'];
delete data['pad:testing:revs:0'];
assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify(data)), /unexpected pad ID/);
});
});
it('rejects records for pad with similar ID', async function () {
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x`) == null);
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx:foo': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x:foo`) == null);
describe('order of records does not matter', function () {
for (const perm of [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]) {
it(JSON.stringify(perm), async function () {
const authorId = makeAuthorId();
const records = Object.entries(makeExport(authorId));
assert.equal(records.length, 3);
await importEtherpad.setPadRaw(padId, JSON.stringify(Object.fromEntries(perm.map((i) => records[i]))));
assert.deepEqual((await authorManager.listPadsOfAuthor(authorId)).padIDs, [padId]);
const pad = await padManager.getPad(padId);
assert.equal(pad.text(), 'foo\n');
});
}
});
describe('exportEtherpadAdditionalContent', function () {
let hookBackup;
before(async function () {
hookBackup = plugins.hooks.exportEtherpadAdditionalContent || [];
plugins.hooks.exportEtherpadAdditionalContent = [{ hook_fn: () => ['custom'] }];
});
after(async function () {
plugins.hooks.exportEtherpadAdditionalContent = hookBackup;
});
it('imports from custom prefix', async function () {
await importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testing': 'a',
'custom:testing:foo': 'b',
}));
const pad = await padManager.getPad(padId);
assert.equal(await pad.db.get(`custom:${padId}`), 'a');
assert.equal(await pad.db.get(`custom:${padId}:foo`), 'b');
});
it('rejects records for pad with similar ID', async function () {
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x`) == null);
await assert.rejects(importEtherpad.setPadRaw(padId, JSON.stringify({
...makeExport(makeAuthorId()),
'custom:testingx:foo': 'x',
})), /unexpected pad ID/);
assert(await db.get(`custom:${padId}x:foo`) == null);
});
});
});
});

View file

@ -1,134 +1,120 @@
import * as Pad from "../../../node/db/Pad.js";
import assert$0 from "assert";
import * as authorManager from "../../../node/db/AuthorManager.js";
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 settings from "../../../node/utils/Settings.js";
'use strict';
const Pad = require('../../../node/db/Pad');
const assert = require('assert').strict;
const authorManager = require('../../../node/db/AuthorManager');
const common = require('../common');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../../node/utils/Settings');
const assert = assert$0.strict;
describe(__filename, function () {
const backups = {};
let pad;
let padId;
before(async function () {
backups.hooks = {
padDefaultContent: plugins.hooks.padDefaultContent,
};
backups.defaultPadText = settings.defaultPadText;
});
beforeEach(async function () {
backups.hooks.padDefaultContent = [];
padId = common.randomString();
assert(!(await padManager.doesPadExist(padId)));
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
if (pad != null) await pad.remove();
pad = null;
});
describe('cleanText', function () {
const testCases = [
['', ''],
['\n', '\n'],
['x', 'x'],
['x\n', 'x\n'],
['x\ny\n', 'x\ny\n'],
['x\ry\n', 'x\ny\n'],
['x\r\ny\n', 'x\ny\n'],
['x\r\r\ny\n', 'x\n\ny\n'],
];
for (const [input, want] of testCases) {
it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {
assert.equal(Pad.cleanText(input), want);
});
}
});
describe('padDefaultContent hook', function () {
it('runs when a pad is created without specific text', async function () {
const p = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({hook_fn: () => resolve()});
});
pad = await padManager.getPad(padId);
await p;
const backups = {};
let pad;
let padId;
before(async function () {
backups.hooks = {
padDefaultContent: plugins.hooks.padDefaultContent,
};
backups.defaultPadText = settings.defaultPadText;
});
it('not run if pad is created with specific text', async function () {
plugins.hooks.padDefaultContent.push(
{hook_fn: () => { throw new Error('should not be called'); }});
pad = await padManager.getPad(padId, '');
beforeEach(async function () {
backups.hooks.padDefaultContent = [];
padId = common.randomString();
assert(!(await padManager.doesPadExist(padId)));
});
it('defaults to settings.defaultPadText', async function () {
const p = new Promise((resolve, reject) => {
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => {
try {
assert.equal(ctx.type, 'text');
assert.equal(ctx.content, settings.defaultPadText);
} catch (err) {
return reject(err);
}
resolve();
}});
});
pad = await padManager.getPad(padId);
await p;
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
if (pad != null)
await pad.remove();
pad = null;
});
it('passes the pad object', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, {pad}) => resolve(pad)});
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, pad);
describe('cleanText', function () {
const testCases = [
['', ''],
['\n', '\n'],
['x', 'x'],
['x\n', 'x\n'],
['x\ny\n', 'x\ny\n'],
['x\ry\n', 'x\ny\n'],
['x\r\ny\n', 'x\ny\n'],
['x\r\r\ny\n', 'x\n\ny\n'],
];
for (const [input, want] of testCases) {
it(`${JSON.stringify(input)} -> ${JSON.stringify(want)}`, async function () {
assert.equal(Pad.cleanText(input), want);
});
}
});
it('passes empty authorId if not provided', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push(
{hook_fn: async (hookName, {authorId}) => resolve(authorId)});
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, '');
describe('padDefaultContent hook', function () {
it('runs when a pad is created without specific text', async function () {
const p = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: () => resolve() });
});
pad = await padManager.getPad(padId);
await p;
});
it('not run if pad is created with specific text', async function () {
plugins.hooks.padDefaultContent.push({ hook_fn: () => { throw new Error('should not be called'); } });
pad = await padManager.getPad(padId, '');
});
it('defaults to settings.defaultPadText', async function () {
const p = new Promise((resolve, reject) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
try {
assert.equal(ctx.type, 'text');
assert.equal(ctx.content, settings.defaultPadText);
}
catch (err) {
return reject(err);
}
resolve();
} });
});
pad = await padManager.getPad(padId);
await p;
});
it('passes the pad object', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { pad }) => resolve(pad) });
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, pad);
});
it('passes empty authorId if not provided', async function () {
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { authorId }) => resolve(authorId) });
});
pad = await padManager.getPad(padId);
assert.equal(await gotP, '');
});
it('passes provided authorId', async function () {
const want = await authorManager.getAuthor4Token(`t.${padId}`);
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, { authorId }) => resolve(authorId) });
});
pad = await padManager.getPad(padId, null, want);
assert.equal(await gotP, want);
});
it('uses provided content', async function () {
const want = 'hello world';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = want;
} });
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
it('cleans provided content', async function () {
const input = 'foo\r\nbar\r\tbaz';
const want = 'foo\nbar\n baz';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({ hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = input;
} });
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
});
it('passes provided authorId', async function () {
const want = await authorManager.getAuthor4Token(`t.${padId}`);
const gotP = new Promise((resolve) => {
plugins.hooks.padDefaultContent.push(
{hook_fn: async (hookName, {authorId}) => resolve(authorId)});
});
pad = await padManager.getPad(padId, null, want);
assert.equal(await gotP, want);
});
it('uses provided content', async function () {
const want = 'hello world';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = want;
}});
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
it('cleans provided content', async function () {
const input = 'foo\r\nbar\r\tbaz';
const want = 'foo\nbar\n baz';
assert.notEqual(want, settings.defaultPadText);
plugins.hooks.padDefaultContent.push({hook_fn: async (hookName, ctx) => {
ctx.type = 'text';
ctx.content = input;
}});
pad = await padManager.getPad(padId);
assert.equal(pad.text(), `${want}\n`);
});
});
});

View file

@ -1,238 +1,210 @@
import SessionStore from "../../../node/db/SessionStore.js";
import assert$0 from "assert";
import * as common from "../common.js";
import * as db from "../../../node/db/DB.js";
import util from "util";
'use strict';
const SessionStore = require('../../../node/db/SessionStore');
const assert = require('assert').strict;
const common = require('../common');
const db = require('../../../node/db/DB');
const util = require('util');
const assert = assert$0.strict;
describe(__filename, function () {
let ss;
let sid;
const set = async (sess) => await util.promisify(ss.set).call(ss, sid, sess);
const get = async () => await util.promisify(ss.get).call(ss, sid);
const destroy = async () => await util.promisify(ss.destroy).call(ss, sid);
const touch = async (sess) => await util.promisify(ss.touch).call(ss, sid, sess);
before(async function () {
await common.init();
});
beforeEach(async function () {
ss = new SessionStore();
sid = common.randomString();
});
afterEach(async function () {
if (ss != null) {
if (sid != null) await destroy();
ss.shutdown();
}
sid = null;
ss = null;
});
describe('set', function () {
it('set of null is a no-op', async function () {
await set(null);
assert(await db.get(`sessionstorage:${sid}`) == null);
let ss;
let sid;
const set = async (sess) => await util.promisify(ss.set).call(ss, sid, sess);
const get = async () => await util.promisify(ss.get).call(ss, sid);
const destroy = async () => await util.promisify(ss.destroy).call(ss, sid);
const touch = async (sess) => await util.promisify(ss.touch).call(ss, sid, sess);
before(async function () {
await common.init();
});
it('set of non-expiring session', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('set of session that expires', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Writing should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('set of already expired session', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(1)}};
await set(sess);
// No record should have been created.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from non-expiring to expiring', async function () {
const sess = {foo: 'bar'};
await set(sess);
const sess2 = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from expiring to non-expiring', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
const sess2 = {foo: 'bar'};
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
});
});
describe('get', function () {
it('get of non-existent entry', async function () {
assert(await get() == null);
});
it('set+get round trip', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (no expiration)', async function () {
const sess = {foo: 'bar', baz: {asdf: 'jkl;'}};
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (not yet expired)', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Reading should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('get of record from previous run (already expired)', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(1)}};
await db.set(`sessionstorage:${sid}`, sess);
assert(await get() == null);
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('external expiration update is picked up', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
const sess2 = {...sess, cookie: {expires: new Date(Date.now() + 200)}};
await db.set(`sessionstorage:${sid}`, sess2);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
await new Promise((resolve) => setTimeout(resolve, 110));
// The original timeout should not have fired.
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('shutdown', function () {
it('shutdown cancels timeouts', async function () {
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
ss.shutdown();
await new Promise((resolve) => setTimeout(resolve, 110));
// The record should not have been automatically purged.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
});
describe('destroy', function () {
it('destroy deletes the database record', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
await destroy();
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('destroy cancels the timeout', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 100)}};
await set(sess);
await destroy();
await db.set(`sessionstorage:${sid}`, sess);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('destroy session that does not exist', async function () {
await destroy();
});
});
describe('touch without refresh', function () {
it('touch before set is equivalent to set if session expires', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 1000)}};
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch updates observed expiration but not database', async function () {
const start = Date.now();
const sess = {cookie: {expires: new Date(start + 200)}};
await set(sess);
const sess2 = {cookie: {expires: new Date(start + 12000)}};
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('touch with refresh', function () {
beforeEach(async function () {
ss = new SessionStore(200);
ss = new SessionStore();
sid = common.randomString();
});
it('touch before set is equivalent to set if session expires', async function () {
const sess = {cookie: {expires: new Date(Date.now() + 1000)}};
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
afterEach(async function () {
if (ss != null) {
if (sid != null)
await destroy();
ss.shutdown();
}
sid = null;
ss = null;
});
it('touch before eligible for refresh updates expiration but not DB', async function () {
const now = Date.now();
const sess = {foo: 'bar', cookie: {expires: new Date(now + 1000)}};
await set(sess);
const sess2 = {foo: 'bar', cookie: {expires: new Date(now + 1001)}};
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
describe('set', function () {
it('set of null is a no-op', async function () {
await set(null);
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('set of non-expiring session', async function () {
const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('set of session that expires', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Writing should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('set of already expired session', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(1) } };
await set(sess);
// No record should have been created.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from non-expiring to expiring', async function () {
const sess = { foo: 'bar' };
await set(sess);
const sess2 = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('switch from expiring to non-expiring', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
const sess2 = { foo: 'bar' };
await set(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
});
});
it('touch before eligible for refresh updates timeout', async function () {
const start = Date.now();
const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}};
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 399)}};
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
describe('get', function () {
it('get of non-existent entry', async function () {
assert(await get() == null);
});
it('set+get round trip', async function () {
const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (no expiration)', async function () {
const sess = { foo: 'bar', baz: { asdf: 'jkl;' } };
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('get of record from previous run (not yet expired)', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await db.set(`sessionstorage:${sid}`, sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
await new Promise((resolve) => setTimeout(resolve, 110));
// Reading should start a timeout.
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('get of record from previous run (already expired)', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(1) } };
await db.set(`sessionstorage:${sid}`, sess);
assert(await get() == null);
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('external expiration update is picked up', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
const sess2 = { ...sess, cookie: { expires: new Date(Date.now() + 200) } };
await db.set(`sessionstorage:${sid}`, sess2);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
await new Promise((resolve) => setTimeout(resolve, 110));
// The original timeout should not have fired.
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
it('touch after eligible for refresh updates db', async function () {
const start = Date.now();
const sess = {foo: 'bar', cookie: {expires: new Date(start + 200)}};
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = {foo: 'bar', cookie: {expires: new Date(start + 400)}};
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
describe('shutdown', function () {
it('shutdown cancels timeouts', async function () {
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
ss.shutdown();
await new Promise((resolve) => setTimeout(resolve, 110));
// The record should not have been automatically purged.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
});
it('refresh=0 updates db every time', async function () {
ss = new SessionStore(0);
const sess = {foo: 'bar', cookie: {expires: new Date(Date.now() + 1000)}};
await set(sess);
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
describe('destroy', function () {
it('destroy deletes the database record', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
await destroy();
assert(await db.get(`sessionstorage:${sid}`) == null);
});
it('destroy cancels the timeout', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 100) } };
await set(sess);
await destroy();
await db.set(`sessionstorage:${sid}`, sess);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
it('destroy session that does not exist', async function () {
await destroy();
});
});
describe('touch without refresh', function () {
it('touch before set is equivalent to set if session expires', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 1000) } };
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch updates observed expiration but not database', async function () {
const start = Date.now();
const sess = { cookie: { expires: new Date(start + 200) } };
await set(sess);
const sess2 = { cookie: { expires: new Date(start + 12000) } };
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
});
describe('touch with refresh', function () {
beforeEach(async function () {
ss = new SessionStore(200);
});
it('touch before set is equivalent to set if session expires', async function () {
const sess = { cookie: { expires: new Date(Date.now() + 1000) } };
await touch(sess);
assert.equal(JSON.stringify(await get()), JSON.stringify(sess));
});
it('touch before eligible for refresh updates expiration but not DB', async function () {
const now = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(now + 1000) } };
await set(sess);
const sess2 = { foo: 'bar', cookie: { expires: new Date(now + 1001) } };
await touch(sess2);
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('touch before eligible for refresh updates timeout', async function () {
const start = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(start + 200) } };
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = { foo: 'bar', cookie: { expires: new Date(start + 399) } };
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('touch after eligible for refresh updates db', async function () {
const start = Date.now();
const sess = { foo: 'bar', cookie: { expires: new Date(start + 200) } };
await set(sess);
await new Promise((resolve) => setTimeout(resolve, 100));
const sess2 = { foo: 'bar', cookie: { expires: new Date(start + 400) } };
await touch(sess2);
await new Promise((resolve) => setTimeout(resolve, 110));
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess2));
assert.equal(JSON.stringify(await get()), JSON.stringify(sess2));
});
it('refresh=0 updates db every time', async function () {
ss = new SessionStore(0);
const sess = { foo: 'bar', cookie: { expires: new Date(Date.now() + 1000) } };
await set(sess);
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
await db.remove(`sessionstorage:${sid}`);
await touch(sess); // No change in expiration time.
assert.equal(JSON.stringify(await db.get(`sessionstorage:${sid}`)), JSON.stringify(sess));
});
});
});
});

View file

@ -1,358 +1,330 @@
import * as Stream from "../../../node/utils/Stream.js";
import assert$0 from "assert";
'use strict';
const Stream = require('../../../node/utils/Stream');
const assert = require('assert').strict;
const assert = assert$0.strict;
class DemoIterable {
constructor() {
this.value = 0;
this.errs = [];
this.rets = [];
}
completed() { return this.errs.length > 0 || this.rets.length > 0; }
next() {
if (this.completed()) return {value: undefined, done: true}; // Mimic standard generators.
return {value: this.value++, done: false};
}
throw(err) {
const alreadyCompleted = this.completed();
this.errs.push(err);
if (alreadyCompleted) throw err; // Mimic standard generator objects.
throw err;
}
return(ret) {
const alreadyCompleted = this.completed();
this.rets.push(ret);
if (alreadyCompleted) return {value: ret, done: true}; // Mimic standard generator objects.
return {value: ret, done: true};
}
[Symbol.iterator]() { return this; }
constructor() {
this.value = 0;
this.errs = [];
this.rets = [];
}
completed() { return this.errs.length > 0 || this.rets.length > 0; }
next() {
if (this.completed())
return { value: undefined, done: true }; // Mimic standard generators.
return { value: this.value++, done: false };
}
throw(err) {
const alreadyCompleted = this.completed();
this.errs.push(err);
if (alreadyCompleted)
throw err; // Mimic standard generator objects.
throw err;
}
return(ret) {
const alreadyCompleted = this.completed();
this.rets.push(ret);
if (alreadyCompleted)
return { value: ret, done: true }; // Mimic standard generator objects.
return { value: ret, done: true };
}
[Symbol.iterator]() { return this; }
}
const assertUnhandledRejection = async (action, want) => {
// 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);
await action();
await seenErrPromise;
} finally {
// Restore the original listeners.
process.off(event, tempListener);
for (const listener of listenersBackup) process.on(event, listener);
}
await assert.rejects(Promise.reject(asyncErr), want);
// 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);
await action();
await seenErrPromise;
}
finally {
// Restore the original listeners.
process.off(event, tempListener);
for (const listener of listenersBackup)
process.on(event, listener);
}
await assert.rejects(Promise.reject(asyncErr), want);
};
describe(__filename, function () {
describe('basic behavior', function () {
it('takes a generator', async function () {
assert.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]);
describe('basic behavior', function () {
it('takes a generator', async function () {
assert.deepEqual([...new Stream((function* () { yield 0; yield 1; yield 2; })())], [0, 1, 2]);
});
it('takes an array', async function () {
assert.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]);
});
it('takes an iterator', async function () {
assert.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]);
});
it('supports empty iterators', async function () {
assert.deepEqual([...new Stream([])], []);
});
it('is resumable', async function () {
const s = new Stream((function* () { yield 0; yield 1; yield 2; })());
let iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.deepEqual([...s], [2]);
});
it('supports return value', async function () {
const s = new Stream((function* () { yield 0; return 1; })());
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.deepEqual(iter.next(), { value: 1, done: true });
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })());
// Fetching from the underlying iterator should not start until the first value is fetched
// from the stream.
assert.equal(lastYield, null);
});
it('throw is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
const err = new Error('injected');
assert.throws(() => iter.throw(err), err);
assert.equal(underlying.errs[0], err);
});
it('return is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.deepEqual(iter.return(42), { value: 42, done: true });
assert.equal(underlying.rets[0], 42);
});
});
it('takes an array', async function () {
assert.deepEqual([...new Stream([0, 1, 2])], [0, 1, 2]);
describe('range', function () {
it('basic', async function () {
assert.deepEqual([...Stream.range(0, 3)], [0, 1, 2]);
});
it('empty', async function () {
assert.deepEqual([...Stream.range(0, 0)], []);
});
it('positive start', async function () {
assert.deepEqual([...Stream.range(3, 5)], [3, 4]);
});
it('negative start', async function () {
assert.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]);
});
it('end before start', async function () {
assert.deepEqual([...Stream.range(3, 0)], []);
});
});
it('takes an iterator', async function () {
assert.deepEqual([...new Stream([0, 1, 2][Symbol.iterator]())], [0, 1, 2]);
describe('batch', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).batch(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).batch(10);
assert.equal(lastYield, null);
});
it('fewer than batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('multiple batches, last batch is not full', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++)
yield lastYield = i;
})();
const s = new Stream(values).batch(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.deepEqual(iter.next(), { value: 2, done: false });
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), { value: 3, done: false });
assert.equal(lastYield, 5);
assert.deepEqual([...s], [4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('batched Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('batched Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
it('supports empty iterators', async function () {
assert.deepEqual([...new Stream([])], []);
describe('buffer', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).buffer(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).buffer(10);
assert.equal(lastYield, null);
});
it('fewer than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), { value: 0, done: false });
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('more than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++)
yield lastYield = i;
})();
const s = new Stream(values).buffer(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), { value: 0, done: false });
assert.equal(lastYield, 3);
assert.deepEqual(iter.next(), { value: 1, done: false });
assert.equal(lastYield, 4);
assert.deepEqual(iter.next(), { value: 2, done: false });
assert.equal(lastYield, 5);
assert.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('buffered Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('buffered Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
it('is resumable', async function () {
const s = new Stream((function* () { yield 0; yield 1; yield 2; })());
let iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 1, done: false});
assert.deepEqual([...s], [2]);
describe('map', function () {
it('empty', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
assert.equal(called, false);
});
it('does not start until needed', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
new Stream((function* () { yield 0; })()).map((v) => called = true);
assert.equal(called, false);
});
it('works', async function () {
const calls = [];
assert.deepEqual([...new Stream([0, 1, 2]).map((v) => { calls.push(v); return 2 * v; })], [0, 2, 4]);
assert.deepEqual(calls, [0, 1, 2]);
});
});
it('supports return value', async function () {
const s = new Stream((function* () { yield 0; return 1; })());
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.deepEqual(iter.next(), {value: 1, done: true});
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })());
// Fetching from the underlying iterator should not start until the first value is fetched
// from the stream.
assert.equal(lastYield, null);
});
it('throw is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
const err = new Error('injected');
assert.throws(() => iter.throw(err), err);
assert.equal(underlying.errs[0], err);
});
it('return is propagated', async function () {
const underlying = new DemoIterable();
const s = new Stream(underlying);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.deepEqual(iter.return(42), {value: 42, done: true});
assert.equal(underlying.rets[0], 42);
});
});
describe('range', function () {
it('basic', async function () {
assert.deepEqual([...Stream.range(0, 3)], [0, 1, 2]);
});
it('empty', async function () {
assert.deepEqual([...Stream.range(0, 0)], []);
});
it('positive start', async function () {
assert.deepEqual([...Stream.range(3, 5)], [3, 4]);
});
it('negative start', async function () {
assert.deepEqual([...Stream.range(-3, 0)], [-3, -2, -1]);
});
it('end before start', async function () {
assert.deepEqual([...Stream.range(3, 0)], []);
});
});
describe('batch', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).batch(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).batch(10);
assert.equal(lastYield, null);
});
it('fewer than batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly batch size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('multiple batches, last batch is not full', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++) yield lastYield = i;
})();
const s = new Stream(values).batch(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), {value: 1, done: false});
assert.deepEqual(iter.next(), {value: 2, done: false});
assert.equal(lastYield, 2);
assert.deepEqual(iter.next(), {value: 3, done: false});
assert.equal(lastYield, 5);
assert.deepEqual([...s], [4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('batched Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('batched Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).batch(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
describe('buffer', function () {
it('empty', async function () {
assert.deepEqual([...new Stream([]).buffer(10)], []);
});
it('does not start until needed', async function () {
let lastYield = null;
new Stream((function* () { yield lastYield = 0; })()).buffer(10);
assert.equal(lastYield, null);
});
it('fewer than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(10);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('exactly buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 5; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(5);
assert.equal(lastYield, null);
assert.deepEqual(s[Symbol.iterator]().next(), {value: 0, done: false});
assert.equal(lastYield, 4);
assert.deepEqual([...s], [1, 2, 3, 4]);
assert.equal(lastYield, 4);
});
it('more than buffer size', async function () {
let lastYield = null;
const values = (function* () {
for (let i = 0; i < 10; i++) yield lastYield = i;
})();
const s = new Stream(values).buffer(3);
assert.equal(lastYield, null);
const iter = s[Symbol.iterator]();
assert.deepEqual(iter.next(), {value: 0, done: false});
assert.equal(lastYield, 3);
assert.deepEqual(iter.next(), {value: 1, done: false});
assert.equal(lastYield, 4);
assert.deepEqual(iter.next(), {value: 2, done: false});
assert.equal(lastYield, 5);
assert.deepEqual([...s], [3, 4, 5, 6, 7, 8, 9]);
assert.equal(lastYield, 9);
});
it('buffered Promise rejections are suppressed while iterating', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
const nextp = iter.next().value;
assert.equal(lastYield, 'promise of 2');
assert.equal(await nextp, 0);
await assert.rejects(iter.next().value, err);
iter.return();
});
it('buffered Promise rejections are unsuppressed when iteration completes', async function () {
let lastYield = null;
const err = new Error('injected');
const values = (function* () {
lastYield = 'promise of 0';
yield new Promise((resolve) => setTimeout(() => resolve(0), 100));
lastYield = 'rejected Promise';
yield Promise.reject(err);
lastYield = 'promise of 2';
yield Promise.resolve(2);
})();
const s = new Stream(values).buffer(3);
const iter = s[Symbol.iterator]();
assert.equal(await iter.next().value, 0);
assert.equal(lastYield, 'promise of 2');
await assertUnhandledRejection(() => iter.return(), err);
});
});
describe('map', function () {
it('empty', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
assert.equal(called, false);
});
it('does not start until needed', async function () {
let called = false;
assert.deepEqual([...new Stream([]).map((v) => called = true)], []);
new Stream((function* () { yield 0; })()).map((v) => called = true);
assert.equal(called, false);
});
it('works', async function () {
const calls = [];
assert.deepEqual(
[...new Stream([0, 1, 2]).map((v) => { calls.push(v); return 2 * v; })], [0, 2, 4]);
assert.deepEqual(calls, [0, 1, 2]);
});
});
});

View file

@ -1,58 +1,43 @@
import * as common from "../../common.js";
import { validate } from "openapi-schema-validation";
'use strict';
/**
* API specs
*
* Tests for generic overarching HTTP API related features not related to any
* specific part of the data model or domain. For example: tests for versioning
* and openapi definitions.
*/
const common = require('../../common');
const validateOpenAPI = require('openapi-schema-validation').validate;
const validateOpenAPI = { validate }.validate;
let agent;
const apiKey = common.apiKey;
let apiVersion = 1;
const 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;
};
const testPadId = makeid();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () {
before(async function () { agent = await common.init(); });
it('can obtain API version', async function () {
await agent.get('/api/')
.expect(200)
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
before(async function () { agent = await common.init(); });
it('can obtain API version', async function () {
await agent.get('/api/')
.expect(200)
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion)
throw new Error('No version set in API');
return;
});
});
it('can obtain valid openapi definition document', async function () {
this.timeout(15000);
await agent.get('/api/openapi.json')
.expect(200)
.expect((res) => {
const {valid, errors} = validateOpenAPI(res.body, 3);
if (!valid) {
const prettyErrors = JSON.stringify(errors, null, 2);
throw new Error(`Document is not valid OpenAPI. ${errors.length} ` +
`validation errors:\n${prettyErrors}`);
}
});
it('can obtain valid openapi definition document', async function () {
this.timeout(15000);
await agent.get('/api/openapi.json')
.expect(200)
.expect((res) => {
const { valid, errors } = validateOpenAPI(res.body, 3);
if (!valid) {
const prettyErrors = JSON.stringify(errors, null, 2);
throw new Error(`Document is not valid OpenAPI. ${errors.length} ` +
`validation errors:\n${prettyErrors}`);
}
});
});
});
});

View file

@ -1,88 +1,75 @@
import assert$0 from "assert";
import * as common from "../../common.js";
import fs from "fs";
'use strict';
/*
* This file is copied & modified from <basedir>/src/tests/backend/specs/api/pad.js
*
* TODO: maybe unify those two files and merge in a single one.
*/
const assert = require('assert').strict;
const common = require('../../common');
const fs = require('fs');
const assert = assert$0.strict;
const fsp = fs.promises;
let agent;
const apiKey = common.apiKey;
let apiVersion = 1;
const testPadId = makeid();
const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('Sanity checks', function () {
it('can connect', async function () {
await agent.get('/api/')
.expect(200)
.expect('Content-Type', /json/);
before(async function () { agent = await common.init(); });
describe('Sanity checks', function () {
it('can connect', async function () {
await agent.get('/api/')
.expect(200)
.expect('Content-Type', /json/);
});
it('finds the version tag', async function () {
const res = await agent.get('/api/')
.expect(200);
apiVersion = res.body.currentVersion;
assert(apiVersion);
});
it('errors with invalid APIKey', async function () {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
.expect(401);
});
});
it('finds the version tag', async function () {
const res = await agent.get('/api/')
.expect(200);
apiVersion = res.body.currentVersion;
assert(apiVersion);
describe('Tests', function () {
it('creates a new Pad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {
const res = await agent.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
})
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('get the HTML of Pad with emojis', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.match(res.body.data.html, /&#127484/);
});
});
it('errors with invalid APIKey', async function () {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
await agent.get(`/api/${apiVersion}/createPad?apikey=password&padID=test`)
.expect(401);
});
});
describe('Tests', function () {
it('creates a new Pad', async function () {
const res = await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', async function () {
const res = await agent.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: await fsp.readFile('tests/backend/specs/api/emojis.html', 'utf8'),
})
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 0);
});
it('get the HTML of Pad with emojis', async function () {
const res = await agent.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/);
assert.match(res.body.data.html, /&#127484/);
});
});
});
/*
End of test
*/
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 10; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 10; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -1,115 +1,105 @@
import * as common from "../../common.js";
'use strict';
const common = require('../../common');
let agent;
const apiKey = common.apiKey;
let apiVersion = 1;
let authorID = '';
const padID = makeid();
const timestamp = Date.now();
const endPoint = (point) => `/api/${apiVersion}/${point}?apikey=${apiKey}`;
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('API Versioning', function () {
it('errors if can not connect', function (done) {
agent.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
before(async function () { agent = await common.init(); });
describe('API Versioning', function () {
it('errors if can not connect', function (done) {
agent.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion)
throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
});
// BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// ///////////////////////////////////
/* Tests performed
-> createPad(padID)
-> createAuthor([name]) -- should return an authorID
-> appendChatMessage(padID, text, authorID, time)
-> getChatHead(padID)
-> getChatHistory(padID)
*/
describe('createPad', function () {
it('creates a new Pad', function (done) {
agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
// BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// ///////////////////////////////////
/* Tests performed
-> createPad(padID)
-> createAuthor([name]) -- should return an authorID
-> appendChatMessage(padID, text, authorID, time)
-> getChatHead(padID)
-> getChatHistory(padID)
*/
describe('createPad', function () {
it('creates a new Pad', function (done) {
agent.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => {
if (res.body.code !== 0)
throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('createAuthor', function () {
it('Creates an author with a name set', function (done) {
agent.get(endPoint('createAuthor'))
.expect((res) => {
if (res.body.code !== 0 || !res.body.data.authorID) {
throw new Error('Unable to create author');
}
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
})
.expect('Content-Type', /json/)
.expect(200, done);
describe('createAuthor', function () {
it('Creates an author with a name set', function (done) {
agent.get(endPoint('createAuthor'))
.expect((res) => {
if (res.body.code !== 0 || !res.body.data.authorID) {
throw new Error('Unable to create author');
}
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('appendChatMessage', function () {
it('Adds a chat message to the pad', function (done) {
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
describe('appendChatMessage', function () {
it('Adds a chat message to the pad', function (done) {
agent.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha` +
`&authorID=${authorID}&time=${timestamp}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create chat message');
})
.expect('Content-Type', /json/)
.expect(200, done);
.expect((res) => {
if (res.body.code !== 0)
throw new Error('Unable to create chat message');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('getChatHead', function () {
it('Gets the head of chat', function (done) {
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');
if (res.body.code !== 0) throw new Error('Unable to get chat head');
})
.expect('Content-Type', /json/)
.expect(200, done);
describe('getChatHead', function () {
it('Gets the head of chat', function (done) {
agent.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.chatHead !== 0)
throw new Error('Chat Head Length is wrong');
if (res.body.code !== 0)
throw new Error('Unable to get chat head');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('getChatHistory', function () {
it('Gets Chat History of a Pad', function (done) {
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.messages.length !== 1) {
throw new Error('Chat History Length is wrong');
}
if (res.body.code !== 0) throw new Error('Unable to get chat history');
})
.expect('Content-Type', /json/)
.expect(200, done);
describe('getChatHistory', function () {
it('Gets Chat History of a Pad', function (done) {
agent.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.messages.length !== 1) {
throw new Error('Chat History Length is wrong');
}
if (res.body.code !== 0)
throw new Error('Unable to get chat history');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
});
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;
}

View file

@ -1,3 +1,5 @@
import assert$0 from "assert";
import * as common from "../../common.js";
'use strict';
/*
* ACHTUNG: there is a copied & modified version of this file in
@ -5,181 +7,175 @@
*
* TODO: unify those two files, and merge in a single one.
*/
const assert = require('assert').strict;
const common = require('../../common');
const assert = assert$0.strict;
let agent;
const apiKey = common.apiKey;
const apiVersion = 1;
const endPoint = (point, version) => `/api/${version || apiVersion}/${point}?apikey=${apiKey}`;
const testImports = {
'malformed': {
input: '<html><body><li>wtf</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>',
wantText: 'wtf\n\n',
disabled: true,
},
'nonelistiteminlist #3620': {
input: '<html><body><ul>test<li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet">test<li>FOO</ul><br></body></html>',
wantText: '\ttest\n\t* FOO\n\n',
disabled: true,
},
'whitespaceinlist #3620': {
input: '<html><body><ul> <li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet"><li>FOO</ul><br></body></html>',
wantText: '\t* FOO\n\n',
},
'prefixcorrectlinenumber': {
input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t2. should be 2\n\n',
},
'prefixcorrectlinenumbernested': {
input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1<ol start="2" class="number"><li>foo</ol><li>should be 2</ol><br></body></html>',
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: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
,
"newlinesshouldntresetlinenumber #2194": {
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
*/
'ignoreAnyTagsOutsideBody': {
description: 'Content outside body should be ignored',
input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>',
wantText: 'empty\n\n',
},
'indentedListsAreNotBullets': {
description: 'Indented lists are represented with tabs and without bullets',
input: '<html><body><ul class="indent"><li>indent</li><li>indent</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="indent"><li>indent</li><li>indent</ul><br></body></html>',
wantText: '\tindent\n\tindent\n\n',
},
'lineWithMultipleSpaces': {
description: 'Multiple spaces should be collapsed',
input: '<html><body>Text with more than one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>',
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: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>',
wantText: 'Text with more than one space.\n\n',
},
'multiplenbsp': {
description: 'Multiple non-breaking space should be preserved',
input: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>',
wantText: ' \n\n',
},
'multipleNonBreakingSpaceBetweenWords': {
description: 'A normal space is always inserted before a word',
input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpacePreceededBySpaceBetweenWords': {
description: 'A non-breaking space preceded by a normal space',
input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpaceFollowededBySpaceBetweenWords': {
description: 'A non-breaking space followed by a normal space',
input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'spacesAfterNewline': {
description: 'Collapse spaces that follow a newline',
input: '<!doctype html><html><body>something<br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAfterNewlineP': {
description: 'Collapse spaces that follow a paragraph',
input: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'spacesAtEndOfLine': {
description: 'Collapse spaces that preceed/follow a newline',
input: '<html><body>something <br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAtEndOfLineP': {
description: 'Collapse spaces that preceed/follow a paragraph',
input: '<html><body>something <p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'nonBreakingSpacesAfterNewlines': {
description: 'Don\'t collapse non-breaking spaces that follow a newline',
input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n something\n\n',
},
'nonBreakingSpacesAfterNewlinesP': {
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n\n something\n\n',
},
'collapseSpacesInsideElements': {
description: 'Preserve only one space when multiple are present',
input: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'collapseSpacesAcrossNewlines': {
description: 'Newlines and multiple spaces across newlines should be collapsed',
input: `
'malformed': {
input: '<html><body><li>wtf</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>',
wantText: 'wtf\n\n',
disabled: true,
},
'nonelistiteminlist #3620': {
input: '<html><body><ul>test<li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet">test<li>FOO</ul><br></body></html>',
wantText: '\ttest\n\t* FOO\n\n',
disabled: true,
},
'whitespaceinlist #3620': {
input: '<html><body><ul> <li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet"><li>FOO</ul><br></body></html>',
wantText: '\t* FOO\n\n',
},
'prefixcorrectlinenumber': {
input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t2. should be 2\n\n',
},
'prefixcorrectlinenumbernested': {
input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1<ol start="2" class="number"><li>foo</ol><li>should be 2</ol><br></body></html>',
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: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
,
"newlinesshouldntresetlinenumber #2194": {
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
*/
'ignoreAnyTagsOutsideBody': {
description: 'Content outside body should be ignored',
input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>',
wantText: 'empty\n\n',
},
'indentedListsAreNotBullets': {
description: 'Indented lists are represented with tabs and without bullets',
input: '<html><body><ul class="indent"><li>indent</li><li>indent</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="indent"><li>indent</li><li>indent</ul><br></body></html>',
wantText: '\tindent\n\tindent\n\n',
},
'lineWithMultipleSpaces': {
description: 'Multiple spaces should be collapsed',
input: '<html><body>Text with more than one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>',
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: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>',
wantText: 'Text with more than one space.\n\n',
},
'multiplenbsp': {
description: 'Multiple non-breaking space should be preserved',
input: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>',
wantText: ' \n\n',
},
'multipleNonBreakingSpaceBetweenWords': {
description: 'A normal space is always inserted before a word',
input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpacePreceededBySpaceBetweenWords': {
description: 'A non-breaking space preceded by a normal space',
input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpaceFollowededBySpaceBetweenWords': {
description: 'A non-breaking space followed by a normal space',
input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'spacesAfterNewline': {
description: 'Collapse spaces that follow a newline',
input: '<!doctype html><html><body>something<br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAfterNewlineP': {
description: 'Collapse spaces that follow a paragraph',
input: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'spacesAtEndOfLine': {
description: 'Collapse spaces that preceed/follow a newline',
input: '<html><body>something <br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAtEndOfLineP': {
description: 'Collapse spaces that preceed/follow a paragraph',
input: '<html><body>something <p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'nonBreakingSpacesAfterNewlines': {
description: 'Don\'t collapse non-breaking spaces that follow a newline',
input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n something\n\n',
},
'nonBreakingSpacesAfterNewlinesP': {
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n\n something\n\n',
},
'collapseSpacesInsideElements': {
description: 'Preserve only one space when multiple are present',
input: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'collapseSpacesAcrossNewlines': {
description: 'Newlines and multiple spaces across newlines should be collapsed',
input: `
<html><body>Need
<span> more </span>
space
<i> s </i>
!<br></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'multipleNewLinesAtBeginning': {
description: 'Multiple new lines and paragraphs at the beginning should be preserved',
input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>',
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: `<html><body>
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'multipleNewLinesAtBeginning': {
description: 'Multiple new lines and paragraphs at the beginning should be preserved',
input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>',
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: `<html><body>
<p>
а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь
</p>
</body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
},
'multiLineParagraphWithPre': {
// XXX why is there &nbsp; before "in"?
description: 'lines in preformatted text should be kept intact',
input: `<html><body>
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
},
'multiLineParagraphWithPre': {
// XXX why is there &nbsp; before "in"?
description: 'lines in preformatted text should be kept intact',
input: `<html><body>
<p>
а б в г ґ д е є ж з и і ї й к л м н о<pre>multiple
lines
@ -188,97 +184,88 @@ const testImports = {
</pre></p><p>п р с т у ф х ц ч ш щ ю я
ь</p>
</body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
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: `<html><body><p>
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
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: `<html><body><p>
1
<pre>preline
</pre></p></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>',
wantText: '1\npreline\n\n\n',
},
'dontDeleteSpaceInsideElements': {
description: 'Preserve spaces inside elements',
input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceOutsideElements': {
description: 'Preserve spaces outside elements',
input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtEndOfElement': {
description: 'Preserve spaces at the end of an element',
input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtBeginOfElements': {
description: 'Preserve spaces at the start of an element',
input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>',
wantText: '1\npreline\n\n\n',
},
'dontDeleteSpaceInsideElements': {
description: 'Preserve spaces inside elements',
input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceOutsideElements': {
description: 'Preserve spaces outside elements',
input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtEndOfElement': {
description: 'Preserve spaces at the end of an element',
input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtBeginOfElements': {
description: 'Preserve spaces at the start of an element',
input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>',
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;
}

View file

@ -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));
});
});
}
});

View file

@ -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} <added changes>`;
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} <added changes>`;
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}),
]);
});
});
});

View file

@ -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: '<html><body><p>foo</p></body></html>',
wantAlines: ['+3'],
wantText: ['foo'],
},
{
description: 'Line starts with asterisk',
html: '<html><body><p>*foo</p></body></html>',
wantAlines: ['+4'],
wantText: ['*foo'],
},
{
description: 'Complex nested Li',
html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>',
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: '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>',
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: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>',
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: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>',
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: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>',
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: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>',
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 <p> line",
},
{
description: 'A single <p></p> should create a new line',
html: '<html><body><p></p><p></p></body></html>',
wantAlines: ['', ''],
wantText: ['', ''],
noteToSelf: '<p></p>should create a line break but not break numbering',
},
{
description: 'Tests if ols properly get line numbers when in a normal OL #2',
html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>',
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: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>',
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: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>',
wantAlines: [],
wantText: ['*should be 1', '*should be 2', '*should be 3'],
noteToSelf: "<p></p>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: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantAlines: ['+5'],
wantText: ['empty'],
},
{
description: 'Multiple spaces should be preserved',
html: '<html><body>Text with more than one space.<br></body></html>',
wantAlines: ['+10'],
wantText: ['Text with more than one space.'],
},
{
description: 'non-breaking and normal space should be preserved',
html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantAlines: ['+10'],
wantText: ['Text with more than one space.'],
},
{
description: 'Multiple nbsp should be preserved',
html: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantAlines: ['+2'],
wantText: [' '],
},
{
description: 'Multiple nbsp between words ',
html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantAlines: ['+m'],
wantText: [' word1 word2 word3'],
},
{
description: 'A non-breaking space preceded by a normal space',
html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantAlines: ['+l'],
wantText: [' word1 word2 word3'],
},
{
description: 'A non-breaking space followed by a normal space',
html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantAlines: ['+l'],
wantText: [' word1 word2 word3'],
},
{
description: 'Don\'t collapse spaces that follow a newline',
html: '<!doctype html><html><body>something<br> something<br></body></html>',
wantAlines: ['+9', '+m'],
wantText: ['something', ' something'],
},
{
description: 'Don\'t collapse spaces that follow a empty paragraph',
html: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantAlines: ['+9', '', '+m'],
wantText: ['something', '', ' something'],
},
{
description: 'Don\'t collapse spaces that preceed/follow a newline',
html: '<html><body>something <br> something<br></body></html>',
wantAlines: ['+l', '+m'],
wantText: ['something ', ' something'],
},
{
description: 'Don\'t collapse spaces that preceed/follow a empty paragraph',
html: '<html><body>something <p></p> something<br></body></html>',
wantAlines: ['+l', '', '+m'],
wantText: ['something ', '', ' something'],
},
{
description: 'Don\'t collapse non-breaking spaces that follow a newline',
html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '+c'],
wantText: ['something', ' something'],
},
{
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '', '+c'],
wantText: ['something', '', ' something'],
},
{
description: 'Preserve all spaces when multiple are present',
html: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantAlines: ['+h*1+4+2'],
wantText: ['Need more space s !'],
},
{
description: 'Newlines and multiple spaces across newlines should be preserved',
html: `
{
description: 'Simple',
html: '<html><body><p>foo</p></body></html>',
wantAlines: ['+3'],
wantText: ['foo'],
},
{
description: 'Line starts with asterisk',
html: '<html><body><p>*foo</p></body></html>',
wantAlines: ['+4'],
wantText: ['*foo'],
},
{
description: 'Complex nested Li',
html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>',
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: '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>',
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: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>',
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: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>',
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: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>',
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: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>',
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 <p> line",
},
{
description: 'A single <p></p> should create a new line',
html: '<html><body><p></p><p></p></body></html>',
wantAlines: ['', ''],
wantText: ['', ''],
noteToSelf: '<p></p>should create a line break but not break numbering',
},
{
description: 'Tests if ols properly get line numbers when in a normal OL #2',
html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>',
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: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>',
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: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>',
wantAlines: [],
wantText: ['*should be 1', '*should be 2', '*should be 3'],
noteToSelf: "<p></p>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: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantAlines: ['+5'],
wantText: ['empty'],
},
{
description: 'Multiple spaces should be preserved',
html: '<html><body>Text with more than one space.<br></body></html>',
wantAlines: ['+10'],
wantText: ['Text with more than one space.'],
},
{
description: 'non-breaking and normal space should be preserved',
html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantAlines: ['+10'],
wantText: ['Text with more than one space.'],
},
{
description: 'Multiple nbsp should be preserved',
html: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantAlines: ['+2'],
wantText: [' '],
},
{
description: 'Multiple nbsp between words ',
html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantAlines: ['+m'],
wantText: [' word1 word2 word3'],
},
{
description: 'A non-breaking space preceded by a normal space',
html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantAlines: ['+l'],
wantText: [' word1 word2 word3'],
},
{
description: 'A non-breaking space followed by a normal space',
html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantAlines: ['+l'],
wantText: [' word1 word2 word3'],
},
{
description: 'Don\'t collapse spaces that follow a newline',
html: '<!doctype html><html><body>something<br> something<br></body></html>',
wantAlines: ['+9', '+m'],
wantText: ['something', ' something'],
},
{
description: 'Don\'t collapse spaces that follow a empty paragraph',
html: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantAlines: ['+9', '', '+m'],
wantText: ['something', '', ' something'],
},
{
description: 'Don\'t collapse spaces that preceed/follow a newline',
html: '<html><body>something <br> something<br></body></html>',
wantAlines: ['+l', '+m'],
wantText: ['something ', ' something'],
},
{
description: 'Don\'t collapse spaces that preceed/follow a empty paragraph',
html: '<html><body>something <p></p> something<br></body></html>',
wantAlines: ['+l', '', '+m'],
wantText: ['something ', '', ' something'],
},
{
description: 'Don\'t collapse non-breaking spaces that follow a newline',
html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '+c'],
wantText: ['something', ' something'],
},
{
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantAlines: ['+9', '', '+c'],
wantText: ['something', '', ' something'],
},
{
description: 'Preserve all spaces when multiple are present',
html: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantAlines: ['+h*1+4+2'],
wantText: ['Need more space s !'],
},
{
description: 'Newlines and multiple spaces across newlines should be preserved',
html: `
<html><body>Need
<span> more </span>
space
<i> s </i>
!<br></body></html>`,
wantAlines: ['+19*1+4+b'],
wantText: ['Need more space s !'],
},
{
description: 'Multiple new lines at the beginning should be preserved',
html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantAlines: ['', '', '', '', '+a', '', '+b'],
wantText: ['', '', '', '', 'first line', '', 'second line'],
},
{
description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
html: `<html><body><p>
wantAlines: ['+19*1+4+b'],
wantText: ['Need more space s !'],
},
{
description: 'Multiple new lines at the beginning should be preserved',
html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantAlines: ['', '', '', '', '+a', '', '+b'],
wantText: ['', '', '', '', 'first line', '', 'second line'],
},
{
description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь</p>
</body></html>`,
wantAlines: ['+1t'],
wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'],
},
{
description: 'lines in preformatted text should be kept intact',
html: `<html><body><p>
wantAlines: ['+1t'],
wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'],
},
{
description: 'lines in preformatted text should be kept intact',
html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о</p><pre>multiple
lines
in
@ -286,101 +275,98 @@ pre
</pre><p>п р с т у ф х ц ч ш щ ю я
ь</p>
</body></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: `<html><body><p>
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: `<html><body><p>
1
</p><pre>preline
</pre></body></html>`,
wantAlines: ['+6', '+7'],
wantText: [' 1 ', 'preline'],
},
{
description: 'Preserve spaces on the beginning and end of a element',
html: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantAlines: ['+f*1+3+1'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces outside elements',
html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantAlines: ['+g*1+1+2'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces at the end of an element',
html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantAlines: ['+g*1+2+1'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces at the start of an element',
html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
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: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantAlines: ['+f*1+3+1'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces outside elements',
html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantAlines: ['+g*1+1+2'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces at the end of an element',
html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantAlines: ['+g*1+2+1'],
wantText: ['Need more space s !'],
},
{
description: 'Preserve spaces at the start of an element',
html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
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);
});
});
}
});

View file

@ -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);
});
});

View file

@ -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));
});
});

View file

@ -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}`);
});
});

File diff suppressed because it is too large Load diff

View file

@ -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');
});
});
});

View file

@ -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);
});
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});
});

View file

@ -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));
});
});

View file

@ -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 || '<empty string>'} -> ${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 || '<empty string>'} -> ${want}`, async function () {
assert.equal(sanitizePathname(p, path[platform]), want);
});
}
});
it('default path API', async function () {
assert.equal(sanitizePathname('foo'), 'foo');
});
});

View file

@ -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);
}
});
}
});
});
});
});

View file

@ -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());
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});
});