2023-06-23 21:18:12 +02:00
|
|
|
import AttributeMap from "./AttributeMap.js";
|
|
|
|
import AttributePool from "./AttributePool.js";
|
|
|
|
import * as attributes from "./attributes.js";
|
|
|
|
import { padutils } from "./pad_utils.js";
|
2021-02-17 15:23:11 +00:00
|
|
|
'use strict';
|
2021-10-25 02:40:01 -04:00
|
|
|
/**
|
|
|
|
* A `[key, value]` pair of strings describing a text attribute.
|
|
|
|
*
|
|
|
|
* @typedef {[string, string]} Attribute
|
|
|
|
*/
|
2021-11-19 00:51:25 -05:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
*/
|
2012-02-26 11:22:46 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* This method is called whenever there is an error in the sync process.
|
|
|
|
*
|
|
|
|
* @param {string} msg - Just some message
|
2012-02-26 11:22:46 +00:00
|
|
|
*/
|
2023-06-23 21:18:12 +02:00
|
|
|
const error = (msg) => {
|
|
|
|
const e = new Error(msg);
|
2011-03-26 13:10:41 +00:00
|
|
|
e.easysync = true;
|
|
|
|
throw e;
|
|
|
|
};
|
2012-02-26 11:22:46 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Assert that a condition is truthy. If the condition is falsy, the `error` function is called to
|
|
|
|
* throw an exception.
|
|
|
|
*
|
|
|
|
* @param {boolean} b - assertion condition
|
2021-10-17 20:33:53 +02:00
|
|
|
* @param {string} msg - error message to include in the exception
|
|
|
|
* @type {(b: boolean, msg: string) => asserts b}
|
2012-02-26 11:22:46 +00:00
|
|
|
*/
|
2021-03-21 14:13:50 -04:00
|
|
|
const assert = (b, msg) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!b)
|
|
|
|
error(`Failed assertion: ${msg}`);
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2012-02-26 11:22:46 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* An operation to apply to a shared document.
|
|
|
|
*/
|
2021-10-25 01:21:19 -04:00
|
|
|
class Op {
|
|
|
|
/**
|
|
|
|
* @param {(''|'='|'+'|'-')} [opcode=''] - Initial value of the `opcode` property.
|
|
|
|
*/
|
|
|
|
constructor(opcode = '') {
|
|
|
|
/**
|
|
|
|
* The operation's operator:
|
|
|
|
* - '=': Keep the next `chars` characters (containing `lines` newlines) from the base
|
|
|
|
* document.
|
|
|
|
* - '-': Remove the next `chars` characters (containing `lines` newlines) from the base
|
|
|
|
* document.
|
|
|
|
* - '+': Insert `chars` characters (containing `lines` newlines) at the current position in
|
|
|
|
* the document. The inserted characters come from the changeset's character bank.
|
|
|
|
* - '' (empty string): Invalid operator used in some contexts to signifiy the lack of an
|
|
|
|
* operation.
|
|
|
|
*
|
|
|
|
* @type {(''|'='|'+'|'-')}
|
|
|
|
* @public
|
|
|
|
*/
|
|
|
|
this.opcode = opcode;
|
|
|
|
/**
|
|
|
|
* The number of characters to keep, insert, or delete.
|
|
|
|
*
|
|
|
|
* @type {number}
|
|
|
|
* @public
|
|
|
|
*/
|
|
|
|
this.chars = 0;
|
|
|
|
/**
|
|
|
|
* The number of characters among the `chars` characters that are newlines. If non-zero, the
|
|
|
|
* last character must be a newline.
|
|
|
|
*
|
|
|
|
* @type {number}
|
|
|
|
* @public
|
|
|
|
*/
|
|
|
|
this.lines = 0;
|
|
|
|
/**
|
|
|
|
* Identifiers of attributes to apply to the text, represented as a repeated (zero or more)
|
|
|
|
* sequence of asterisk followed by a non-negative base-36 (lower-case) integer. For example,
|
|
|
|
* '*2*1o' indicates that attributes 2 and 60 apply to the text affected by the operation. The
|
|
|
|
* identifiers come from the document's attribute pool.
|
|
|
|
*
|
|
|
|
* For keep ('=') operations, the attributes are merged with the base text's existing
|
|
|
|
* attributes:
|
|
|
|
* - A keep op attribute with a non-empty value replaces an existing base text attribute that
|
|
|
|
* has the same key.
|
|
|
|
* - A keep op attribute with an empty value is interpreted as an instruction to remove an
|
|
|
|
* existing base text attribute that has the same key, if one exists.
|
|
|
|
*
|
|
|
|
* This is the empty string for remove ('-') operations.
|
|
|
|
*
|
|
|
|
* @type {string}
|
|
|
|
* @public
|
|
|
|
*/
|
|
|
|
this.attribs = '';
|
|
|
|
}
|
|
|
|
toString() {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!this.opcode)
|
|
|
|
throw new TypeError('null op');
|
|
|
|
if (typeof this.attribs !== 'string')
|
|
|
|
throw new TypeError('attribs must be a string');
|
|
|
|
const l = this.lines ? `|${exports.numToString(this.lines)}` : '';
|
|
|
|
return this.attribs + l + this.opcode + exports.numToString(this.chars);
|
2021-10-25 01:21:19 -04:00
|
|
|
}
|
|
|
|
}
|
2012-02-26 11:22:46 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Iterator over a changeset's operations.
|
|
|
|
*
|
|
|
|
* Note: This class does NOT implement the ECMAScript iterable or iterator protocols.
|
2021-10-25 05:48:58 -04:00
|
|
|
*
|
|
|
|
* @deprecated Use `deserializeOps` instead.
|
2021-10-17 20:14:34 +02:00
|
|
|
*/
|
2021-10-13 15:42:03 -04:00
|
|
|
class OpIter {
|
|
|
|
/**
|
|
|
|
* @param {string} ops - String encoding the change operations to iterate over.
|
|
|
|
*/
|
|
|
|
constructor(ops) {
|
2023-06-23 21:18:12 +02:00
|
|
|
this._gen = exports.deserializeOps(ops);
|
2021-10-20 20:34:09 -04:00
|
|
|
this._next = this._gen.next();
|
2021-10-13 15:42:03 -04:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* @returns {boolean} Whether there are any remaining operations.
|
|
|
|
*/
|
|
|
|
hasNext() {
|
2021-10-20 20:34:09 -04:00
|
|
|
return !this._next.done;
|
2021-10-13 15:42:03 -04:00
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Returns the next operation object and advances the iterator.
|
|
|
|
*
|
|
|
|
* Note: This does NOT implement the ECMAScript iterator protocol.
|
|
|
|
*
|
|
|
|
* @param {Op} [opOut] - Deprecated. Operation object to recycle for the return value.
|
|
|
|
* @returns {Op} The next operation, or an operation with a falsy `opcode` property if there are
|
|
|
|
* no more operations.
|
|
|
|
*/
|
|
|
|
next(opOut = new Op()) {
|
|
|
|
if (this.hasNext()) {
|
2021-10-20 20:34:09 -04:00
|
|
|
copyOp(this._next.value, opOut);
|
|
|
|
this._next = this._gen.next();
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-10-13 15:42:03 -04:00
|
|
|
clearOp(opOut);
|
|
|
|
}
|
|
|
|
return opOut;
|
|
|
|
}
|
|
|
|
}
|
2012-02-26 11:34:06 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Cleans an Op object.
|
|
|
|
*
|
|
|
|
* @param {Op} op - object to clear
|
2012-02-26 11:34:06 +00:00
|
|
|
*/
|
2021-03-21 14:13:50 -04:00
|
|
|
const clearOp = (op) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
op.opcode = '';
|
|
|
|
op.chars = 0;
|
|
|
|
op.lines = 0;
|
|
|
|
op.attribs = '';
|
|
|
|
};
|
2012-02-26 11:34:06 +00:00
|
|
|
/**
|
|
|
|
* Copies op1 to op2
|
2021-10-17 20:14:34 +02:00
|
|
|
*
|
|
|
|
* @param {Op} op1 - src Op
|
2021-10-02 18:17:11 -04:00
|
|
|
* @param {Op} [op2] - dest Op. If not given, a new Op is used.
|
|
|
|
* @returns {Op} `op2`
|
2012-02-26 11:34:06 +00:00
|
|
|
*/
|
2021-10-25 01:21:19 -04:00
|
|
|
const copyOp = (op1, op2 = new Op()) => Object.assign(op2, op1);
|
2012-02-26 11:34:06 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Serializes a sequence of Ops.
|
|
|
|
*
|
|
|
|
* @typedef {object} OpAssembler
|
|
|
|
* @property {Function} append -
|
|
|
|
* @property {Function} clear -
|
|
|
|
* @property {Function} toString -
|
|
|
|
*/
|
|
|
|
/**
|
|
|
|
* Efficiently merges consecutive operations that are mergeable, ignores no-ops, and drops final
|
|
|
|
* pure "keeps". It does not re-order operations.
|
|
|
|
*
|
|
|
|
* @typedef {object} MergingOpAssembler
|
|
|
|
* @property {Function} append -
|
|
|
|
* @property {Function} clear -
|
|
|
|
* @property {Function} endDocument -
|
|
|
|
* @property {Function} toString -
|
|
|
|
*/
|
2021-10-13 00:19:09 -04:00
|
|
|
/**
|
|
|
|
* Generates operations from the given text and attributes.
|
|
|
|
*
|
|
|
|
* @param {('-'|'+'|'=')} opcode - The operator to use.
|
|
|
|
* @param {string} text - The text to remove/add/keep.
|
2021-11-19 00:51:25 -05:00
|
|
|
* @param {(Iterable<Attribute>|AttributeString)} [attribs] - The attributes to insert into the pool
|
|
|
|
* (if necessary) and encode. If an attribute string, no checking is performed to ensure that
|
|
|
|
* the attributes exist in the pool, are in the canonical order, and contain no duplicate keys.
|
|
|
|
* If this is an iterable of attributes, `pool` must be non-null.
|
|
|
|
* @param {?AttributePool} pool - Attribute pool. Required if `attribs` is an iterable of
|
|
|
|
* attributes, ignored if `attribs` is an attribute string.
|
2021-10-13 00:19:09 -04:00
|
|
|
* @yields {Op} One or two ops (depending on the presense of newlines) that cover the given text.
|
|
|
|
* @returns {Generator<Op>}
|
|
|
|
*/
|
|
|
|
const opsFromText = function* (opcode, text, attribs = '', pool = null) {
|
2021-10-25 01:21:19 -04:00
|
|
|
const op = new Op(opcode);
|
2021-11-19 00:51:25 -05:00
|
|
|
op.attribs = typeof attribs === 'string'
|
2023-06-23 21:18:12 +02:00
|
|
|
? attribs : new AttributeMap(pool).update(attribs || [], opcode === '+').toString();
|
2021-10-13 00:19:09 -04:00
|
|
|
const lastNewlinePos = text.lastIndexOf('\n');
|
|
|
|
if (lastNewlinePos < 0) {
|
|
|
|
op.chars = text.length;
|
|
|
|
op.lines = 0;
|
|
|
|
yield op;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-10-13 00:19:09 -04:00
|
|
|
op.chars = lastNewlinePos + 1;
|
|
|
|
op.lines = text.match(/\n/g).length;
|
|
|
|
yield op;
|
|
|
|
const op2 = copyOp(op);
|
|
|
|
op2.chars = text.length - (lastNewlinePos + 1);
|
|
|
|
op2.lines = 0;
|
|
|
|
yield op2;
|
|
|
|
}
|
|
|
|
};
|
2021-10-17 20:14:34 +02:00
|
|
|
/**
|
2023-06-23 21:18:12 +02:00
|
|
|
* @typedef {object} StringArrayLike
|
|
|
|
* @property {(i: number) => string} get - Returns the line at index `i`.
|
|
|
|
* @property {(number|(() => number))} length - The number of lines, or a method that returns the
|
|
|
|
* number of lines.
|
|
|
|
* @property {(((start?: number, end?: number) => string[])|undefined)} slice - Like
|
|
|
|
* `Array.prototype.slice()`. Optional if the return value of the `removeLines` method is not
|
|
|
|
* needed.
|
|
|
|
* @property {(i: number, d?: number, ...l: string[]) => any} splice - Like
|
|
|
|
* `Array.prototype.splice()`.
|
2021-10-17 20:14:34 +02:00
|
|
|
*/
|
|
|
|
/**
|
2023-06-23 21:18:12 +02:00
|
|
|
* Class to iterate and modify texts which have several lines. It is used for applying Changesets on
|
|
|
|
* arrays of lines.
|
2021-10-17 20:14:34 +02:00
|
|
|
*
|
2023-06-23 21:18:12 +02:00
|
|
|
* Mutation operations have the same constraints as exports operations with respect to newlines, but
|
|
|
|
* not the other additional constraints (i.e. ins/del ordering, forbidden no-ops, non-mergeability,
|
|
|
|
* final newline). Can be used to mutate lists of strings where the last char of each string is not
|
|
|
|
* actually a newline, but for the purposes of N and L values, the caller should pretend it is, and
|
|
|
|
* for things to work right in that case, the input to the `insert` method should be a single line
|
|
|
|
* with no newlines.
|
2012-02-26 11:34:06 +00:00
|
|
|
*/
|
2023-06-23 21:18:12 +02:00
|
|
|
class TextLinesMutator {
|
|
|
|
/**
|
|
|
|
* @param {(string[]|StringArrayLike)} lines - Lines to mutate (in place).
|
|
|
|
*/
|
|
|
|
constructor(lines) {
|
|
|
|
this._lines = lines;
|
|
|
|
/**
|
|
|
|
* this._curSplice holds values that will be passed as arguments to this._lines.splice() to
|
|
|
|
* insert, delete, or change lines:
|
|
|
|
* - this._curSplice[0] is an index into the this._lines array.
|
|
|
|
* - this._curSplice[1] is the number of lines that will be removed from the this._lines array
|
|
|
|
* starting at the index.
|
|
|
|
* - The other elements represent mutated (changed by ops) lines or new lines (added by ops)
|
|
|
|
* to insert at the index.
|
|
|
|
*
|
|
|
|
* @type {[number, number?, ...string[]?]}
|
|
|
|
*/
|
|
|
|
this._curSplice = [0, 0];
|
|
|
|
this._inSplice = false;
|
|
|
|
// position in lines after curSplice is applied:
|
|
|
|
this._curLine = 0;
|
|
|
|
this._curCol = 0;
|
|
|
|
// invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) &&
|
|
|
|
// curLine >= curSplice[0]
|
|
|
|
// invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then
|
|
|
|
// curCol == 0
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Get a line from `lines` at given index.
|
|
|
|
*
|
|
|
|
* @param {number} idx - an index
|
|
|
|
* @returns {string}
|
|
|
|
*/
|
|
|
|
_linesGet(idx) {
|
|
|
|
if ('get' in this._lines) {
|
|
|
|
return this._lines.get(idx);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return this._lines[idx];
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
/**
|
|
|
|
* Return a slice from `lines`.
|
|
|
|
*
|
|
|
|
* @param {number} start - the start index
|
|
|
|
* @param {number} end - the end index
|
|
|
|
* @returns {string[]}
|
|
|
|
*/
|
|
|
|
_linesSlice(start, end) {
|
|
|
|
// can be unimplemented if removeLines's return value not needed
|
|
|
|
if (this._lines.slice) {
|
|
|
|
return this._lines.slice(start, end);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
else {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
}
|
2021-10-25 02:40:01 -04:00
|
|
|
/**
|
2023-06-23 21:18:12 +02:00
|
|
|
* Return the length of `lines`.
|
2021-10-25 02:40:01 -04:00
|
|
|
*
|
2023-06-23 21:18:12 +02:00
|
|
|
* @returns {number}
|
2021-10-25 02:40:01 -04:00
|
|
|
*/
|
2023-06-23 21:18:12 +02:00
|
|
|
_linesLength() {
|
|
|
|
if (typeof this._lines.length === 'number') {
|
|
|
|
return this._lines.length;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return this._lines.length();
|
|
|
|
}
|
|
|
|
}
|
2021-10-20 21:52:37 +02:00
|
|
|
/**
|
2023-06-23 21:18:12 +02:00
|
|
|
* Starts a new splice.
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
_enterSplice() {
|
|
|
|
this._curSplice[0] = this._curLine;
|
|
|
|
this._curSplice[1] = 0;
|
2016-01-23 12:57:48 +01:00
|
|
|
// TODO(doc) when is this the case?
|
|
|
|
// check all enterSplice calls and changes to curCol
|
2023-06-23 21:18:12 +02:00
|
|
|
if (this._curCol > 0)
|
|
|
|
this._putCurLineInSplice();
|
2021-03-21 14:34:02 -04:00
|
|
|
this._inSplice = true;
|
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Changes the lines array according to the values in curSplice and resets curSplice. Called via
|
|
|
|
* close or TODO(doc).
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
_leaveSplice() {
|
2023-06-23 21:18:12 +02:00
|
|
|
this._lines.splice(...this._curSplice);
|
|
|
|
this._curSplice.length = 2;
|
|
|
|
this._curSplice[0] = this._curSplice[1] = 0;
|
|
|
|
this._inSplice = false;
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
|
|
|
* Indicates if curLine is already in the splice. This is necessary because the last element in
|
2021-10-20 21:52:37 +02:00
|
|
|
* curSplice is curLine when this line is currently worked on (e.g. when skipping or inserting).
|
2016-01-23 12:57:48 +01:00
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @returns {boolean} true if curLine is in splice
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
_isCurLineInSplice() {
|
2021-11-30 00:27:38 -05:00
|
|
|
// The value of `this._curSplice[1]` does not matter when determining the return value because
|
|
|
|
// `this._curLine` refers to the line number *after* the splice is applied (so after those lines
|
|
|
|
// are deleted).
|
2021-03-21 14:34:02 -04:00
|
|
|
return this._curLine - this._curSplice[0] < this._curSplice.length - 2;
|
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Incorporates current line into the splice and marks its old position to be deleted.
|
2016-01-23 12:57:48 +01:00
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @returns {number} the index of the added line in curSplice
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
_putCurLineInSplice() {
|
|
|
|
if (!this._isCurLineInSplice()) {
|
|
|
|
this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1]));
|
|
|
|
this._curSplice[1]++;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
// TODO should be the same as this._curSplice.length - 1
|
|
|
|
return 2 + this._curLine - this._curSplice[0];
|
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
|
|
|
* It will skip some newlines by putting them into the splice.
|
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @param {number} L -
|
2021-10-20 21:52:37 +02:00
|
|
|
* @param {boolean} includeInSplice - Indicates that attributes are present.
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2023-06-23 21:18:12 +02:00
|
|
|
skipLines(L, includeInSplice) {
|
|
|
|
if (!L)
|
|
|
|
return;
|
2021-09-27 17:25:31 -04:00
|
|
|
if (includeInSplice) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!this._inSplice)
|
|
|
|
this._enterSplice();
|
2021-09-27 17:25:31 -04:00
|
|
|
// TODO(doc) should this count the number of characters that are skipped to check?
|
|
|
|
for (let i = 0; i < L; i++) {
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curCol = 0;
|
|
|
|
this._putCurLineInSplice();
|
|
|
|
this._curLine++;
|
2021-09-27 17:25:31 -04:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-03-21 14:34:02 -04:00
|
|
|
if (this._inSplice) {
|
2021-09-27 17:25:31 -04:00
|
|
|
if (L > 1) {
|
|
|
|
// TODO(doc) figure out why single lines are incorporated into splice instead of ignored
|
2021-03-21 14:34:02 -04:00
|
|
|
this._leaveSplice();
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-03-21 14:34:02 -04:00
|
|
|
this._putCurLineInSplice();
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curLine += L;
|
|
|
|
this._curCol = 0;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-09-27 17:25:31 -04:00
|
|
|
// tests case foo in remove(), which isn't otherwise covered in current impl
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
|
|
|
* Skip some characters. Can contain newlines.
|
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @param {number} N - number of characters to skip
|
|
|
|
* @param {number} L - number of newlines to skip
|
|
|
|
* @param {boolean} includeInSplice - indicates if attributes are present
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
skip(N, L, includeInSplice) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!N)
|
|
|
|
return;
|
2021-09-27 17:25:31 -04:00
|
|
|
if (L) {
|
2021-03-21 14:34:02 -04:00
|
|
|
this.skipLines(L, includeInSplice);
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (includeInSplice && !this._inSplice)
|
|
|
|
this._enterSplice();
|
2021-03-21 14:34:02 -04:00
|
|
|
if (this._inSplice) {
|
2021-09-27 17:25:31 -04:00
|
|
|
// although the line is put into splice curLine is not increased, because
|
|
|
|
// only some chars are skipped, not the whole line
|
2021-03-21 14:34:02 -04:00
|
|
|
this._putCurLineInSplice();
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curCol += N;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Remove whole lines from lines array.
|
2016-01-23 12:57:48 +01:00
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @param {number} L - number of lines to remove
|
|
|
|
* @returns {string}
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
removeLines(L) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!L)
|
|
|
|
return '';
|
|
|
|
if (!this._inSplice)
|
|
|
|
this._enterSplice();
|
2021-09-27 17:25:31 -04:00
|
|
|
/**
|
|
|
|
* Gets a string of joined lines after the end of the splice.
|
|
|
|
*
|
|
|
|
* @param {number} k - number of lines
|
|
|
|
* @returns {string} joined lines
|
|
|
|
*/
|
|
|
|
const nextKLinesText = (k) => {
|
2021-03-21 14:34:02 -04:00
|
|
|
const m = this._curSplice[0] + this._curSplice[1];
|
|
|
|
return this._linesSlice(m, m + k).join('');
|
2021-09-27 17:25:31 -04:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
let removed = '';
|
2021-03-21 14:34:02 -04:00
|
|
|
if (this._isCurLineInSplice()) {
|
|
|
|
if (this._curCol === 0) {
|
|
|
|
removed = this._curSplice[this._curSplice.length - 1];
|
|
|
|
this._curSplice.length--;
|
2021-09-27 17:25:31 -04:00
|
|
|
removed += nextKLinesText(L - 1);
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice[1] += L - 1;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-09-27 17:25:31 -04:00
|
|
|
removed = nextKLinesText(L - 1);
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice[1] += L - 1;
|
|
|
|
const sline = this._curSplice.length - 1;
|
|
|
|
removed = this._curSplice[sline].substring(this._curCol) + removed;
|
|
|
|
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
|
|
|
|
this._linesGet(this._curSplice[0] + this._curSplice[1]);
|
|
|
|
this._curSplice[1] += 1;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-09-27 17:25:31 -04:00
|
|
|
removed = nextKLinesText(L);
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice[1] += L;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
return removed;
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Remove text from lines array.
|
2016-01-23 12:57:48 +01:00
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @param {number} N - characters to delete
|
|
|
|
* @param {number} L - lines to delete
|
|
|
|
* @returns {string}
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
remove(N, L) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!N)
|
|
|
|
return '';
|
|
|
|
if (L)
|
|
|
|
return this.removeLines(L);
|
|
|
|
if (!this._inSplice)
|
|
|
|
this._enterSplice();
|
2021-09-27 17:25:31 -04:00
|
|
|
// although the line is put into splice, curLine is not increased, because
|
|
|
|
// only some chars are removed not the whole line
|
2021-03-21 14:34:02 -04:00
|
|
|
const sline = this._putCurLineInSplice();
|
|
|
|
const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N);
|
|
|
|
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
|
|
|
|
this._curSplice[sline].substring(this._curCol + N);
|
2011-03-26 13:10:41 +00:00
|
|
|
return removed;
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
|
|
|
* Inserts text into lines array.
|
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @param {string} text - the text to insert
|
|
|
|
* @param {number} L - number of newlines in text
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
insert(text, L) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!text)
|
|
|
|
return;
|
|
|
|
if (!this._inSplice)
|
|
|
|
this._enterSplice();
|
2021-09-27 17:25:31 -04:00
|
|
|
if (L) {
|
2023-06-23 21:18:12 +02:00
|
|
|
const newLines = exports.splitTextLines(text);
|
2021-03-21 14:34:02 -04:00
|
|
|
if (this._isCurLineInSplice()) {
|
|
|
|
const sline = this._curSplice.length - 1;
|
2021-09-27 17:25:31 -04:00
|
|
|
/** @type {string} */
|
2021-03-21 14:34:02 -04:00
|
|
|
const theLine = this._curSplice[sline];
|
|
|
|
const lineCol = this._curCol;
|
2021-10-20 21:52:37 +02:00
|
|
|
// Insert the chars up to `curCol` and the first new line.
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
|
|
|
|
this._curLine++;
|
2021-09-27 17:25:31 -04:00
|
|
|
newLines.splice(0, 1);
|
|
|
|
// insert the remaining new lines
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice.push(...newLines);
|
|
|
|
this._curLine += newLines.length;
|
2021-09-27 17:25:31 -04:00
|
|
|
// insert the remaining chars from the "old" line (e.g. the line we were in
|
|
|
|
// when we started to insert new lines)
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice.push(theLine.substring(lineCol));
|
|
|
|
this._curCol = 0; // TODO(doc) why is this not set to the length of last line?
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice.push(...newLines);
|
|
|
|
this._curLine += newLines.length;
|
2021-09-27 17:25:31 -04:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-10-20 21:52:37 +02:00
|
|
|
// There are no additional lines. Although the line is put into splice, curLine is not
|
|
|
|
// increased because there may be more chars in the line (newline is not reached).
|
2021-03-21 14:34:02 -04:00
|
|
|
const sline = this._putCurLineInSplice();
|
|
|
|
if (!this._curSplice[sline]) {
|
2023-06-23 21:18:12 +02:00
|
|
|
const err = new Error('curSplice[sline] not populated, actual curSplice contents is ' +
|
2021-03-21 14:34:02 -04:00
|
|
|
`${JSON.stringify(this._curSplice)}. Possibly related to ` +
|
2021-03-04 20:37:37 -05:00
|
|
|
'https://github.com/ether/etherpad-lite/issues/2802');
|
|
|
|
console.error(err.stack || err.toString());
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text +
|
|
|
|
this._curSplice[sline].substring(this._curCol);
|
|
|
|
this._curCol += text.length;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Checks if curLine (the line we are in when curSplice is applied) is the last line in `lines`.
|
2016-01-23 12:57:48 +01:00
|
|
|
*
|
2021-10-17 20:14:34 +02:00
|
|
|
* @returns {boolean} indicates if there are lines left
|
2016-01-23 12:57:48 +01:00
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
hasMore() {
|
|
|
|
let docLines = this._linesLength();
|
|
|
|
if (this._inSplice) {
|
|
|
|
docLines += this._curSplice.length - 2 - this._curSplice[1];
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-03-21 14:34:02 -04:00
|
|
|
return this._curLine < docLines;
|
|
|
|
}
|
2016-01-23 12:57:48 +01:00
|
|
|
/**
|
|
|
|
* Closes the splice
|
|
|
|
*/
|
2021-03-21 14:34:02 -04:00
|
|
|
close() {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (this._inSplice)
|
|
|
|
this._leaveSplice();
|
2021-03-21 14:34:02 -04:00
|
|
|
}
|
|
|
|
}
|
2012-02-26 18:59:11 +00:00
|
|
|
/**
|
2021-10-17 20:14:34 +02:00
|
|
|
* Apply operations to other operations.
|
|
|
|
*
|
|
|
|
* @param {string} in1 - first Op string
|
|
|
|
* @param {string} in2 - second Op string
|
|
|
|
* @param {Function} func - Callback that applies an operation to another operation. Will be called
|
|
|
|
* multiple times depending on the number of operations in `in1` and `in2`. `func` has signature
|
2021-10-13 17:00:50 -04:00
|
|
|
* `opOut = f(op1, op2)`:
|
2021-10-17 20:14:34 +02:00
|
|
|
* - `op1` is the current operation from `in1`. `func` is expected to mutate `op1` to
|
|
|
|
* partially or fully consume it, and MUST set `op1.opcode` to the empty string once `op1`
|
|
|
|
* is fully consumed. If `op1` is not fully consumed, `func` will be called again with the
|
|
|
|
* same `op1` value. If `op1` is fully consumed, the next call to `func` will be given the
|
|
|
|
* next operation from `in1`. If there are no more operations in `in1`, `op1.opcode` will be
|
|
|
|
* the empty string.
|
|
|
|
* - `op2` is the current operation from `in2`, to apply to `op1`. Has the same consumption
|
|
|
|
* and advancement semantics as `op1`.
|
2021-10-13 17:00:50 -04:00
|
|
|
* - `opOut` is the result of applying `op2` (before consumption) to `op1` (before
|
|
|
|
* consumption). If there is no result (perhaps `op1` and `op2` cancelled each other out),
|
|
|
|
* either `opOut` must be nullish or `opOut.opcode` must be the empty string.
|
2021-10-17 20:14:34 +02:00
|
|
|
* @returns {string} the integrated changeset
|
2012-02-26 18:59:11 +00:00
|
|
|
*/
|
2021-03-21 14:13:50 -04:00
|
|
|
const applyZip = (in1, in2, func) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
const ops1 = exports.deserializeOps(in1);
|
|
|
|
const ops2 = exports.deserializeOps(in2);
|
2021-10-25 05:48:58 -04:00
|
|
|
let next1 = ops1.next();
|
|
|
|
let next2 = ops2.next();
|
2023-06-23 21:18:12 +02:00
|
|
|
const assem = exports.smartOpAssembler();
|
2021-10-25 05:48:58 -04:00
|
|
|
while (!next1.done || !next2.done) {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!next1.done && !next1.value.opcode)
|
|
|
|
next1 = ops1.next();
|
|
|
|
if (!next2.done && !next2.value.opcode)
|
|
|
|
next2 = ops2.next();
|
|
|
|
if (next1.value == null)
|
|
|
|
next1.value = new Op();
|
|
|
|
if (next2.value == null)
|
|
|
|
next2.value = new Op();
|
|
|
|
if (!next1.value.opcode && !next2.value.opcode)
|
|
|
|
break;
|
2021-10-25 05:48:58 -04:00
|
|
|
const opOut = func(next1.value, next2.value);
|
2023-06-23 21:18:12 +02:00
|
|
|
if (opOut && opOut.opcode)
|
|
|
|
assem.append(opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
assem.endDocument();
|
|
|
|
return assem.toString();
|
|
|
|
};
|
2012-02-26 12:18:17 +00:00
|
|
|
/**
|
2023-06-23 21:18:12 +02:00
|
|
|
* Function used as parameter for applyZip to apply a Changeset to an attribute.
|
2021-10-17 20:14:34 +02:00
|
|
|
*
|
2023-06-23 21:18:12 +02:00
|
|
|
* @param {Op} attOp - The op from the sequence that is being operated on, either an attribution
|
|
|
|
* string or the earlier of two exportss being composed.
|
|
|
|
* @param {Op} csOp -
|
|
|
|
* @param {AttributePool} pool - Can be null if definitely not needed.
|
|
|
|
* @returns {Op} The result of applying `csOp` to `attOp`.
|
2012-02-26 12:18:17 +00:00
|
|
|
*/
|
2023-06-23 21:18:12 +02:00
|
|
|
const slicerZipperFunc = (attOp, csOp, pool) => {
|
|
|
|
const opOut = new Op();
|
|
|
|
if (!attOp.opcode) {
|
|
|
|
copyOp(csOp, opOut);
|
|
|
|
csOp.opcode = '';
|
|
|
|
}
|
|
|
|
else if (!csOp.opcode) {
|
|
|
|
copyOp(attOp, opOut);
|
|
|
|
attOp.opcode = '';
|
|
|
|
}
|
|
|
|
else if (attOp.opcode === '-') {
|
|
|
|
copyOp(attOp, opOut);
|
|
|
|
attOp.opcode = '';
|
|
|
|
}
|
|
|
|
else if (csOp.opcode === '+') {
|
|
|
|
copyOp(csOp, opOut);
|
|
|
|
csOp.opcode = '';
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
for (const op of [attOp, csOp]) {
|
|
|
|
assert(op.chars >= op.lines, `op has more newlines than chars: ${op.toString()}`);
|
|
|
|
}
|
|
|
|
assert(attOp.chars < csOp.chars ? attOp.lines <= csOp.lines
|
|
|
|
: attOp.chars > csOp.chars ? attOp.lines >= csOp.lines
|
|
|
|
: attOp.lines === csOp.lines, 'line count mismatch when composing changesets A*B; ' +
|
|
|
|
`opA: ${attOp.toString()} opB: ${csOp.toString()}`);
|
|
|
|
assert(['+', '='].includes(attOp.opcode), `unexpected opcode in op: ${attOp.toString()}`);
|
|
|
|
assert(['-', '='].includes(csOp.opcode), `unexpected opcode in op: ${csOp.toString()}`);
|
|
|
|
opOut.opcode = {
|
|
|
|
'+': {
|
|
|
|
'-': '',
|
|
|
|
'=': '+',
|
|
|
|
},
|
|
|
|
'=': {
|
|
|
|
'-': '-',
|
|
|
|
'=': '=',
|
|
|
|
},
|
|
|
|
}[attOp.opcode][csOp.opcode];
|
|
|
|
const [fullyConsumedOp, partiallyConsumedOp] = [attOp, csOp].sort((a, b) => a.chars - b.chars);
|
|
|
|
opOut.chars = fullyConsumedOp.chars;
|
|
|
|
opOut.lines = fullyConsumedOp.lines;
|
|
|
|
opOut.attribs = csOp.opcode === '-'
|
|
|
|
// csOp is a remove op and remove ops normally never have any attributes, so this should
|
|
|
|
// normally be the empty string. However, padDiff.js adds attributes to remove ops and needs
|
|
|
|
// them preserved so they are copied here.
|
|
|
|
? csOp.attribs
|
|
|
|
: exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode === '=', pool);
|
|
|
|
partiallyConsumedOp.chars -= fullyConsumedOp.chars;
|
|
|
|
partiallyConsumedOp.lines -= fullyConsumedOp.lines;
|
|
|
|
if (!partiallyConsumedOp.chars)
|
|
|
|
partiallyConsumedOp.opcode = '';
|
|
|
|
fullyConsumedOp.opcode = '';
|
|
|
|
}
|
|
|
|
return opOut;
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* Transforms a changeset into a list of splices in the form [startChar, endChar, newText] meaning
|
|
|
|
* replace text from startChar to endChar with newText.
|
|
|
|
*
|
|
|
|
* @param {string} cs - Changeset
|
|
|
|
* @returns {[number, number, string][]}
|
|
|
|
*/
|
|
|
|
const toSplices = (cs) => {
|
|
|
|
const unpacked = exports.unpack(cs);
|
|
|
|
/** @type {[number, number, string][]} */
|
|
|
|
const splices = [];
|
|
|
|
let oldPos = 0;
|
|
|
|
const charIter = exports.stringIterator(unpacked.charBank);
|
|
|
|
let inSplice = false;
|
|
|
|
for (const op of exports.deserializeOps(unpacked.ops)) {
|
|
|
|
if (op.opcode === '=') {
|
|
|
|
oldPos += op.chars;
|
|
|
|
inSplice = false;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (!inSplice) {
|
|
|
|
splices.push([oldPos, oldPos, '']);
|
|
|
|
inSplice = true;
|
|
|
|
}
|
|
|
|
if (op.opcode === '-') {
|
|
|
|
oldPos += op.chars;
|
|
|
|
splices[splices.length - 1][1] += op.chars;
|
|
|
|
}
|
|
|
|
else if (op.opcode === '+') {
|
|
|
|
splices[splices.length - 1][2] += charIter.take(op.chars);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return splices;
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* @deprecated Use an AttributeMap instead.
|
|
|
|
*/
|
|
|
|
const attribsAttributeValue = (attribs, key, pool) => {
|
|
|
|
if (!attribs)
|
|
|
|
return '';
|
|
|
|
for (const [k, v] of attributes.attribsFromString(attribs, pool)) {
|
|
|
|
if (k === key)
|
|
|
|
return v;
|
|
|
|
}
|
|
|
|
return '';
|
|
|
|
};
|
|
|
|
const followAttributes = (att1, att2, pool) => {
|
|
|
|
// The merge of two sets of attribute changes to the same text
|
|
|
|
// takes the lexically-earlier value if there are two values
|
|
|
|
// for the same key. Otherwise, all key/value changes from
|
|
|
|
// both attribute sets are taken. This operation is the "follow",
|
|
|
|
// so a set of changes is produced that can be applied to att1
|
|
|
|
// to produce the merged set.
|
|
|
|
if ((!att2) || (!pool))
|
|
|
|
return '';
|
|
|
|
if (!att1)
|
|
|
|
return att2;
|
|
|
|
const atts = new Map();
|
|
|
|
att2.replace(/\*([0-9a-z]+)/g, (_, a) => {
|
|
|
|
const [key, val] = pool.getAttrib(exports.parseNum(a));
|
|
|
|
atts.set(key, val);
|
|
|
|
return '';
|
|
|
|
});
|
|
|
|
att1.replace(/\*([0-9a-z]+)/g, (_, a) => {
|
|
|
|
const [key, val] = pool.getAttrib(exports.parseNum(a));
|
|
|
|
if (atts.has(key) && val <= atts.get(key))
|
|
|
|
atts.delete(key);
|
|
|
|
return '';
|
|
|
|
});
|
|
|
|
// we've only removed attributes, so they're already sorted
|
|
|
|
const buf = exports.stringAssembler();
|
|
|
|
for (const att of atts) {
|
|
|
|
buf.append('*');
|
|
|
|
buf.append(exports.numToString(pool.putAttrib(att)));
|
|
|
|
}
|
|
|
|
return buf.toString();
|
|
|
|
};
|
|
|
|
export const parseNum = (str) => parseInt(str, 36);
|
|
|
|
export const numToString = (num) => num.toString(36).toLowerCase();
|
|
|
|
export const oldLen = (cs) => exports.unpack(cs).oldLen;
|
|
|
|
export const newLen = (cs) => exports.unpack(cs).newLen;
|
|
|
|
export const deserializeOps = function* (ops) {
|
|
|
|
// TODO: Migrate to String.prototype.matchAll() once there is enough browser support.
|
|
|
|
const regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|(.)/g;
|
|
|
|
let match;
|
|
|
|
while ((match = regex.exec(ops)) != null) {
|
|
|
|
if (match[5] === '$')
|
|
|
|
return; // Start of the insert operation character bank.
|
|
|
|
if (match[5] != null)
|
|
|
|
error(`invalid operation: ${ops.slice(regex.lastIndex - 1)}`);
|
|
|
|
const op = new Op(match[3]);
|
|
|
|
op.lines = exports.parseNum(match[2] || '0');
|
|
|
|
op.chars = exports.parseNum(match[4]);
|
|
|
|
op.attribs = match[1];
|
|
|
|
yield op;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
export const opIterator = (opsStr) => {
|
|
|
|
padutils.warnDeprecated('Changeset.opIterator() is deprecated; use Changeset.deserializeOps() instead');
|
|
|
|
return new OpIter(opsStr);
|
|
|
|
};
|
|
|
|
export const newOp = (optOpcode) => {
|
|
|
|
padutils.warnDeprecated('Changeset.newOp() is deprecated; use the Changeset.Op class instead');
|
|
|
|
return new Op(optOpcode);
|
|
|
|
};
|
|
|
|
export const checkRep = (cs) => {
|
|
|
|
const unpacked = exports.unpack(cs);
|
|
|
|
const oldLen = unpacked.oldLen;
|
|
|
|
const newLen = unpacked.newLen;
|
|
|
|
const ops = unpacked.ops;
|
|
|
|
let charBank = unpacked.charBank;
|
|
|
|
const assem = exports.smartOpAssembler();
|
|
|
|
let oldPos = 0;
|
|
|
|
let calcNewLen = 0;
|
|
|
|
for (const o of exports.deserializeOps(ops)) {
|
|
|
|
switch (o.opcode) {
|
|
|
|
case '=':
|
|
|
|
oldPos += o.chars;
|
|
|
|
calcNewLen += o.chars;
|
|
|
|
break;
|
|
|
|
case '-':
|
|
|
|
oldPos += o.chars;
|
|
|
|
assert(oldPos <= oldLen, `${oldPos} > ${oldLen} in ${cs}`);
|
|
|
|
break;
|
|
|
|
case '+':
|
|
|
|
{
|
|
|
|
assert(charBank.length >= o.chars, 'Invalid changeset: not enough chars in charBank');
|
|
|
|
const chars = charBank.slice(0, o.chars);
|
|
|
|
const nlines = (chars.match(/\n/g) || []).length;
|
|
|
|
assert(nlines === o.lines, 'Invalid changeset: number of newlines in insert op does not match the charBank');
|
|
|
|
assert(o.lines === 0 || chars.endsWith('\n'), 'Invalid changeset: multiline insert op does not end with a newline');
|
|
|
|
charBank = charBank.slice(o.chars);
|
|
|
|
calcNewLen += o.chars;
|
|
|
|
assert(calcNewLen <= newLen, `${calcNewLen} > ${newLen} in ${cs}`);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
assert(false, `Invalid changeset: Unknown opcode: ${JSON.stringify(o.opcode)}`);
|
|
|
|
}
|
|
|
|
assem.append(o);
|
|
|
|
}
|
|
|
|
calcNewLen += oldLen - oldPos;
|
|
|
|
assert(calcNewLen === newLen, 'Invalid changeset: claimed length does not match actual length');
|
|
|
|
assert(charBank === '', 'Invalid changeset: excess characters in the charBank');
|
|
|
|
assem.endDocument();
|
|
|
|
const normalized = exports.pack(oldLen, calcNewLen, assem.toString(), unpacked.charBank);
|
|
|
|
assert(normalized === cs, 'Invalid changeset: not in canonical form');
|
|
|
|
return cs;
|
|
|
|
};
|
|
|
|
export const smartOpAssembler = () => {
|
|
|
|
const minusAssem = exports.mergingOpAssembler();
|
|
|
|
const plusAssem = exports.mergingOpAssembler();
|
|
|
|
const keepAssem = exports.mergingOpAssembler();
|
|
|
|
const assem = exports.stringAssembler();
|
|
|
|
let lastOpcode = '';
|
|
|
|
let lengthChange = 0;
|
|
|
|
const flushKeeps = () => {
|
|
|
|
assem.append(keepAssem.toString());
|
|
|
|
keepAssem.clear();
|
|
|
|
};
|
|
|
|
const flushPlusMinus = () => {
|
|
|
|
assem.append(minusAssem.toString());
|
|
|
|
minusAssem.clear();
|
|
|
|
assem.append(plusAssem.toString());
|
|
|
|
plusAssem.clear();
|
|
|
|
};
|
|
|
|
const append = (op) => {
|
|
|
|
if (!op.opcode)
|
|
|
|
return;
|
|
|
|
if (!op.chars)
|
|
|
|
return;
|
|
|
|
if (op.opcode === '-') {
|
|
|
|
if (lastOpcode === '=') {
|
|
|
|
flushKeeps();
|
|
|
|
}
|
|
|
|
minusAssem.append(op);
|
|
|
|
lengthChange -= op.chars;
|
|
|
|
}
|
|
|
|
else if (op.opcode === '+') {
|
|
|
|
if (lastOpcode === '=') {
|
|
|
|
flushKeeps();
|
|
|
|
}
|
|
|
|
plusAssem.append(op);
|
|
|
|
lengthChange += op.chars;
|
|
|
|
}
|
|
|
|
else if (op.opcode === '=') {
|
|
|
|
if (lastOpcode !== '=') {
|
|
|
|
flushPlusMinus();
|
|
|
|
}
|
|
|
|
keepAssem.append(op);
|
|
|
|
}
|
|
|
|
lastOpcode = op.opcode;
|
|
|
|
};
|
|
|
|
/**
|
|
|
|
* Generates operations from the given text and attributes.
|
|
|
|
*
|
|
|
|
* @deprecated Use `opsFromText` instead.
|
|
|
|
* @param {('-'|'+'|'=')} opcode - The operator to use.
|
|
|
|
* @param {string} text - The text to remove/add/keep.
|
|
|
|
* @param {(string|Iterable<Attribute>)} attribs - The attributes to apply to the operations.
|
|
|
|
* @param {?AttributePool} pool - Attribute pool. Only required if `attribs` is an iterable of
|
|
|
|
* attribute key, value pairs.
|
|
|
|
*/
|
|
|
|
const appendOpWithText = (opcode, text, attribs, pool) => {
|
|
|
|
padutils.warnDeprecated('Changeset.smartOpAssembler().appendOpWithText() is deprecated; ' +
|
|
|
|
'use opsFromText() instead.');
|
|
|
|
for (const op of opsFromText(opcode, text, attribs, pool))
|
|
|
|
append(op);
|
|
|
|
};
|
|
|
|
const toString = () => {
|
|
|
|
flushPlusMinus();
|
|
|
|
flushKeeps();
|
|
|
|
return assem.toString();
|
|
|
|
};
|
|
|
|
const clear = () => {
|
|
|
|
minusAssem.clear();
|
|
|
|
plusAssem.clear();
|
|
|
|
keepAssem.clear();
|
|
|
|
assem.clear();
|
|
|
|
lengthChange = 0;
|
|
|
|
};
|
|
|
|
const endDocument = () => {
|
|
|
|
keepAssem.endDocument();
|
|
|
|
};
|
|
|
|
const getLengthChange = () => lengthChange;
|
|
|
|
return {
|
|
|
|
append,
|
|
|
|
toString,
|
|
|
|
clear,
|
|
|
|
endDocument,
|
|
|
|
appendOpWithText,
|
|
|
|
getLengthChange,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
export const mergingOpAssembler = () => {
|
|
|
|
const assem = exports.opAssembler();
|
|
|
|
const bufOp = new Op();
|
|
|
|
// If we get, for example, insertions [xxx\n,yyy], those don't merge,
|
|
|
|
// but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n].
|
|
|
|
// This variable stores the length of yyy and any other newline-less
|
|
|
|
// ops immediately after it.
|
|
|
|
let bufOpAdditionalCharsAfterNewline = 0;
|
|
|
|
/**
|
|
|
|
* @param {boolean} [isEndDocument]
|
|
|
|
*/
|
|
|
|
const flush = (isEndDocument) => {
|
|
|
|
if (!bufOp.opcode)
|
|
|
|
return;
|
|
|
|
if (isEndDocument && bufOp.opcode === '=' && !bufOp.attribs) {
|
|
|
|
// final merged keep, leave it implicit
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
assem.append(bufOp);
|
|
|
|
if (bufOpAdditionalCharsAfterNewline) {
|
|
|
|
bufOp.chars = bufOpAdditionalCharsAfterNewline;
|
|
|
|
bufOp.lines = 0;
|
|
|
|
assem.append(bufOp);
|
|
|
|
bufOpAdditionalCharsAfterNewline = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
bufOp.opcode = '';
|
|
|
|
};
|
|
|
|
const append = (op) => {
|
|
|
|
if (op.chars <= 0)
|
|
|
|
return;
|
|
|
|
if (bufOp.opcode === op.opcode && bufOp.attribs === op.attribs) {
|
|
|
|
if (op.lines > 0) {
|
|
|
|
// bufOp and additional chars are all mergeable into a multi-line op
|
|
|
|
bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars;
|
|
|
|
bufOp.lines += op.lines;
|
|
|
|
bufOpAdditionalCharsAfterNewline = 0;
|
|
|
|
}
|
|
|
|
else if (bufOp.lines === 0) {
|
|
|
|
// both bufOp and op are in-line
|
|
|
|
bufOp.chars += op.chars;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
// append in-line text to multi-line bufOp
|
|
|
|
bufOpAdditionalCharsAfterNewline += op.chars;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
flush();
|
|
|
|
copyOp(op, bufOp);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
const endDocument = () => {
|
|
|
|
flush(true);
|
|
|
|
};
|
|
|
|
const toString = () => {
|
|
|
|
flush();
|
|
|
|
return assem.toString();
|
|
|
|
};
|
|
|
|
const clear = () => {
|
|
|
|
assem.clear();
|
|
|
|
clearOp(bufOp);
|
|
|
|
};
|
|
|
|
return {
|
|
|
|
append,
|
|
|
|
toString,
|
|
|
|
clear,
|
|
|
|
endDocument,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
export const opAssembler = () => {
|
|
|
|
let serialized = '';
|
|
|
|
/**
|
|
|
|
* @param {Op} op - Operation to add. Ownership remains with the caller.
|
|
|
|
*/
|
|
|
|
const append = (op) => {
|
|
|
|
assert(op instanceof Op, 'argument must be an instance of Op');
|
|
|
|
serialized += op.toString();
|
|
|
|
};
|
|
|
|
const toString = () => serialized;
|
|
|
|
const clear = () => {
|
|
|
|
serialized = '';
|
|
|
|
};
|
|
|
|
return {
|
|
|
|
append,
|
|
|
|
toString,
|
|
|
|
clear,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
export const stringIterator = (str) => {
|
|
|
|
let curIndex = 0;
|
|
|
|
// newLines is the number of \n between curIndex and str.length
|
|
|
|
let newLines = str.split('\n').length - 1;
|
|
|
|
const getnewLines = () => newLines;
|
|
|
|
const assertRemaining = (n) => {
|
|
|
|
assert(n <= remaining(), `!(${n} <= ${remaining()})`);
|
|
|
|
};
|
|
|
|
const take = (n) => {
|
|
|
|
assertRemaining(n);
|
|
|
|
const s = str.substr(curIndex, n);
|
|
|
|
newLines -= s.split('\n').length - 1;
|
|
|
|
curIndex += n;
|
|
|
|
return s;
|
|
|
|
};
|
|
|
|
const peek = (n) => {
|
|
|
|
assertRemaining(n);
|
|
|
|
const s = str.substr(curIndex, n);
|
|
|
|
return s;
|
|
|
|
};
|
|
|
|
const skip = (n) => {
|
|
|
|
assertRemaining(n);
|
|
|
|
curIndex += n;
|
|
|
|
};
|
|
|
|
const remaining = () => str.length - curIndex;
|
|
|
|
return {
|
|
|
|
take,
|
|
|
|
skip,
|
|
|
|
remaining,
|
|
|
|
peek,
|
|
|
|
newlines: getnewLines,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
export const stringAssembler = () => ({
|
|
|
|
_str: '',
|
|
|
|
clear() { this._str = ''; },
|
|
|
|
/**
|
|
|
|
* @param {string} x -
|
|
|
|
*/
|
|
|
|
append(x) { this._str += String(x); },
|
|
|
|
toString() { return this._str; },
|
|
|
|
});
|
|
|
|
export const unpack = (cs) => {
|
|
|
|
const headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/;
|
|
|
|
const headerMatch = headerRegex.exec(cs);
|
|
|
|
if ((!headerMatch) || (!headerMatch[0]))
|
|
|
|
error(`Not a changeset: ${cs}`);
|
|
|
|
const oldLen = exports.parseNum(headerMatch[1]);
|
|
|
|
const changeSign = (headerMatch[2] === '>') ? 1 : -1;
|
|
|
|
const changeMag = exports.parseNum(headerMatch[3]);
|
|
|
|
const newLen = oldLen + changeSign * changeMag;
|
|
|
|
const opsStart = headerMatch[0].length;
|
|
|
|
let opsEnd = cs.indexOf('$');
|
|
|
|
if (opsEnd < 0)
|
|
|
|
opsEnd = cs.length;
|
|
|
|
return {
|
|
|
|
oldLen,
|
|
|
|
newLen,
|
|
|
|
ops: cs.substring(opsStart, opsEnd),
|
|
|
|
charBank: cs.substring(opsEnd + 1),
|
|
|
|
};
|
|
|
|
};
|
|
|
|
export const pack = (oldLen, newLen, opsStr, bank) => {
|
|
|
|
const lenDiff = newLen - oldLen;
|
|
|
|
const lenDiffStr = (lenDiff >= 0 ? `>${exports.numToString(lenDiff)}`
|
|
|
|
: `<${exports.numToString(-lenDiff)}`);
|
|
|
|
const a = [];
|
|
|
|
a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank);
|
|
|
|
return a.join('');
|
|
|
|
};
|
|
|
|
export const applyToText = (cs, str) => {
|
|
|
|
const unpacked = exports.unpack(cs);
|
|
|
|
assert(str.length === unpacked.oldLen, `mismatched apply: ${str.length} / ${unpacked.oldLen}`);
|
|
|
|
const bankIter = exports.stringIterator(unpacked.charBank);
|
|
|
|
const strIter = exports.stringIterator(str);
|
|
|
|
const assem = exports.stringAssembler();
|
|
|
|
for (const op of exports.deserializeOps(unpacked.ops)) {
|
|
|
|
switch (op.opcode) {
|
|
|
|
case '+':
|
|
|
|
// op is + and op.lines 0: no newlines must be in op.chars
|
|
|
|
// op is + and op.lines >0: op.chars must include op.lines newlines
|
|
|
|
if (op.lines !== bankIter.peek(op.chars).split('\n').length - 1) {
|
|
|
|
throw new Error(`newline count is wrong in op +; cs:${cs} and text:${str}`);
|
2020-11-23 13:24:19 -05:00
|
|
|
}
|
|
|
|
assem.append(bankIter.take(op.chars));
|
|
|
|
break;
|
|
|
|
case '-':
|
2023-06-23 21:18:12 +02:00
|
|
|
// op is - and op.lines 0: no newlines must be in the deleted string
|
|
|
|
// op is - and op.lines >0: op.lines newlines must be in the deleted string
|
2021-02-17 18:15:01 +00:00
|
|
|
if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) {
|
2020-11-23 13:24:19 -05:00
|
|
|
throw new Error(`newline count is wrong in op -; cs:${cs} and text:${str}`);
|
|
|
|
}
|
|
|
|
strIter.skip(op.chars);
|
|
|
|
break;
|
|
|
|
case '=':
|
2023-06-23 21:18:12 +02:00
|
|
|
// op is = and op.lines 0: no newlines must be in the copied string
|
|
|
|
// op is = and op.lines >0: op.lines newlines must be in the copied string
|
2021-02-17 18:15:01 +00:00
|
|
|
if (op.lines !== strIter.peek(op.chars).split('\n').length - 1) {
|
2020-11-23 13:24:19 -05:00
|
|
|
throw new Error(`newline count is wrong in op =; cs:${cs} and text:${str}`);
|
|
|
|
}
|
|
|
|
assem.append(strIter.take(op.chars));
|
|
|
|
break;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
assem.append(strIter.take(strIter.remaining()));
|
2023-06-23 21:18:12 +02:00
|
|
|
return assem.toString();
|
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const mutateTextLines = (cs, lines) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
const unpacked = exports.unpack(cs);
|
|
|
|
const bankIter = exports.stringIterator(unpacked.charBank);
|
2021-03-21 14:34:02 -04:00
|
|
|
const mut = new TextLinesMutator(lines);
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of exports.deserializeOps(unpacked.ops)) {
|
2011-03-26 13:10:41 +00:00
|
|
|
switch (op.opcode) {
|
2020-11-23 13:24:19 -05:00
|
|
|
case '+':
|
|
|
|
mut.insert(bankIter.take(op.chars), op.lines);
|
|
|
|
break;
|
|
|
|
case '-':
|
|
|
|
mut.remove(op.chars, op.lines);
|
|
|
|
break;
|
|
|
|
case '=':
|
|
|
|
mut.skip(op.chars, op.lines, (!!op.attribs));
|
|
|
|
break;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
mut.close();
|
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const composeAttributes = (att1, att2, resultIsMutation, pool) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
// att1 and att2 are strings like "*3*f*1c", asMutation is a boolean.
|
|
|
|
// Sometimes attribute (key,value) pairs are treated as attribute presence
|
|
|
|
// information, while other times they are treated as operations that
|
|
|
|
// mutate a set of attributes, and this affects whether an empty value
|
|
|
|
// is a deletion or a change.
|
|
|
|
// Examples, of the form (att1Items, att2Items, resultIsMutation) -> result
|
|
|
|
// ([], [(bold, )], true) -> [(bold, )]
|
|
|
|
// ([], [(bold, )], false) -> []
|
|
|
|
// ([], [(bold, true)], true) -> [(bold, true)]
|
|
|
|
// ([], [(bold, true)], false) -> [(bold, true)]
|
|
|
|
// ([(bold, true)], [(bold, )], true) -> [(bold, )]
|
|
|
|
// ([(bold, true)], [(bold, )], false) -> []
|
|
|
|
// pool can be null if att2 has no attributes.
|
|
|
|
if ((!att1) && resultIsMutation) {
|
|
|
|
// In the case of a mutation (i.e. composing two exportss),
|
|
|
|
// an att2 composed with an empy att1 is just att2. If att1
|
|
|
|
// is part of an attribution string, then att2 may remove
|
|
|
|
// attributes that are already gone, so don't do this optimization.
|
|
|
|
return att2;
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!att2)
|
|
|
|
return att1;
|
2021-11-19 00:51:25 -05:00
|
|
|
return AttributeMap.fromString(att1, pool).updateFromString(att2, !resultIsMutation).toString();
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const applyToAttribution = (cs, astr, pool) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
const unpacked = exports.unpack(cs);
|
2021-10-13 17:00:50 -04:00
|
|
|
return applyZip(astr, unpacked.ops, (op1, op2) => slicerZipperFunc(op1, op2, pool));
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const mutateAttributionLines = (cs, lines, pool) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const unpacked = exports.unpack(cs);
|
2021-10-25 05:48:58 -04:00
|
|
|
const csOps = exports.deserializeOps(unpacked.ops);
|
|
|
|
let csOpsNext = csOps.next();
|
2020-11-23 13:24:19 -05:00
|
|
|
const csBank = unpacked.charBank;
|
|
|
|
let csBankIndex = 0;
|
2011-03-26 13:10:41 +00:00
|
|
|
// treat the attribution lines as text lines, mutating a line at a time
|
2021-03-21 14:34:02 -04:00
|
|
|
const mut = new TextLinesMutator(lines);
|
2021-10-20 21:52:37 +02:00
|
|
|
/**
|
|
|
|
* The Ops in the current line from `lines`.
|
|
|
|
*
|
|
|
|
* @type {?Generator<Op>}
|
|
|
|
*/
|
2021-10-25 05:48:58 -04:00
|
|
|
let lineOps = null;
|
|
|
|
let lineOpsNext = null;
|
|
|
|
const lineOpsHasNext = () => lineOpsNext && !lineOpsNext.done;
|
2021-10-20 21:52:37 +02:00
|
|
|
/**
|
|
|
|
* Returns false if we are on the last attribute line in `lines` and there is no additional op in
|
|
|
|
* that line.
|
|
|
|
*
|
|
|
|
* @returns {boolean} True if there are more ops to go through.
|
|
|
|
*/
|
2021-10-25 05:48:58 -04:00
|
|
|
const isNextMutOp = () => lineOpsHasNext() || mut.hasMore();
|
2021-10-20 21:52:37 +02:00
|
|
|
/**
|
|
|
|
* @returns {Op} The next Op from `lineIter`. If there are no more Ops, `lineIter` is reset to
|
|
|
|
* iterate over the next line, which is consumed from `mut`. If there are no more lines,
|
|
|
|
* returns a null Op.
|
|
|
|
*/
|
2021-10-13 17:00:50 -04:00
|
|
|
const nextMutOp = () => {
|
2021-10-25 05:48:58 -04:00
|
|
|
if (!lineOpsHasNext() && mut.hasMore()) {
|
2021-10-20 21:52:37 +02:00
|
|
|
// There are more attribute lines in `lines` to do AND either we just started so `lineIter` is
|
|
|
|
// still null or there are no more ops in current `lineIter`.
|
2020-11-23 13:24:19 -05:00
|
|
|
const line = mut.removeLines(1);
|
2021-10-25 05:48:58 -04:00
|
|
|
lineOps = exports.deserializeOps(line);
|
|
|
|
lineOpsNext = lineOps.next();
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!lineOpsHasNext())
|
|
|
|
return new Op(); // No more ops and no more lines.
|
2021-10-25 05:48:58 -04:00
|
|
|
const op = lineOpsNext.value;
|
|
|
|
lineOpsNext = lineOps.next();
|
|
|
|
return op;
|
2021-02-17 15:23:11 +00:00
|
|
|
};
|
2020-11-23 13:24:19 -05:00
|
|
|
let lineAssem = null;
|
2021-10-20 21:52:37 +02:00
|
|
|
/**
|
|
|
|
* Appends an op to `lineAssem`. In case `lineAssem` includes one single newline, adds it to the
|
|
|
|
* `lines` mutator.
|
|
|
|
*/
|
2021-02-17 15:23:11 +00:00
|
|
|
const outputMutOp = (op) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (!lineAssem) {
|
|
|
|
lineAssem = exports.mergingOpAssembler();
|
|
|
|
}
|
|
|
|
lineAssem.append(op);
|
2023-06-23 21:18:12 +02:00
|
|
|
if (op.lines <= 0)
|
|
|
|
return;
|
2021-09-27 17:25:31 -04:00
|
|
|
assert(op.lines === 1, `Can't have op.lines of ${op.lines} in attribution lines`);
|
|
|
|
// ship it to the mut
|
|
|
|
mut.insert(lineAssem.toString(), 1);
|
|
|
|
lineAssem = null;
|
2021-02-17 15:23:11 +00:00
|
|
|
};
|
2021-10-25 01:21:19 -04:00
|
|
|
let csOp = new Op();
|
|
|
|
let attOp = new Op();
|
2021-10-25 05:48:58 -04:00
|
|
|
while (csOp.opcode || !csOpsNext.done || attOp.opcode || isNextMutOp()) {
|
|
|
|
if (!csOp.opcode && !csOpsNext.done) {
|
2021-10-20 21:52:37 +02:00
|
|
|
// coOp done, but more ops in cs.
|
2021-10-25 05:48:58 -04:00
|
|
|
csOp = csOpsNext.value;
|
|
|
|
csOpsNext = csOps.next();
|
|
|
|
}
|
|
|
|
if (!csOp.opcode && !attOp.opcode && !lineAssem && !lineOpsHasNext()) {
|
2011-03-26 13:10:41 +00:00
|
|
|
break; // done
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (csOp.opcode === '=' && csOp.lines > 0 && !csOp.attribs && !attOp.opcode &&
|
|
|
|
!lineAssem && !lineOpsHasNext()) {
|
2021-10-20 21:52:37 +02:00
|
|
|
// Skip multiple lines without attributes; this is what makes small changes not order of the
|
|
|
|
// document size.
|
2011-03-26 13:10:41 +00:00
|
|
|
mut.skipLines(csOp.lines);
|
|
|
|
csOp.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (csOp.opcode === '+') {
|
2021-10-13 17:00:50 -04:00
|
|
|
const opOut = copyOp(csOp);
|
2011-03-26 13:10:41 +00:00
|
|
|
if (csOp.lines > 1) {
|
2021-10-20 21:52:37 +02:00
|
|
|
// Copy the first line from `csOp` to `opOut`.
|
2020-11-23 13:24:19 -05:00
|
|
|
const firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex;
|
2011-03-26 13:10:41 +00:00
|
|
|
csOp.chars -= firstLineLen;
|
|
|
|
csOp.lines--;
|
|
|
|
opOut.lines = 1;
|
|
|
|
opOut.chars = firstLineLen;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-10-20 21:52:37 +02:00
|
|
|
// Either one or no newlines in '+' `csOp`, copy to `opOut` and reset `csOp`.
|
2011-03-26 13:10:41 +00:00
|
|
|
csOp.opcode = '';
|
|
|
|
}
|
|
|
|
outputMutOp(opOut);
|
|
|
|
csBankIndex += opOut.chars;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
|
|
|
if (!attOp.opcode && isNextMutOp())
|
|
|
|
attOp = nextMutOp();
|
2021-10-13 17:00:50 -04:00
|
|
|
const opOut = slicerZipperFunc(attOp, csOp, pool);
|
2023-06-23 21:18:12 +02:00
|
|
|
if (opOut.opcode)
|
|
|
|
outputMutOp(opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
2021-03-21 14:13:50 -04:00
|
|
|
assert(!lineAssem, `line assembler not finished:${cs}`);
|
2011-03-26 13:10:41 +00:00
|
|
|
mut.close();
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const joinAttributionLines = (theAlines) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.mergingOpAssembler();
|
2021-10-11 18:14:01 -04:00
|
|
|
for (const aline of theAlines) {
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of exports.deserializeOps(aline))
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
return assem.toString();
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const splitAttributionLines = (attrOps, text) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.mergingOpAssembler();
|
|
|
|
const lines = [];
|
|
|
|
let pos = 0;
|
2021-02-17 15:23:11 +00:00
|
|
|
const appendOp = (op) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
assem.append(op);
|
|
|
|
if (op.lines > 0) {
|
|
|
|
lines.push(assem.toString());
|
|
|
|
assem.clear();
|
|
|
|
}
|
|
|
|
pos += op.chars;
|
2021-02-17 15:23:11 +00:00
|
|
|
};
|
2021-10-25 05:48:58 -04:00
|
|
|
for (const op of exports.deserializeOps(attrOps)) {
|
2020-11-23 13:24:19 -05:00
|
|
|
let numChars = op.chars;
|
|
|
|
let numLines = op.lines;
|
2011-03-26 13:10:41 +00:00
|
|
|
while (numLines > 1) {
|
2020-11-23 13:24:19 -05:00
|
|
|
const newlineEnd = text.indexOf('\n', pos) + 1;
|
2021-03-21 14:13:50 -04:00
|
|
|
assert(newlineEnd > 0, 'newlineEnd <= 0 in splitAttributionLines');
|
2011-03-26 13:10:41 +00:00
|
|
|
op.chars = newlineEnd - pos;
|
|
|
|
op.lines = 1;
|
|
|
|
appendOp(op);
|
|
|
|
numChars -= op.chars;
|
|
|
|
numLines -= op.lines;
|
|
|
|
}
|
2021-02-17 18:15:01 +00:00
|
|
|
if (numLines === 1) {
|
2011-03-26 13:10:41 +00:00
|
|
|
op.chars = numChars;
|
|
|
|
op.lines = 1;
|
|
|
|
}
|
|
|
|
appendOp(op);
|
|
|
|
}
|
|
|
|
return lines;
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const splitTextLines = (text) => text.match(/[^\n]*(?:\n|[^\n]$)/g);
|
|
|
|
export const compose = (cs1, cs2, pool) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const unpacked1 = exports.unpack(cs1);
|
|
|
|
const unpacked2 = exports.unpack(cs2);
|
|
|
|
const len1 = unpacked1.oldLen;
|
|
|
|
const len2 = unpacked1.newLen;
|
2021-03-21 14:13:50 -04:00
|
|
|
assert(len2 === unpacked2.oldLen, 'mismatched composition of two changesets');
|
2020-11-23 13:24:19 -05:00
|
|
|
const len3 = unpacked2.newLen;
|
|
|
|
const bankIter1 = exports.stringIterator(unpacked1.charBank);
|
|
|
|
const bankIter2 = exports.stringIterator(unpacked2.charBank);
|
|
|
|
const bankAssem = exports.stringAssembler();
|
2021-10-13 17:00:50 -04:00
|
|
|
const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const op1code = op1.opcode;
|
|
|
|
const op2code = op2.opcode;
|
2021-02-17 15:53:42 +00:00
|
|
|
if (op1code === '+' && op2code === '-') {
|
2011-03-26 13:10:41 +00:00
|
|
|
bankIter1.skip(Math.min(op1.chars, op2.chars));
|
|
|
|
}
|
2021-10-13 17:00:50 -04:00
|
|
|
const opOut = slicerZipperFunc(op1, op2, pool);
|
2021-02-17 15:53:42 +00:00
|
|
|
if (opOut.opcode === '+') {
|
|
|
|
if (op2code === '+') {
|
2011-03-26 13:10:41 +00:00
|
|
|
bankAssem.append(bankIter2.take(opOut.chars));
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
bankAssem.append(bankIter1.take(opOut.chars));
|
|
|
|
}
|
|
|
|
}
|
2021-10-13 17:00:50 -04:00
|
|
|
return opOut;
|
2011-03-26 13:10:41 +00:00
|
|
|
});
|
|
|
|
return exports.pack(len1, len3, newOps, bankAssem.toString());
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const attributeTester = (attribPair, pool) => {
|
2021-02-17 15:23:11 +00:00
|
|
|
const never = (attribs) => false;
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!pool)
|
|
|
|
return never;
|
2020-11-23 13:24:19 -05:00
|
|
|
const attribNum = pool.putAttrib(attribPair, true);
|
2023-06-23 21:18:12 +02:00
|
|
|
if (attribNum < 0)
|
|
|
|
return never;
|
2021-09-27 17:25:31 -04:00
|
|
|
const re = new RegExp(`\\*${exports.numToString(attribNum)}(?!\\w)`);
|
|
|
|
return (attribs) => re.test(attribs);
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const identity = (N) => exports.pack(N, N, '', '');
|
|
|
|
export const makeSplice = (orig, start, ndel, ins, attribs, pool) => {
|
|
|
|
if (start < 0)
|
|
|
|
throw new RangeError(`start index must be non-negative (is ${start})`);
|
|
|
|
if (ndel < 0)
|
|
|
|
throw new RangeError(`characters to delete must be non-negative (is ${ndel})`);
|
|
|
|
if (start > orig.length)
|
|
|
|
start = orig.length;
|
|
|
|
if (ndel > orig.length - start)
|
|
|
|
ndel = orig.length - start;
|
2021-12-12 19:36:08 -05:00
|
|
|
const deleted = orig.substring(start, start + ndel);
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.smartOpAssembler();
|
2021-10-13 00:19:09 -04:00
|
|
|
const ops = (function* () {
|
2021-12-12 19:36:08 -05:00
|
|
|
yield* opsFromText('=', orig.substring(0, start));
|
|
|
|
yield* opsFromText('-', deleted);
|
|
|
|
yield* opsFromText('+', ins, attribs, pool);
|
2021-10-13 00:19:09 -04:00
|
|
|
})();
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of ops)
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
assem.endDocument();
|
2021-12-12 19:36:08 -05:00
|
|
|
return exports.pack(orig.length, orig.length + ins.length - ndel, assem.toString(), ins);
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const characterRangeFollow = (cs, startChar, endChar, insertionsAfter) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
let newStartChar = startChar;
|
|
|
|
let newEndChar = endChar;
|
|
|
|
let lengthChangeSoFar = 0;
|
2021-10-11 18:14:01 -04:00
|
|
|
for (const splice of toSplices(cs)) {
|
2020-11-23 13:24:19 -05:00
|
|
|
const spliceStart = splice[0] + lengthChangeSoFar;
|
|
|
|
const spliceEnd = splice[1] + lengthChangeSoFar;
|
|
|
|
const newTextLength = splice[2].length;
|
|
|
|
const thisLengthChange = newTextLength - (spliceEnd - spliceStart);
|
2011-03-26 13:10:41 +00:00
|
|
|
if (spliceStart <= newStartChar && spliceEnd >= newEndChar) {
|
|
|
|
// splice fully replaces/deletes range
|
|
|
|
// (also case that handles insertion at a collapsed selection)
|
|
|
|
if (insertionsAfter) {
|
|
|
|
newStartChar = newEndChar = spliceStart;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
newStartChar = newEndChar = spliceStart + newTextLength;
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (spliceEnd <= newStartChar) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// splice is before range
|
|
|
|
newStartChar += thisLengthChange;
|
|
|
|
newEndChar += thisLengthChange;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (spliceStart >= newEndChar) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// splice is after range
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// splice is inside range
|
|
|
|
newEndChar += thisLengthChange;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (spliceEnd < newEndChar) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// splice overlaps beginning of range
|
|
|
|
newStartChar = spliceStart + newTextLength;
|
|
|
|
newEndChar += thisLengthChange;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
// splice overlaps end of range
|
|
|
|
newEndChar = spliceStart;
|
|
|
|
}
|
|
|
|
lengthChangeSoFar += thisLengthChange;
|
|
|
|
}
|
|
|
|
return [newStartChar, newEndChar];
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const moveOpsToNewPool = (cs, oldPool, newPool) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
// works on exports or attribution string
|
2020-11-23 13:24:19 -05:00
|
|
|
let dollarPos = cs.indexOf('$');
|
2011-03-26 13:10:41 +00:00
|
|
|
if (dollarPos < 0) {
|
|
|
|
dollarPos = cs.length;
|
|
|
|
}
|
2020-11-23 13:24:19 -05:00
|
|
|
const upToDollar = cs.substring(0, dollarPos);
|
|
|
|
const fromDollar = cs.substring(dollarPos);
|
2011-03-26 13:10:41 +00:00
|
|
|
// order of attribs stays the same
|
2020-11-23 13:24:19 -05:00
|
|
|
return upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => {
|
|
|
|
const oldNum = exports.parseNum(a);
|
2021-10-25 05:19:35 -04:00
|
|
|
const pair = oldPool.getAttrib(oldNum);
|
|
|
|
// The attribute might not be in the old pool if the user is viewing the current revision in the
|
|
|
|
// timeslider and text is deleted. See: https://github.com/ether/etherpad-lite/issues/3932
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!pair)
|
|
|
|
return '';
|
2020-11-23 13:24:19 -05:00
|
|
|
const newNum = newPool.putAttrib(pair);
|
|
|
|
return `*${exports.numToString(newNum)}`;
|
2011-03-26 13:10:41 +00:00
|
|
|
}) + fromDollar;
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const makeAttribution = (text) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.smartOpAssembler();
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of opsFromText('+', text))
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
return assem.toString();
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const eachAttribNumber = (cs, func) => {
|
|
|
|
padutils.warnDeprecated('Changeset.eachAttribNumber() is deprecated; use attributes.decodeAttribString() instead');
|
2020-11-23 13:24:19 -05:00
|
|
|
let dollarPos = cs.indexOf('$');
|
2011-03-26 13:10:41 +00:00
|
|
|
if (dollarPos < 0) {
|
|
|
|
dollarPos = cs.length;
|
|
|
|
}
|
2020-11-23 13:24:19 -05:00
|
|
|
const upToDollar = cs.substring(0, dollarPos);
|
2021-11-19 00:51:25 -05:00
|
|
|
// WARNING: The following cannot be replaced with a call to `attributes.decodeAttribString()`
|
|
|
|
// because that function only works on attribute strings, not serialized operations or changesets.
|
2020-11-23 13:24:19 -05:00
|
|
|
upToDollar.replace(/\*([0-9a-z]+)/g, (_, a) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
func(exports.parseNum(a));
|
|
|
|
return '';
|
|
|
|
});
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const filterAttribNumbers = (cs, filter) => exports.mapAttribNumbers(cs, filter);
|
|
|
|
export const mapAttribNumbers = (cs, func) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
let dollarPos = cs.indexOf('$');
|
2011-03-26 13:10:41 +00:00
|
|
|
if (dollarPos < 0) {
|
|
|
|
dollarPos = cs.length;
|
|
|
|
}
|
2020-11-23 13:24:19 -05:00
|
|
|
const upToDollar = cs.substring(0, dollarPos);
|
|
|
|
const newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, (s, a) => {
|
|
|
|
const n = func(exports.parseNum(a));
|
2011-03-26 13:10:41 +00:00
|
|
|
if (n === true) {
|
|
|
|
return s;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if ((typeof n) === 'number') {
|
2020-11-23 13:24:19 -05:00
|
|
|
return `*${exports.numToString(n)}`;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
return '';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
return newUpToDollar + cs.substring(dollarPos);
|
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const makeAText = (text, attribs) => ({
|
2021-02-17 15:35:53 +00:00
|
|
|
text,
|
|
|
|
attribs: (attribs || exports.makeAttribution(text)),
|
|
|
|
});
|
2023-06-23 20:53:55 +02:00
|
|
|
export const applyToAText = (cs, atext, pool) => ({
|
2021-02-17 15:35:53 +00:00
|
|
|
text: exports.applyToText(cs, atext.text),
|
|
|
|
attribs: exports.applyToAttribution(cs, atext.attribs, pool),
|
|
|
|
});
|
2023-06-23 21:18:12 +02:00
|
|
|
export const cloneAText = (atext) => {
|
|
|
|
if (!atext)
|
|
|
|
error('atext is null');
|
2021-09-27 17:25:31 -04:00
|
|
|
return {
|
|
|
|
text: atext.text,
|
|
|
|
attribs: atext.attribs,
|
|
|
|
};
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const copyAText = (atext1, atext2) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
atext2.text = atext1.text;
|
|
|
|
atext2.attribs = atext1.attribs;
|
|
|
|
};
|
2023-06-23 20:53:55 +02:00
|
|
|
export const opsFromAText = function* (atext) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// intentionally skips last newline char of atext
|
2021-03-21 19:48:30 -04:00
|
|
|
let lastOp = null;
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of exports.deserializeOps(atext.attribs)) {
|
|
|
|
if (lastOp != null)
|
|
|
|
yield lastOp;
|
2021-10-25 05:48:58 -04:00
|
|
|
lastOp = op;
|
2021-03-21 19:48:30 -04:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
if (lastOp == null)
|
|
|
|
return;
|
2021-03-21 19:48:30 -04:00
|
|
|
// exclude final newline
|
|
|
|
if (lastOp.lines <= 1) {
|
|
|
|
lastOp.lines = 0;
|
|
|
|
lastOp.chars--;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-03-21 19:48:30 -04:00
|
|
|
const nextToLastNewlineEnd = atext.text.lastIndexOf('\n', atext.text.length - 2) + 1;
|
|
|
|
const lastLineLength = atext.text.length - nextToLastNewlineEnd - 1;
|
|
|
|
lastOp.lines--;
|
|
|
|
lastOp.chars -= (lastLineLength + 1);
|
2021-10-25 01:16:46 -04:00
|
|
|
yield copyOp(lastOp);
|
2021-03-21 19:48:30 -04:00
|
|
|
lastOp.lines = 0;
|
|
|
|
lastOp.chars = lastLineLength;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
if (lastOp.chars)
|
|
|
|
yield lastOp;
|
2021-10-25 01:16:46 -04:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const appendATextToAssembler = (atext, assem) => {
|
|
|
|
padutils.warnDeprecated('Changeset.appendATextToAssembler() is deprecated; use Changeset.opsFromAText() instead');
|
|
|
|
for (const op of exports.opsFromAText(atext))
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const prepareForWire = (cs, pool) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const newPool = new AttributePool();
|
|
|
|
const newCs = exports.moveOpsToNewPool(cs, pool, newPool);
|
2011-03-26 13:10:41 +00:00
|
|
|
return {
|
|
|
|
translated: newCs,
|
2020-11-23 13:24:19 -05:00
|
|
|
pool: newPool,
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const isIdentity = (cs) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const unpacked = exports.unpack(cs);
|
2021-02-17 18:15:01 +00:00
|
|
|
return unpacked.ops === '' && unpacked.oldLen === unpacked.newLen;
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const opAttributeValue = (op, key, pool) => {
|
|
|
|
padutils.warnDeprecated('Changeset.opAttributeValue() is deprecated; use an AttributeMap instead');
|
2021-11-19 00:51:25 -05:00
|
|
|
return attribsAttributeValue(op.attribs, key, pool);
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
const attribsAttributeValue$0 = (attribs, key, pool) => {
|
|
|
|
padutils.warnDeprecated('Changeset.attribsAttributeValue() is deprecated; use an AttributeMap instead');
|
2021-11-19 00:51:25 -05:00
|
|
|
return attribsAttributeValue(attribs, key, pool);
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const builder = (oldLen) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.smartOpAssembler();
|
2021-10-25 01:21:19 -04:00
|
|
|
const o = new Op();
|
2020-11-23 13:24:19 -05:00
|
|
|
const charBank = exports.stringAssembler();
|
2021-02-17 16:15:36 +00:00
|
|
|
const self = {
|
2021-10-17 20:14:34 +02:00
|
|
|
/**
|
2021-10-25 02:40:01 -04:00
|
|
|
* @param {number} N - Number of characters to keep.
|
|
|
|
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
|
|
|
|
* character must be a newline.
|
|
|
|
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
|
|
|
|
* (no pool needed in latter case).
|
|
|
|
* @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of
|
|
|
|
* attribute key, value pairs.
|
|
|
|
* @returns {Builder} this
|
2021-10-17 20:14:34 +02:00
|
|
|
*/
|
2021-02-17 15:35:53 +00:00
|
|
|
keep: (N, L, attribs, pool) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
o.opcode = '=';
|
2021-11-19 00:51:25 -05:00
|
|
|
o.attribs = typeof attribs === 'string'
|
2023-06-23 21:18:12 +02:00
|
|
|
? attribs : new AttributeMap(pool).update(attribs || []).toString();
|
2011-03-26 13:10:41 +00:00
|
|
|
o.chars = N;
|
|
|
|
o.lines = (L || 0);
|
|
|
|
assem.append(o);
|
|
|
|
return self;
|
|
|
|
},
|
2021-10-25 02:40:01 -04:00
|
|
|
/**
|
|
|
|
* @param {string} text - Text to keep.
|
|
|
|
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
|
|
|
|
* (no pool needed in latter case).
|
|
|
|
* @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of
|
|
|
|
* attribute key, value pairs.
|
|
|
|
* @returns {Builder} this
|
|
|
|
*/
|
2021-02-17 15:23:11 +00:00
|
|
|
keepText: (text, attribs, pool) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of opsFromText('=', text, attribs, pool))
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
return self;
|
|
|
|
},
|
2021-10-25 02:40:01 -04:00
|
|
|
/**
|
|
|
|
* @param {string} text - Text to insert.
|
|
|
|
* @param {(string|Attribute[])} attribs - Either [[key1,value1],[key2,value2],...] or '*0*1...'
|
|
|
|
* (no pool needed in latter case).
|
|
|
|
* @param {?AttributePool} pool - Attribute pool, only required if `attribs` is a list of
|
|
|
|
* attribute key, value pairs.
|
|
|
|
* @returns {Builder} this
|
|
|
|
*/
|
2021-02-17 15:35:53 +00:00
|
|
|
insert: (text, attribs, pool) => {
|
2023-06-23 21:18:12 +02:00
|
|
|
for (const op of opsFromText('+', text, attribs, pool))
|
|
|
|
assem.append(op);
|
2011-03-26 13:10:41 +00:00
|
|
|
charBank.append(text);
|
|
|
|
return self;
|
|
|
|
},
|
2021-10-25 02:40:01 -04:00
|
|
|
/**
|
|
|
|
* @param {number} N - Number of characters to remove.
|
|
|
|
* @param {number} L - Number of newlines among the `N` characters. If positive, the last
|
|
|
|
* character must be a newline.
|
|
|
|
* @returns {Builder} this
|
|
|
|
*/
|
2021-02-17 15:35:53 +00:00
|
|
|
remove: (N, L) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
o.opcode = '-';
|
|
|
|
o.attribs = '';
|
|
|
|
o.chars = N;
|
|
|
|
o.lines = (L || 0);
|
|
|
|
assem.append(o);
|
|
|
|
return self;
|
|
|
|
},
|
2021-02-17 15:35:53 +00:00
|
|
|
toString: () => {
|
2011-03-26 13:10:41 +00:00
|
|
|
assem.endDocument();
|
2020-11-23 13:24:19 -05:00
|
|
|
const newLen = oldLen + assem.getLengthChange();
|
2011-03-26 13:10:41 +00:00
|
|
|
return exports.pack(oldLen, newLen, assem.toString(), charBank.toString());
|
2020-11-23 13:24:19 -05:00
|
|
|
},
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
|
|
|
return self;
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const makeAttribsString = (opcode, attribs, pool) => {
|
|
|
|
padutils.warnDeprecated('Changeset.makeAttribsString() is deprecated; ' +
|
2021-11-19 00:51:25 -05:00
|
|
|
'use AttributeMap.prototype.toString() or attributes.attribsToString() instead');
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!attribs || !['=', '+'].includes(opcode))
|
|
|
|
return '';
|
|
|
|
if (typeof attribs === 'string')
|
|
|
|
return attribs;
|
2021-11-19 00:51:25 -05:00
|
|
|
return new AttributeMap(pool).update(attribs, opcode === '+').toString();
|
2011-03-26 13:10:41 +00:00
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const subattribution = (astr, start, optEnd) => {
|
2021-10-25 05:48:58 -04:00
|
|
|
const attOps = exports.deserializeOps(astr);
|
|
|
|
let attOpsNext = attOps.next();
|
2020-11-23 13:24:19 -05:00
|
|
|
const assem = exports.smartOpAssembler();
|
2021-10-25 01:21:19 -04:00
|
|
|
let attOp = new Op();
|
|
|
|
const csOp = new Op();
|
2021-02-17 15:23:11 +00:00
|
|
|
const doCsOp = () => {
|
2023-06-23 21:18:12 +02:00
|
|
|
if (!csOp.chars)
|
|
|
|
return;
|
2021-10-25 05:48:58 -04:00
|
|
|
while (csOp.opcode && (attOp.opcode || !attOpsNext.done)) {
|
|
|
|
if (!attOp.opcode) {
|
|
|
|
attOp = attOpsNext.value;
|
|
|
|
attOpsNext = attOps.next();
|
|
|
|
}
|
2021-09-27 17:25:31 -04:00
|
|
|
if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars &&
|
|
|
|
attOp.lines > 0 && csOp.lines <= 0) {
|
|
|
|
csOp.lines++;
|
|
|
|
}
|
2021-10-13 17:00:50 -04:00
|
|
|
const opOut = slicerZipperFunc(attOp, csOp, null);
|
2023-06-23 21:18:12 +02:00
|
|
|
if (opOut.opcode)
|
|
|
|
assem.append(opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2011-03-26 13:10:41 +00:00
|
|
|
csOp.opcode = '-';
|
|
|
|
csOp.chars = start;
|
|
|
|
doCsOp();
|
|
|
|
if (optEnd === undefined) {
|
|
|
|
if (attOp.opcode) {
|
|
|
|
assem.append(attOp);
|
|
|
|
}
|
2021-10-25 05:48:58 -04:00
|
|
|
while (!attOpsNext.done) {
|
|
|
|
assem.append(attOpsNext.value);
|
|
|
|
attOpsNext = attOps.next();
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
csOp.opcode = '=';
|
|
|
|
csOp.chars = optEnd - start;
|
|
|
|
doCsOp();
|
|
|
|
}
|
|
|
|
return assem.toString();
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const inverse = (cs, lines, alines, pool) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
// lines and alines are what the exports is meant to apply to.
|
|
|
|
// They may be arrays or objects with .get(i) and .length methods.
|
|
|
|
// They include final newlines on lines.
|
2021-03-04 21:41:29 -05:00
|
|
|
const linesGet = (idx) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (lines.get) {
|
|
|
|
return lines.get(idx);
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
return lines[idx];
|
|
|
|
}
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2021-10-17 20:14:34 +02:00
|
|
|
/**
|
|
|
|
* @param {number} idx -
|
|
|
|
* @returns {string}
|
|
|
|
*/
|
2021-03-04 21:41:29 -05:00
|
|
|
const alinesGet = (idx) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (alines.get) {
|
|
|
|
return alines.get(idx);
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
return alines[idx];
|
|
|
|
}
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2020-11-23 13:24:19 -05:00
|
|
|
let curLine = 0;
|
|
|
|
let curChar = 0;
|
2021-10-25 05:48:58 -04:00
|
|
|
let curLineOps = null;
|
|
|
|
let curLineOpsNext = null;
|
|
|
|
let curLineOpsLine;
|
2021-10-25 01:21:19 -04:00
|
|
|
let curLineNextOp = new Op('+');
|
2020-11-23 13:24:19 -05:00
|
|
|
const unpacked = exports.unpack(cs);
|
|
|
|
const builder = exports.builder(unpacked.newLen);
|
2021-02-17 15:23:11 +00:00
|
|
|
const consumeAttribRuns = (numChars, func /* (len, attribs, endsLine)*/) => {
|
2021-10-25 05:48:58 -04:00
|
|
|
if (!curLineOps || curLineOpsLine !== curLine) {
|
|
|
|
curLineOps = exports.deserializeOps(alinesGet(curLine));
|
|
|
|
curLineOpsNext = curLineOps.next();
|
|
|
|
curLineOpsLine = curLine;
|
2020-11-23 13:24:19 -05:00
|
|
|
let indexIntoLine = 0;
|
2021-10-25 05:48:58 -04:00
|
|
|
while (!curLineOpsNext.done) {
|
|
|
|
curLineNextOp = curLineOpsNext.value;
|
|
|
|
curLineOpsNext = curLineOps.next();
|
2011-03-26 13:10:41 +00:00
|
|
|
if (indexIntoLine + curLineNextOp.chars >= curChar) {
|
|
|
|
curLineNextOp.chars -= (curChar - indexIntoLine);
|
2021-11-08 23:35:03 -05:00
|
|
|
break;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-11-08 23:35:03 -05:00
|
|
|
indexIntoLine += curLineNextOp.chars;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
while (numChars > 0) {
|
2021-10-25 05:48:58 -04:00
|
|
|
if (!curLineNextOp.chars && curLineOpsNext.done) {
|
2011-03-26 13:10:41 +00:00
|
|
|
curLine++;
|
|
|
|
curChar = 0;
|
2021-10-25 05:48:58 -04:00
|
|
|
curLineOpsLine = curLine;
|
2011-03-26 13:10:41 +00:00
|
|
|
curLineNextOp.chars = 0;
|
2021-10-25 05:48:58 -04:00
|
|
|
curLineOps = exports.deserializeOps(alinesGet(curLine));
|
|
|
|
curLineOpsNext = curLineOps.next();
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-09-30 13:39:02 -04:00
|
|
|
if (!curLineNextOp.chars) {
|
2021-10-25 05:48:58 -04:00
|
|
|
if (curLineOpsNext.done) {
|
|
|
|
curLineNextOp = new Op();
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2021-10-25 05:48:58 -04:00
|
|
|
curLineNextOp = curLineOpsNext.value;
|
|
|
|
curLineOpsNext = curLineOps.next();
|
|
|
|
}
|
2021-09-30 13:39:02 -04:00
|
|
|
}
|
2020-11-23 13:24:19 -05:00
|
|
|
const charsToUse = Math.min(numChars, curLineNextOp.chars);
|
2021-02-17 18:15:01 +00:00
|
|
|
func(charsToUse, curLineNextOp.attribs, charsToUse === curLineNextOp.chars &&
|
2021-02-17 15:59:52 +00:00
|
|
|
curLineNextOp.lines > 0);
|
2011-03-26 13:10:41 +00:00
|
|
|
numChars -= charsToUse;
|
|
|
|
curLineNextOp.chars -= charsToUse;
|
|
|
|
curChar += charsToUse;
|
|
|
|
}
|
2021-10-25 05:48:58 -04:00
|
|
|
if (!curLineNextOp.chars && curLineOpsNext.done) {
|
2011-03-26 13:10:41 +00:00
|
|
|
curLine++;
|
|
|
|
curChar = 0;
|
|
|
|
}
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2021-02-17 15:23:11 +00:00
|
|
|
const skip = (N, L) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (L) {
|
|
|
|
curLine += L;
|
|
|
|
curChar = 0;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (curLineOps && curLineOpsLine === curLine) {
|
|
|
|
consumeAttribRuns(N, () => { });
|
|
|
|
}
|
|
|
|
else {
|
2020-11-23 13:24:19 -05:00
|
|
|
curChar += N;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-02-17 15:23:11 +00:00
|
|
|
};
|
|
|
|
const nextText = (numChars) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
let len = 0;
|
|
|
|
const assem = exports.stringAssembler();
|
2021-03-04 21:41:29 -05:00
|
|
|
const firstString = linesGet(curLine).substring(curChar);
|
2011-03-26 13:10:41 +00:00
|
|
|
len += firstString.length;
|
|
|
|
assem.append(firstString);
|
2020-11-23 13:24:19 -05:00
|
|
|
let lineNum = curLine + 1;
|
2011-03-26 13:10:41 +00:00
|
|
|
while (len < numChars) {
|
2021-03-04 21:41:29 -05:00
|
|
|
const nextString = linesGet(lineNum);
|
2011-03-26 13:10:41 +00:00
|
|
|
len += nextString.length;
|
|
|
|
assem.append(nextString);
|
|
|
|
lineNum++;
|
|
|
|
}
|
|
|
|
return assem.toString().substring(0, numChars);
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2021-02-17 15:23:11 +00:00
|
|
|
const cachedStrFunc = (func) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const cache = {};
|
2021-02-17 15:35:53 +00:00
|
|
|
return (s) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (!cache[s]) {
|
|
|
|
cache[s] = func(s);
|
|
|
|
}
|
|
|
|
return cache[s];
|
|
|
|
};
|
2021-02-17 15:35:53 +00:00
|
|
|
};
|
2021-10-25 05:48:58 -04:00
|
|
|
for (const csOp of exports.deserializeOps(unpacked.ops)) {
|
2021-02-17 15:53:42 +00:00
|
|
|
if (csOp.opcode === '=') {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (csOp.attribs) {
|
2021-11-19 00:51:25 -05:00
|
|
|
const attribs = AttributeMap.fromString(csOp.attribs, pool);
|
|
|
|
const undoBackToAttribs = cachedStrFunc((oldAttribsStr) => {
|
|
|
|
const oldAttribs = AttributeMap.fromString(oldAttribsStr, pool);
|
|
|
|
const backAttribs = new AttributeMap(pool);
|
|
|
|
for (const [key, value] of attribs) {
|
|
|
|
const oldValue = oldAttribs.get(key) || '';
|
2023-06-23 21:18:12 +02:00
|
|
|
if (oldValue !== value)
|
|
|
|
backAttribs.set(key, oldValue);
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-11-19 01:10:04 -05:00
|
|
|
// TODO: backAttribs does not restore removed attributes (it is missing attributes that
|
|
|
|
// are in oldAttribs but not in attribs). I don't know if that is intentional.
|
2021-11-19 00:51:25 -05:00
|
|
|
return backAttribs.toString();
|
2011-03-26 13:10:41 +00:00
|
|
|
});
|
2020-11-23 13:24:19 -05:00
|
|
|
consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs));
|
|
|
|
});
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
skip(csOp.chars, csOp.lines);
|
|
|
|
builder.keep(csOp.chars, csOp.lines);
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (csOp.opcode === '+') {
|
2011-03-26 13:10:41 +00:00
|
|
|
builder.remove(csOp.chars, csOp.lines);
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (csOp.opcode === '-') {
|
2021-02-17 16:15:36 +00:00
|
|
|
const textBank = nextText(csOp.chars);
|
|
|
|
let textBankIndex = 0;
|
2020-11-23 13:24:19 -05:00
|
|
|
consumeAttribRuns(csOp.chars, (len, attribs, endsLine) => {
|
2011-03-26 13:10:41 +00:00
|
|
|
builder.insert(textBank.substr(textBankIndex, len), attribs);
|
|
|
|
textBankIndex += len;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return exports.checkRep(builder.toString());
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const follow = (cs1, cs2, reverseInsertOrder, pool) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const unpacked1 = exports.unpack(cs1);
|
|
|
|
const unpacked2 = exports.unpack(cs2);
|
|
|
|
const len1 = unpacked1.oldLen;
|
|
|
|
const len2 = unpacked2.oldLen;
|
2021-03-21 14:13:50 -04:00
|
|
|
assert(len1 === len2, 'mismatched follow - cannot transform cs1 on top of cs2');
|
2020-11-23 13:24:19 -05:00
|
|
|
const chars1 = exports.stringIterator(unpacked1.charBank);
|
|
|
|
const chars2 = exports.stringIterator(unpacked2.charBank);
|
|
|
|
const oldLen = unpacked1.newLen;
|
|
|
|
let oldPos = 0;
|
|
|
|
let newLen = 0;
|
|
|
|
const hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool);
|
2021-10-13 17:00:50 -04:00
|
|
|
const newOps = applyZip(unpacked1.ops, unpacked2.ops, (op1, op2) => {
|
2021-10-25 01:21:19 -04:00
|
|
|
const opOut = new Op();
|
2021-02-17 15:53:42 +00:00
|
|
|
if (op1.opcode === '+' || op2.opcode === '+') {
|
2020-11-23 13:24:19 -05:00
|
|
|
let whichToDo;
|
2021-02-17 15:53:42 +00:00
|
|
|
if (op2.opcode !== '+') {
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 1;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (op1.opcode !== '+') {
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 2;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
// both +
|
2020-11-23 13:24:19 -05:00
|
|
|
const firstChar1 = chars1.peek(1);
|
|
|
|
const firstChar2 = chars2.peek(1);
|
|
|
|
const insertFirst1 = hasInsertFirst(op1.attribs);
|
|
|
|
const insertFirst2 = hasInsertFirst(op2.attribs);
|
2011-03-26 13:10:41 +00:00
|
|
|
if (insertFirst1 && !insertFirst2) {
|
|
|
|
whichToDo = 1;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (insertFirst2 && !insertFirst1) {
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 2;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (firstChar1 === '\n' && firstChar2 !== '\n') {
|
2021-02-17 16:02:10 +00:00
|
|
|
// insert string that doesn't start with a newline first so as not to break up lines
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 2;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (firstChar1 !== '\n' && firstChar2 === '\n') {
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 1;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (reverseInsertOrder) {
|
2021-02-17 16:02:10 +00:00
|
|
|
// break symmetry:
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 2;
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
whichToDo = 1;
|
|
|
|
}
|
|
|
|
}
|
2021-02-17 16:02:10 +00:00
|
|
|
if (whichToDo === 1) {
|
2011-03-26 13:10:41 +00:00
|
|
|
chars1.skip(op1.chars);
|
|
|
|
opOut.opcode = '=';
|
|
|
|
opOut.lines = op1.lines;
|
|
|
|
opOut.chars = op1.chars;
|
|
|
|
opOut.attribs = '';
|
|
|
|
op1.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
// whichToDo == 2
|
|
|
|
chars2.skip(op2.chars);
|
2021-03-21 14:13:50 -04:00
|
|
|
copyOp(op2, opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
op2.opcode = '';
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (op1.opcode === '-') {
|
2011-03-26 13:10:41 +00:00
|
|
|
if (!op2.opcode) {
|
|
|
|
op1.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (op1.chars <= op2.chars) {
|
2020-11-23 13:24:19 -05:00
|
|
|
op2.chars -= op1.chars;
|
|
|
|
op2.lines -= op1.lines;
|
|
|
|
op1.opcode = '';
|
|
|
|
if (!op2.chars) {
|
2011-03-26 13:10:41 +00:00
|
|
|
op2.opcode = '';
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2020-11-23 13:24:19 -05:00
|
|
|
op1.chars -= op2.chars;
|
|
|
|
op1.lines -= op2.lines;
|
|
|
|
op2.opcode = '';
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (op2.opcode === '-') {
|
2021-03-21 14:13:50 -04:00
|
|
|
copyOp(op2, opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
if (!op1.opcode) {
|
|
|
|
op2.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (op2.chars <= op1.chars) {
|
2011-03-26 13:10:41 +00:00
|
|
|
// delete part or all of a keep
|
|
|
|
op1.chars -= op2.chars;
|
|
|
|
op1.lines -= op2.lines;
|
|
|
|
op2.opcode = '';
|
|
|
|
if (!op1.chars) {
|
|
|
|
op1.opcode = '';
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
// delete all of a keep, and keep going
|
|
|
|
opOut.lines = op1.lines;
|
|
|
|
opOut.chars = op1.chars;
|
|
|
|
op2.lines -= op1.lines;
|
|
|
|
op2.chars -= op1.chars;
|
|
|
|
op1.opcode = '';
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (!op1.opcode) {
|
2021-03-21 14:13:50 -04:00
|
|
|
copyOp(op2, opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
op2.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else if (!op2.opcode) {
|
Issue #1625: Fix to client-side-induced changeset spamming.
THE BUG - HIGH LEVEL:
- When client A sends out an attribute change, client B applies that change to itself but
also thinks that it made the change itself, which is incorrect. This means that when client B
next makes a change, he will send out that he made the attrib change that A actually made.
- Ex: Have 2 clients on the same pad. Have A apply bold on some text. Next, have B type a character.
B will broadcast that it both added a character AND applied bold, when in reality it did NOT
apply bold at all, that change was done by the other client and this client incorrectly adopted it as its own.
- This root bug behavior results in clients continuing to think that they each made the other client's change,
thus resulting in an infinite loop of changeset spamming that bogs down clients and harms server stability.
THE BUG - IN DEPTH:
- The root issue is in the way that Changesets are combined in Changeset.follow(). Specifically, in the case of a
changeset with ONLY new attrib changes (no text changes) being merged with an identity changeset (has no ops).
- In this case, Changeset.follow() copies the ops of the new CS and fully overrides the other CS.
- applyChangesToBase invokes Changeset.follow to determine the final client document CS state after applying the new CS.
If the final client document CS state is NOT the identity CS, then the client broadcasts that it made a change.
- When client A changes just attribs, client B's applyChangesToBase calls Changeset.follow() and passes client A's
changeset (attrib change) and Client B's current changeset state (identity).
- As per the noted bug, Changeset.follow() returns client A's changeset as a result, causing client B to adopt
client A's changeset as its own document state. Thus, client A ends up thinking it has made client B's changes.
THE FIX:
- Changeset.follow() should NOT copy the ops of the new CS passed in if those changes are only attrib changes.
This allows applyChangesToBase to properly set the client's CS back to the identity after applying an
external attrib change, instead of incorrectly adopting the external client's changes.
2013-04-24 15:02:58 -07:00
|
|
|
// @NOTE: Critical bugfix for EPL issue #1625. We do not copy op1 here
|
|
|
|
// in order to prevent attributes from leaking into result changesets.
|
2021-03-21 14:13:50 -04:00
|
|
|
// copyOp(op1, opOut);
|
2011-03-26 13:10:41 +00:00
|
|
|
op1.opcode = '';
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
// both keeps
|
|
|
|
opOut.opcode = '=';
|
2021-03-21 14:13:50 -04:00
|
|
|
opOut.attribs = followAttributes(op1.attribs, op2.attribs, pool);
|
2011-03-26 13:10:41 +00:00
|
|
|
if (op1.chars <= op2.chars) {
|
|
|
|
opOut.chars = op1.chars;
|
|
|
|
opOut.lines = op1.lines;
|
|
|
|
op2.chars -= op1.chars;
|
|
|
|
op2.lines -= op1.lines;
|
|
|
|
op1.opcode = '';
|
|
|
|
if (!op2.chars) {
|
|
|
|
op2.opcode = '';
|
|
|
|
}
|
2023-06-23 21:18:12 +02:00
|
|
|
}
|
|
|
|
else {
|
2011-03-26 13:10:41 +00:00
|
|
|
opOut.chars = op2.chars;
|
|
|
|
opOut.lines = op2.lines;
|
|
|
|
op1.chars -= op2.chars;
|
|
|
|
op1.lines -= op2.lines;
|
|
|
|
op2.opcode = '';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
switch (opOut.opcode) {
|
2020-11-23 13:24:19 -05:00
|
|
|
case '=':
|
|
|
|
oldPos += opOut.chars;
|
|
|
|
newLen += opOut.chars;
|
|
|
|
break;
|
|
|
|
case '-':
|
|
|
|
oldPos += opOut.chars;
|
|
|
|
break;
|
|
|
|
case '+':
|
|
|
|
newLen += opOut.chars;
|
|
|
|
break;
|
2011-03-26 13:10:41 +00:00
|
|
|
}
|
2021-10-13 17:00:50 -04:00
|
|
|
return opOut;
|
2011-03-26 13:10:41 +00:00
|
|
|
});
|
|
|
|
newLen += oldLen - oldPos;
|
|
|
|
return exports.pack(oldLen, newLen, newOps, unpacked2.charBank);
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export const exportedForTestingOnly = {
|
2021-03-21 14:34:02 -04:00
|
|
|
TextLinesMutator,
|
2021-03-21 14:13:50 -04:00
|
|
|
followAttributes,
|
|
|
|
toSplices,
|
|
|
|
};
|
2023-06-23 21:18:12 +02:00
|
|
|
export { Op };
|
|
|
|
export { attribsAttributeValue$0 as attribsAttributeValue };
|