text_operation.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  1. // @ts-check
  2. /**
  3. * The text operation from OT.js with some minor cosmetic changes.
  4. *
  5. * Specifically, this is based on
  6. * https://github.com/Operational-Transformation/ot.js/
  7. * blob/298825f58fb51fefb352e7df5ddbc668f4d5646f/lib/text-operation.js
  8. * from 18 Mar 2013.
  9. */
  10. 'use strict'
  11. const containsNonBmpChars = require('../util').containsNonBmpChars
  12. const EditOperation = require('./edit_operation')
  13. const {
  14. ScanOp,
  15. RetainOp,
  16. InsertOp,
  17. RemoveOp,
  18. isRetain,
  19. isInsert,
  20. isRemove,
  21. } = require('./scan_op')
  22. const {
  23. UnprocessableError,
  24. ApplyError,
  25. InvalidInsertionError,
  26. TooLongError,
  27. } = require('../errors')
  28. /** @typedef {import('../file_data/string_file_data')} StringFileData */
  29. /**
  30. * Create an empty text operation.
  31. * @extends EditOperation
  32. */
  33. class TextOperation extends EditOperation {
  34. /**
  35. * Length of the longest file that we'll attempt to edit, in characters.
  36. *
  37. * @type {number}
  38. */
  39. static MAX_STRING_LENGTH = 2 * Math.pow(1024, 2)
  40. static UnprocessableError = UnprocessableError
  41. static ApplyError = ApplyError
  42. static InvalidInsertionError = InvalidInsertionError
  43. static TooLongError = TooLongError
  44. constructor() {
  45. super()
  46. // When an operation is applied to an input string, you can think of this as
  47. // if an imaginary cursor runs over the entire string and skips over some
  48. // parts, removes some parts and inserts characters at some positions. These
  49. // actions (skip/remove/insert) are stored as an array in the "ops" property.
  50. /** @type {ScanOp[]} */
  51. this.ops = []
  52. // An operation's baseLength is the length of every string the operation
  53. // can be applied to.
  54. this.baseLength = 0
  55. // The targetLength is the length of every string that results from applying
  56. // the operation on a valid input string.
  57. this.targetLength = 0
  58. }
  59. equals(other) {
  60. if (this.baseLength !== other.baseLength) {
  61. return false
  62. }
  63. if (this.targetLength !== other.targetLength) {
  64. return false
  65. }
  66. if (this.ops.length !== other.ops.length) {
  67. return false
  68. }
  69. for (let i = 0; i < this.ops.length; i++) {
  70. if (!this.ops[i].equals(other.ops[i])) {
  71. return false
  72. }
  73. }
  74. return true
  75. }
  76. // After an operation is constructed, the user of the library can specify the
  77. // actions of an operation (skip/insert/remove) with these three builder
  78. // methods. They all return the operation for convenient chaining.
  79. /**
  80. * Skip over a given number of characters.
  81. * @param {number | {r: number}} n
  82. */
  83. retain(n) {
  84. if (n === 0) {
  85. return this
  86. }
  87. if (!isRetain(n)) {
  88. throw new Error('retain expects an integer or a retain object')
  89. }
  90. const newOp = RetainOp.fromJSON(n)
  91. if (newOp.length === 0) {
  92. return this
  93. }
  94. this.baseLength += newOp.length
  95. this.targetLength += newOp.length
  96. const lastOperation = this.ops[this.ops.length - 1]
  97. if (lastOperation?.canMergeWith(newOp)) {
  98. // The last op is a retain op => we can merge them into one op.
  99. lastOperation.mergeWith(newOp)
  100. } else {
  101. // Create a new op.
  102. this.ops.push(newOp)
  103. }
  104. return this
  105. }
  106. /**
  107. * Insert a string at the current position.
  108. * @param {string | {i: string}} insertValue
  109. */
  110. insert(insertValue) {
  111. if (!isInsert(insertValue)) {
  112. throw new Error('insert expects a string or an insert object')
  113. }
  114. const newOp = InsertOp.fromJSON(insertValue)
  115. if (newOp.insertion === '') {
  116. return this
  117. }
  118. this.targetLength += newOp.insertion.length
  119. const ops = this.ops
  120. const lastOp = this.ops[this.ops.length - 1]
  121. if (lastOp?.canMergeWith(newOp)) {
  122. // Merge insert op.
  123. lastOp.mergeWith(newOp)
  124. } else if (lastOp instanceof RemoveOp) {
  125. // It doesn't matter when an operation is applied whether the operation
  126. // is remove(3), insert("something") or insert("something"), remove(3).
  127. // Here we enforce that in this case, the insert op always comes first.
  128. // This makes all operations that have the same effect when applied to
  129. // a document of the right length equal in respect to the `equals` method.
  130. const secondToLastOp = ops[ops.length - 2]
  131. if (secondToLastOp?.canMergeWith(newOp)) {
  132. secondToLastOp.mergeWith(newOp)
  133. } else {
  134. ops[ops.length] = ops[ops.length - 1]
  135. ops[ops.length - 2] = newOp
  136. }
  137. } else {
  138. ops.push(newOp)
  139. }
  140. return this
  141. }
  142. /**
  143. * Remove a string at the current position.
  144. * @param {number | string} n
  145. */
  146. remove(n) {
  147. if (typeof n === 'string') {
  148. n = n.length
  149. }
  150. if (typeof n !== 'number') {
  151. throw new Error('remove expects an integer or a string')
  152. }
  153. if (n === 0) {
  154. return this
  155. }
  156. if (n > 0) {
  157. n = -n
  158. }
  159. const newOp = RemoveOp.fromJSON(n)
  160. this.baseLength -= n
  161. const lastOp = this.ops[this.ops.length - 1]
  162. if (lastOp?.canMergeWith(newOp)) {
  163. lastOp.mergeWith(newOp)
  164. } else {
  165. this.ops.push(newOp)
  166. }
  167. return this
  168. }
  169. /**
  170. * Tests whether this operation has no effect.
  171. */
  172. isNoop() {
  173. return (
  174. this.ops.length === 0 ||
  175. (this.ops.length === 1 && this.ops[0] instanceof RetainOp)
  176. )
  177. }
  178. /**
  179. * Pretty printing.
  180. */
  181. toString() {
  182. return this.ops.map(op => op.toString()).join(', ')
  183. }
  184. /**
  185. * @inheritdoc
  186. */
  187. toJSON() {
  188. return { textOperation: this.ops.map(op => op.toJSON()) }
  189. }
  190. /**
  191. * Converts a plain JS object into an operation and validates it.
  192. */
  193. static fromJSON = function ({ textOperation: ops }) {
  194. const o = new TextOperation()
  195. for (const op of ops) {
  196. if (isRetain(op)) {
  197. o.retain(op)
  198. } else if (isInsert(op)) {
  199. o.insert(op)
  200. } else if (isRemove(op)) {
  201. o.remove(op)
  202. } else {
  203. throw new UnprocessableError('unknown operation: ' + JSON.stringify(op))
  204. }
  205. }
  206. return o
  207. }
  208. /**
  209. * Apply an operation to a string, returning a new string. Throws an error if
  210. * there's a mismatch between the input string and the operation.
  211. * @override
  212. * @inheritdoc
  213. * @param {StringFileData} file
  214. */
  215. apply(file) {
  216. const str = file.getContent()
  217. const operation = this
  218. if (containsNonBmpChars(str)) {
  219. throw new TextOperation.ApplyError(
  220. 'The string contains non BMP characters.',
  221. operation,
  222. str
  223. )
  224. }
  225. if (str.length !== operation.baseLength) {
  226. throw new TextOperation.ApplyError(
  227. "The operation's base length must be equal to the string's length.",
  228. operation,
  229. str
  230. )
  231. }
  232. const ops = this.ops
  233. const { inputCursor, result } = ops.reduce(
  234. (intermediate, op) => op.apply(str, intermediate),
  235. { result: '', inputCursor: 0 }
  236. )
  237. if (inputCursor !== str.length) {
  238. throw new TextOperation.ApplyError(
  239. "The operation didn't operate on the whole string.",
  240. operation,
  241. str
  242. )
  243. }
  244. if (result.length > TextOperation.MAX_STRING_LENGTH) {
  245. throw new TextOperation.TooLongError(operation, result.length)
  246. }
  247. file.content = result
  248. }
  249. /**
  250. * @inheritdoc
  251. */
  252. applyToLength(length) {
  253. const operation = this
  254. if (length !== operation.baseLength) {
  255. throw new TextOperation.ApplyError(
  256. "The operation's base length must be equal to the string's length.",
  257. operation,
  258. length
  259. )
  260. }
  261. const { length: newLength, inputCursor } = this.ops.reduce(
  262. (intermediate, op) => op.applyToLength(intermediate),
  263. { length: 0, inputCursor: 0, inputLength: length }
  264. )
  265. if (inputCursor !== length) {
  266. throw new TextOperation.ApplyError(
  267. "The operation didn't operate on the whole string.",
  268. operation,
  269. length
  270. )
  271. }
  272. if (newLength > TextOperation.MAX_STRING_LENGTH) {
  273. throw new TextOperation.TooLongError(operation, newLength)
  274. }
  275. return newLength
  276. }
  277. /**
  278. * @inheritdoc
  279. * @param {StringFileData} previousState
  280. */
  281. invert(previousState) {
  282. const str = previousState.getContent()
  283. let strIndex = 0
  284. const inverse = new TextOperation()
  285. const ops = this.ops
  286. for (let i = 0, l = ops.length; i < l; i++) {
  287. const op = ops[i]
  288. if (op instanceof RetainOp) {
  289. inverse.retain(op.length)
  290. strIndex += op.length
  291. } else if (op instanceof InsertOp) {
  292. inverse.remove(op.insertion.length)
  293. } else if (op instanceof RemoveOp) {
  294. // remove op
  295. inverse.insert(str.slice(strIndex, strIndex + op.length))
  296. strIndex += op.length
  297. } else {
  298. throw new UnprocessableError('unknown scanop during inversion')
  299. }
  300. }
  301. return inverse
  302. }
  303. /**
  304. * @inheritdoc
  305. * @param {EditOperation} other
  306. */
  307. canBeComposedWithForUndo(other) {
  308. if (!(other instanceof TextOperation)) {
  309. return false
  310. }
  311. if (this.isNoop() || other.isNoop()) {
  312. return true
  313. }
  314. const startA = getStartIndex(this)
  315. const startB = getStartIndex(other)
  316. const simpleA = getSimpleOp(this)
  317. const simpleB = getSimpleOp(other)
  318. if (!simpleA || !simpleB) {
  319. return false
  320. }
  321. if (simpleA instanceof InsertOp && simpleB instanceof InsertOp) {
  322. return startA + simpleA.insertion.length === startB
  323. }
  324. if (simpleA instanceof RemoveOp && simpleB instanceof RemoveOp) {
  325. // there are two possibilities to delete: with backspace and with the
  326. // delete key.
  327. return startB + simpleB.length === startA || startA === startB
  328. }
  329. return false
  330. }
  331. /**
  332. * @inheritdoc
  333. * @param {EditOperation} other
  334. */
  335. canBeComposedWith(other) {
  336. if (!(other instanceof TextOperation)) {
  337. return false
  338. }
  339. return this.targetLength === other.baseLength
  340. }
  341. /**
  342. * @inheritdoc
  343. * @param {EditOperation} operation2
  344. */
  345. compose(operation2) {
  346. if (!(operation2 instanceof TextOperation)) {
  347. throw new Error(
  348. `Trying to compose TextOperation with ${operation2?.constructor?.name}.`
  349. )
  350. }
  351. const operation1 = this
  352. if (operation1.targetLength !== operation2.baseLength) {
  353. throw new Error(
  354. 'The base length of the second operation has to be the ' +
  355. 'target length of the first operation'
  356. )
  357. }
  358. const operation = new TextOperation() // the combined operation
  359. const ops1 = operation1.ops
  360. const ops2 = operation2.ops // for fast access
  361. let i1 = 0
  362. let i2 = 0 // current index into ops1 respectively ops2
  363. let op1 = ops1[i1++]
  364. let op2 = ops2[i2++] // current ops
  365. for (;;) {
  366. // Dispatch on the type of op1 and op2
  367. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  368. // end condition: both ops1 and ops2 have been processed
  369. break
  370. }
  371. if (op1 instanceof RemoveOp) {
  372. operation.remove(-op1.length)
  373. op1 = ops1[i1++]
  374. continue
  375. }
  376. if (op2 instanceof InsertOp) {
  377. operation.insert(op2.insertion)
  378. op2 = ops2[i2++]
  379. continue
  380. }
  381. if (typeof op1 === 'undefined') {
  382. throw new Error(
  383. 'Cannot compose operations: first operation is too short.'
  384. )
  385. }
  386. if (typeof op2 === 'undefined') {
  387. throw new Error(
  388. 'Cannot compose operations: first operation is too long.'
  389. )
  390. }
  391. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  392. if (op1.length > op2.length) {
  393. operation.retain(op2.length)
  394. op1 = ScanOp.fromJSON(op1.length - op2.length)
  395. op2 = ops2[i2++]
  396. } else if (op1.length === op2.length) {
  397. operation.retain(op1.length)
  398. op1 = ops1[i1++]
  399. op2 = ops2[i2++]
  400. } else {
  401. operation.retain(op1.length)
  402. op2 = ScanOp.fromJSON(op2.length - op1.length)
  403. op1 = ops1[i1++]
  404. }
  405. } else if (op1 instanceof InsertOp && op2 instanceof RemoveOp) {
  406. if (op1.insertion.length > op2.length) {
  407. op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
  408. op2 = ops2[i2++]
  409. } else if (op1.insertion.length === op2.length) {
  410. op1 = ops1[i1++]
  411. op2 = ops2[i2++]
  412. } else {
  413. op2 = ScanOp.fromJSON(-op2.length + op1.insertion.length)
  414. op1 = ops1[i1++]
  415. }
  416. } else if (op1 instanceof InsertOp && op2 instanceof RetainOp) {
  417. if (op1.insertion.length > op2.length) {
  418. operation.insert(op1.insertion.slice(0, op2.length))
  419. op1 = ScanOp.fromJSON(op1.insertion.slice(op2.length))
  420. op2 = ops2[i2++]
  421. } else if (op1.insertion.length === op2.length) {
  422. operation.insert(op1.insertion)
  423. op1 = ops1[i1++]
  424. op2 = ops2[i2++]
  425. } else {
  426. operation.insert(op1.insertion)
  427. op2 = ScanOp.fromJSON(op2.length - op1.insertion.length)
  428. op1 = ops1[i1++]
  429. }
  430. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  431. if (op1.length > op2.length) {
  432. operation.remove(-op2.length)
  433. op1 = ScanOp.fromJSON(op1.length - op2.length)
  434. op2 = ops2[i2++]
  435. } else if (op1.length === op2.length) {
  436. operation.remove(-op2.length)
  437. op1 = ops1[i1++]
  438. op2 = ops2[i2++]
  439. } else {
  440. operation.remove(op1.length)
  441. op2 = ScanOp.fromJSON(-op2.length + op1.length)
  442. op1 = ops1[i1++]
  443. }
  444. } else {
  445. throw new Error(
  446. "This shouldn't happen: op1: " +
  447. JSON.stringify(op1) +
  448. ', op2: ' +
  449. JSON.stringify(op2)
  450. )
  451. }
  452. }
  453. return operation
  454. }
  455. /**
  456. * Transform takes two operations A and B that happened concurrently and
  457. * produces two operations A' and B' (in an array) such that
  458. * `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the
  459. * heart of OT.
  460. * @param {TextOperation} operation1
  461. * @param {TextOperation} operation2
  462. */
  463. static transform(operation1, operation2) {
  464. if (operation1.baseLength !== operation2.baseLength) {
  465. throw new Error('Both operations have to have the same base length')
  466. }
  467. const operation1prime = new TextOperation()
  468. const operation2prime = new TextOperation()
  469. const ops1 = operation1.ops
  470. const ops2 = operation2.ops
  471. let i1 = 0
  472. let i2 = 0
  473. let op1 = ops1[i1++]
  474. let op2 = ops2[i2++]
  475. for (;;) {
  476. // At every iteration of the loop, the imaginary cursor that both
  477. // operation1 and operation2 have that operates on the input string must
  478. // have the same position in the input string.
  479. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') {
  480. // end condition: both ops1 and ops2 have been processed
  481. break
  482. }
  483. // next two cases: one or both ops are insert ops
  484. // => insert the string in the corresponding prime operation, skip it in
  485. // the other one. If both op1 and op2 are insert ops, prefer op1.
  486. if (op1 instanceof InsertOp) {
  487. operation1prime.insert(op1.insertion)
  488. operation2prime.retain(op1.insertion.length)
  489. op1 = ops1[i1++]
  490. continue
  491. }
  492. if (op2 instanceof InsertOp) {
  493. operation1prime.retain(op2.insertion.length)
  494. operation2prime.insert(op2.insertion)
  495. op2 = ops2[i2++]
  496. continue
  497. }
  498. if (typeof op1 === 'undefined') {
  499. throw new Error(
  500. 'Cannot compose operations: first operation is too short.'
  501. )
  502. }
  503. if (typeof op2 === 'undefined') {
  504. throw new Error(
  505. 'Cannot compose operations: first operation is too long.'
  506. )
  507. }
  508. let minl
  509. if (op1 instanceof RetainOp && op2 instanceof RetainOp) {
  510. // Simple case: retain/retain
  511. if (op1.length > op2.length) {
  512. minl = op2.length
  513. op1 = ScanOp.fromJSON(op1.length - op2.length)
  514. op2 = ops2[i2++]
  515. } else if (op1.length === op2.length) {
  516. minl = op2.length
  517. op1 = ops1[i1++]
  518. op2 = ops2[i2++]
  519. } else {
  520. minl = op1.length
  521. op2 = ScanOp.fromJSON(op2.length - op1.length)
  522. op1 = ops1[i1++]
  523. }
  524. operation1prime.retain(minl)
  525. operation2prime.retain(minl)
  526. } else if (op1 instanceof RemoveOp && op2 instanceof RemoveOp) {
  527. // Both operations remove the same string at the same position. We don't
  528. // need to produce any operations, we just skip over the remove ops and
  529. // handle the case that one operation removes more than the other.
  530. if (op1.length > op2.length) {
  531. op1 = ScanOp.fromJSON(-op1.length - -op2.length)
  532. op2 = ops2[i2++]
  533. } else if (op1.length === op2.length) {
  534. op1 = ops1[i1++]
  535. op2 = ops2[i2++]
  536. } else {
  537. op2 = ScanOp.fromJSON(-op2.length - -op1.length)
  538. op1 = ops1[i1++]
  539. }
  540. // next two cases: remove/retain and retain/remove
  541. } else if (op1 instanceof RemoveOp && op2 instanceof RetainOp) {
  542. if (op1.length > op2.length) {
  543. minl = op2.length
  544. op1 = ScanOp.fromJSON(-op1.length + op2.length)
  545. op2 = ops2[i2++]
  546. } else if (op1.length === op2.length) {
  547. minl = op2.length
  548. op1 = ops1[i1++]
  549. op2 = ops2[i2++]
  550. } else {
  551. minl = op1.length
  552. op2 = ScanOp.fromJSON(op2.length + -op1.length)
  553. op1 = ops1[i1++]
  554. }
  555. operation1prime.remove(minl)
  556. } else if (op1 instanceof RetainOp && op2 instanceof RemoveOp) {
  557. if (op1.length > op2.length) {
  558. minl = op2.length
  559. op1 = ScanOp.fromJSON(op1.length + -op2.length)
  560. op2 = ops2[i2++]
  561. } else if (op1.length === op2.length) {
  562. minl = op1.length
  563. op1 = ops1[i1++]
  564. op2 = ops2[i2++]
  565. } else {
  566. minl = op1.length
  567. op2 = ScanOp.fromJSON(-op2.length + op1.length)
  568. op1 = ops1[i1++]
  569. }
  570. operation2prime.remove(minl)
  571. } else {
  572. throw new Error("The two operations aren't compatible")
  573. }
  574. }
  575. return [operation1prime, operation2prime]
  576. }
  577. }
  578. // Operation are essentially lists of ops. There are three types of ops:
  579. //
  580. // * Retain ops: Advance the cursor position by a given number of characters.
  581. // Represented by positive ints.
  582. // * Insert ops: Insert a given string at the current cursor position.
  583. // Represented by strings.
  584. // * Remove ops: Remove the next n characters. Represented by negative ints.
  585. /**
  586. *
  587. * @param {TextOperation} operation
  588. * @returns {ScanOp | null}
  589. */
  590. function getSimpleOp(operation) {
  591. const ops = operation.ops
  592. switch (ops.length) {
  593. case 1:
  594. return ops[0]
  595. case 2:
  596. return ops[0] instanceof RetainOp
  597. ? ops[1]
  598. : ops[1] instanceof RetainOp
  599. ? ops[0]
  600. : null
  601. case 3:
  602. if (ops[0] instanceof RetainOp && ops[2] instanceof RetainOp) {
  603. return ops[1]
  604. }
  605. }
  606. return null
  607. }
  608. function getStartIndex(operation) {
  609. if (operation.ops[0] instanceof RetainOp) {
  610. return operation.ops[0].length
  611. }
  612. return 0
  613. }
  614. module.exports = TextOperation