├── .gitignore
├── .npmignore
├── .prettierrc
├── .vscode
├── launch.json
└── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── doc
├── change.dot
├── change.jpg
├── change.png
├── change.svg
├── introduction.dot
├── introduction.jpg
├── introduction.png
├── introduction.svg
├── merge.dot
├── merge.jpg
├── merge.png
├── merge.svg
├── rebase.dot
├── rebase.jpg
├── rebase.png
└── rebase.svg
├── jestconfig.json
├── package-lock.json
├── package.json
├── src
├── __tests__
│ ├── chalk.ts
│ ├── generate.spec.ts
│ ├── generator
│ │ ├── ArraySplit.ts
│ │ ├── Attribute.ts
│ │ ├── ChangeList.ts
│ │ ├── Content.ts
│ │ ├── ContentChangeList.ts
│ │ ├── Delta.ts
│ │ ├── Embed.ts
│ │ ├── Insert.ts
│ │ ├── Op.ts
│ │ ├── Ops.ts
│ │ ├── Range.ts
│ │ ├── RetainDelete.ts
│ │ └── SharedString.ts
│ ├── jsproptest.spec.ts
│ ├── random.ts
│ ├── regression.spec.ts
│ ├── tslint.json
│ └── underscore.spec.ts
├── core
│ ├── Delta.ts
│ ├── DeltaComposer.ts
│ ├── DeltaContext.ts
│ ├── DeltaIterator.ts
│ ├── DeltaTransformer.ts
│ ├── Embedded.ts
│ ├── Fragment.ts
│ ├── FragmentIterator.ts
│ ├── IDelta.ts
│ ├── Modification.ts
│ ├── Range.ts
│ ├── SharedString.ts
│ ├── Source.ts
│ ├── __tests__
│ │ ├── Delta.props.spec.ts
│ │ ├── Fragment.spec.ts
│ │ ├── SharedString.spec.ts
│ │ ├── primitive.props.spec.ts
│ │ ├── primitive.spec.ts
│ │ ├── quill-delta.spec.ts
│ │ ├── range.spec.ts
│ │ ├── text.spec.ts
│ │ └── tslint.json
│ ├── primitive.ts
│ ├── printer.ts
│ └── util.ts
├── document
│ ├── Document.ts
│ ├── DocumentSet.ts
│ └── __tests__
│ │ ├── Document.props.spec.ts
│ │ ├── Document.spec.ts
│ │ ├── Document.stateful.spec.ts
│ │ └── tslint.json
├── excerpt
│ ├── Excerpt.ts
│ ├── ExcerptMarker.ts
│ ├── ExcerptSource.ts
│ ├── ExcerptSync.ts
│ ├── ExcerptTarget.ts
│ ├── ExcerptUtil.ts
│ ├── __tests__
│ │ ├── Excerpt.props.spec.ts
│ │ ├── Excerpt.spec.ts
│ │ └── tslint.json
│ └── index.ts
├── history
│ ├── History.ts
│ ├── SyncRequest.ts
│ ├── SyncResponse.ts
│ └── __tests__
│ │ ├── History.props.spec.ts
│ │ ├── generator
│ │ ├── GenHistory.ts
│ │ ├── GenHistoryAndDelta.ts
│ │ └── GenHistoryAndDivergingDeltas.ts
│ │ ├── history.spec.ts
│ │ └── tslint.json
├── index.ts
└── service
│ ├── DocClient.ts
│ ├── DocServer.ts
│ ├── RepoClient.ts
│ └── RepoServer.ts
├── tsconfig.json
└── tslint.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | yarn-error.log
3 | yarn.lock
4 | /lib
5 | coverage
6 | .DS_Store
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | lib
3 | coverage
4 | tsconfig.json
5 | tslint.json
6 | .prettierrc
7 | jestconfig.json
8 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "trailingComma": "all",
4 | "singleQuote": true,
5 | "semi": false,
6 | "tabWidth": 4
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Debug jest test specific",
9 | "type": "node",
10 | "request": "launch",
11 | "runtimeArgs": [
12 | "--inspect-brk",
13 | "${workspaceRoot}/node_modules/.bin/jest",
14 | "--runInBand",
15 | "--coverage",
16 | "false",
17 | "--config",
18 | "jestconfig.json",
19 | "Excerpt.props.spec.ts"
20 | ],
21 | "console": "integratedTerminal",
22 | "internalConsoleOptions": "neverOpen",
23 | "port": 9229
24 | },
25 | {
26 | "name": "Debug jest test Excerpt.spec",
27 | "type": "node",
28 | "request": "launch",
29 | "runtimeArgs": [
30 | "--inspect-brk",
31 | "${workspaceRoot}/node_modules/.bin/jest",
32 | "--runInBand",
33 | "--coverage",
34 | "false",
35 | "--config",
36 | "jestconfig.json",
37 | "Excerpt.spec.ts"
38 | ],
39 | "console": "integratedTerminal",
40 | "internalConsoleOptions": "neverOpen",
41 | "port": 9229
42 | },
43 | {
44 | "name": "Debug jest test",
45 | "type": "node",
46 | "request": "launch",
47 | "runtimeArgs": [
48 | "--inspect-brk",
49 | "${workspaceRoot}/node_modules/.bin/jest",
50 | "--runInBand",
51 | "--coverage",
52 | "false",
53 | "--config",
54 | "jestconfig.json",
55 | "--testNamePattern",
56 | "${jest.testNamePattern}",
57 | "--runTestsByPath",
58 | "${jest.testFile}"
59 | ],
60 | "console": "integratedTerminal",
61 | "internalConsoleOptions": "neverOpen",
62 | "port": 9229
63 | },
64 | {
65 | "type": "node",
66 | "name": "vscode-jest-tests",
67 | "request": "launch",
68 | "console": "integratedTerminal",
69 | "internalConsoleOptions": "neverOpen",
70 | "disableOptimisticBPs": true,
71 | "program": "${workspaceFolder}/node_modules/.bin/npx",
72 | "cwd": "${workspaceFolder}",
73 | "args": [
74 | "jest",
75 | "--config",
76 | "jestconfig.json",
77 | "--verbose",
78 | "--runInBand",
79 | "--watchAll=false"
80 | ]
81 | }
82 | ]
83 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "jest.jestCommandLine": "npx jest --config jestconfig.json --verbose"
3 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:8
2 |
3 | RUN mkdir -p /usr/src/app
4 | WORKDIR /usr/src/app
5 |
6 | COPY package*.json ./
7 | RUN npm install
8 |
9 | COPY . .
10 |
11 | CMD [ "npm", "test" ]
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Won-Wook Hong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/doc/change.dot:
--------------------------------------------------------------------------------
1 | digraph change {
2 | content0 [shape=box, label="content 0"]
3 | //change0 [shape=diamond, label="change 0"]
4 | content1 [shape=box, label="content 1"]
5 | //change1 [shape=diamond, label="change 1"]
6 | content2 [shape=box, label="content 2"]
7 | //change2 [shape=diamond, label="change 2"]
8 | content3 [shape=box, label="content 3"]
9 |
10 |
11 | //content0 -> change0 -> content1 -> change1 -> content2 -> change2 -> content3
12 | content0 -> content1 [label=" change0"]
13 | content1 -> content2 [label=" change1"]
14 | content2 -> content3 [label=" change2"]
15 | }
16 |
--------------------------------------------------------------------------------
/doc/change.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/change.jpg
--------------------------------------------------------------------------------
/doc/change.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/change.png
--------------------------------------------------------------------------------
/doc/change.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
59 |
--------------------------------------------------------------------------------
/doc/introduction.dot:
--------------------------------------------------------------------------------
1 | digraph introduction {
2 | graph [/*label="Orthogonal edges",*/ splines=ortho, nodesep=0.8]
3 | node [shape=box]
4 | //node [fontname=Arial];
5 | initial [label="Initial: 'Hi!'"]
6 | alice_rev0 [label=i!>]
7 | alice_rev1 [label=ello!'>]
8 | bob_rev0 [label=, pretty!'>]
9 | charlie_rev0 [label=world!'>]
10 | merged1 [label=iello, pretty!'>]
11 | merged2 [label=iello, pretty world!'>]
12 |
13 | initial -> alice_rev0 -> alice_rev1 -> merged1;
14 | initial -> bob_rev0 -> merged1;
15 | merged1 -> merged2
16 | initial -> charlie_rev0 -> merged2
17 | }
18 |
--------------------------------------------------------------------------------
/doc/introduction.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/introduction.jpg
--------------------------------------------------------------------------------
/doc/introduction.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/introduction.png
--------------------------------------------------------------------------------
/doc/introduction.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
118 |
--------------------------------------------------------------------------------
/doc/merge.dot:
--------------------------------------------------------------------------------
1 | digraph merge {
2 | //graph [label="Orthogonal edges", splines=ortho, nodesep=0.8]
3 | subgraph cluster_0 {
4 | b_rev0 [label="rev 0"]
5 | b_rev1 [label="rev 1"]
6 | b_rev2 [label="rev 2"]
7 | b_client1 [label="client rev 1"]
8 | b_client2 [label="client rev 2"]
9 | inv1 [style=invisible]
10 | inv2 [style=invisible]
11 |
12 | b_rev0 -> b_rev1 -> b_rev2 [penwidth=4]
13 | b_rev0 -> b_client1 -> b_client2
14 | b_rev2 -> inv1 -> inv2 [style=invisible, arrowhead=none]
15 | }
16 |
17 | subgraph cluster_1 {
18 | rev0 [label="rev 0"]
19 | rev1 [label="rev 1"]
20 | rev2 [label="rev 2"]
21 | client1 [label="client rev 1"]
22 | client2 [label="client rev 2"]
23 | client1_ [label="rev 3 (client rev 1 transformed)"]
24 | client2_ [label="rev 4 (client rev 2 transformed)"]
25 |
26 | rev0 -> rev1 -> rev2 [penwidth=4]
27 | rev0 -> client1 -> client2
28 | rev2 -> client1_ -> client2_ [penwidth=4]
29 | client1 -> client1_ [style="dashed", color=red]
30 | client2 -> client2_ [style="dashed", color=red]
31 |
32 | {rank = same; rev1; client1}
33 | {rank = same; rev2; client2}
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/doc/merge.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/merge.jpg
--------------------------------------------------------------------------------
/doc/merge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/merge.png
--------------------------------------------------------------------------------
/doc/merge.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
182 |
--------------------------------------------------------------------------------
/doc/rebase.dot:
--------------------------------------------------------------------------------
1 | digraph merge {
2 | subgraph cluster_0 {
3 | b_rev0 [label="rev 0"]
4 | b_rev1 [label="rev 1"]
5 | b_rev2 [label="rev 2"]
6 | b_client1 [label="client rev 1"]
7 | b_client2 [label="client rev 2"]
8 | inv1 [style=invisible]
9 | inv2 [style=invisible]
10 |
11 | b_rev0 -> b_rev1 -> b_rev2 [penwidth=4]
12 | b_rev0 -> b_client1 -> b_client2
13 | b_rev2 -> inv1 -> inv2 [style=invisible, arrowhead=none]
14 | }
15 |
16 | subgraph cluster_1 {
17 | rev0 [label="rev 0"]
18 | rev1 [label="original rev 1"]
19 | rev2 [label="original rev 2"]
20 | client1 [label="rev 1 (client rev 1)"]
21 | client2 [label="rev 2 (client rev 2)"]
22 | rev3 [label="rev 3 (original rev 1 transformed)"]
23 | rev4 [label="rev 4 (original rev 2 transformed)"]
24 |
25 | rev0 -> rev1 -> rev2
26 | rev0 -> client1 -> client2 -> rev3 -> rev4 [penwidth=4]
27 | rev1 -> rev3 [style="dashed", color=red]
28 | rev2 -> rev4 [style="dashed", color=red]
29 |
30 | {rank = same; rev1; client1}
31 | {rank = same; rev2; client2}
32 |
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/doc/rebase.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/rebase.jpg
--------------------------------------------------------------------------------
/doc/rebase.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kindone/text-versioncontrol/512d63c4c9e6f7d4554ca18665b88be6bfe18cf2/doc/rebase.png
--------------------------------------------------------------------------------
/doc/rebase.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
182 |
--------------------------------------------------------------------------------
/jestconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "transform": {
3 | "^.+\\.(t|j)sx?$": "ts-jest"
4 | },
5 | "testRegex": "((\\.|/)(test|spec))\\.(jsx?|tsx?)$",
6 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"],
7 | "collectCoverage": true
8 | }
9 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "text-versioncontrol",
3 | "version": "0.9.10",
4 | "lockfileVersion": 1,
5 | "requires": true,
6 | "dependencies": {
7 | "@types/underscore": {
8 | "version": "1.11.2",
9 | "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.2.tgz",
10 | "integrity": "sha512-Ls2ylbo7++ITrWk2Yc3G/jijwSq5V3GT0tlgVXEl2kKYXY3ImrtmTCoE2uyTWFRI5owMBriloZFWbE1SXOsE7w=="
11 | },
12 | "underscore": {
13 | "version": "1.13.1",
14 | "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
15 | "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "text-versioncontrol",
3 | "version": "0.9.11",
4 | "description": "Text Version Control",
5 | "main": "lib/index.js",
6 | "types": "lib/index.d.ts",
7 | "engines": {
8 | "node": ">=0.14"
9 | },
10 | "scripts": {
11 | "test": "npx jest --config jestconfig.json --verbose",
12 | "testbail": "npx jest --config jestconfig.json --verbose --bail",
13 | "build": "npx tsc",
14 | "format": "prettier --write \"src/**/*.ts\" \"src/**/*.js\"",
15 | "lint": "npx tslint -p tsconfig.json",
16 | "prepare": "npm run build",
17 | "prepublishOnly": "npm test && npm run lint",
18 | "preversion": "npm run lint",
19 | "version": "npm run format && git add -A src",
20 | "postversion": "git push && git push --tags"
21 | },
22 | "repository": "https://github.com/kindone/text-versioncontrol.git",
23 | "author": "Won-Wook Hong ",
24 | "license": "MIT",
25 | "keywords": [
26 | "CRDT",
27 | "OT"
28 | ],
29 | "devDependencies": {
30 | "@types/jest": "^23.3.2",
31 | "hosted-git-info": ">=2.8.9",
32 | "jest": "^26.6.3",
33 | "jest-each": "^24.7.1",
34 | "jsproptest": "^0.3.8",
35 | "lodash": ">=4.17.21",
36 | "npx": "^10.2.2",
37 | "prettier": "^1.14.2",
38 | "pure-rand": "^1.6.2",
39 | "ts-jest": "^23.1.4",
40 | "tslint": "^5.11.0",
41 | "tslint-config-prettier": "^1.15.0",
42 | "typescript": "^4.1.3"
43 | },
44 | "dependencies": {
45 | "@types/underscore": "^1.11.2",
46 | "chalk": "^2.4.1",
47 | "quill-delta": "^4.2.1",
48 | "underscore": "^1.13.1"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/__tests__/chalk.ts:
--------------------------------------------------------------------------------
1 | import * as chalk from 'chalk'
2 |
3 | describe('Chalk', () => {
4 | it('basic', () => {
5 | const source = chalk.default.cyan
6 | const target = chalk.default.red
7 | const inserted = chalk.default.green
8 | const changes = [
9 | 'abcd',
10 | source('[') + 'ab' + target('[') + 'abcd' + target(']') + 'cd' + source(']'),
11 | source('[') + 'ab' + target('[') + 'ab' + inserted('x') + 'cd' + target(']') + 'cd' + source(']'),
12 | ]
13 | let i = 0
14 | for (const change of changes) {
15 | console.log('rev ' + i++ + ': ' + change)
16 | }
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/src/__tests__/generate.spec.ts:
--------------------------------------------------------------------------------
1 | import { forAll, interval, Property, Random } from 'jsproptest'
2 | import { contentLengthChanged, minContentLengthForChange } from '../core/primitive'
3 | import { JSONStringify } from '../core/util'
4 | import { ArraySplitsGen, ArraySplit } from './generator/ArraySplit'
5 | import { ChangeList, ChangeListGen } from './generator/ChangeList'
6 | import { DeltaGen } from './generator/Delta'
7 |
8 | describe('generate', () => {
9 | it('attributeMap', () => {})
10 |
11 | it('ArraySplit', () => {
12 | const minLength = 1
13 | const maxLength = 30
14 | const tupleGen = interval(minLength, maxLength)
15 | .chain(length => interval(minLength, length))
16 | .chain(lenthAndMinSplits => interval(lenthAndMinSplits[1], lenthAndMinSplits[0]))
17 | .map(tup => [tup[0][0], tup[0][1], tup[1]])
18 | const prop = new Property((tup: [number, number, number]) => {
19 | const [length, minSplits, maxSplits] = tup
20 | const arraySplitsGen = ArraySplitsGen(length, minSplits, maxSplits)
21 | expect(length).toBeGreaterThanOrEqual(minSplits)
22 | expect(length).toBeGreaterThanOrEqual(maxSplits)
23 | expect(minSplits).toBeLessThanOrEqual(maxSplits)
24 |
25 | forAll((arraySplits: ArraySplit[]) => {
26 | expect(arraySplits[arraySplits.length - 1].from + arraySplits[arraySplits.length - 1].length).toBe(
27 | length,
28 | )
29 |
30 | return arraySplits.length >= minSplits && arraySplits.length <= maxSplits
31 | }, arraySplitsGen)
32 | })
33 | prop.setNumRuns(100).forAll(tupleGen)
34 | })
35 |
36 | it('generate Delta', () => {
37 | const prop = new Property((initialLength: number, seed: number): void => {
38 | const random = new Random(seed.toString())
39 | const delta = DeltaGen(initialLength).generate(random).value
40 | const minLength = minContentLengthForChange(delta)
41 | if (minLength != initialLength) throw new Error(`${initialLength},${minLength},${JSONStringify(delta)}`)
42 | const newLength = contentLengthChanged(initialLength, delta)
43 | if (newLength < 0) throw new Error(`${initialLength},${newLength},${JSONStringify(delta)}`)
44 | })
45 | prop.setNumRuns(10000).forAll(interval(0, 30), interval(0, 100))
46 | })
47 |
48 | it('Delta bug', () => {
49 | const random = new Random('65')
50 | expect(contentLengthChanged(11, DeltaGen(11).generate(random).value)).toBeGreaterThanOrEqual(0)
51 | })
52 |
53 | it('generate ChangeList', () => {
54 | // forAll((initialLength:number, numChanges:number, seed:number):void => {
55 | // const random = new Random(seed.toString())
56 | // ChangeListGen(initialLength, numChanges).generate(random)
57 | // }, interval(0, 10), interval(1, 20), interval(0, 100))
58 | // const random = new Random()
59 | // for(let i = 0; i < 1000; i++)
60 | // console.log(JSONStringify(ChangeListGen().generate(random).value))
61 | forAll((_changeList: ChangeList): void => {}, ChangeListGen(10, 50))
62 | })
63 | })
64 |
--------------------------------------------------------------------------------
/src/__tests__/generator/ArraySplit.ts:
--------------------------------------------------------------------------------
1 | import { JSONStringify } from '../../core/util'
2 | import * as _ from 'underscore'
3 | import { Generator, interval, just, SetGen } from 'jsproptest'
4 |
5 | export type ArraySplit = { from: number; length: number }
6 |
7 | function getSortedArrayFromSet(set: Set): Array {
8 | const arr = new Array()
9 | set.forEach(function(item) {
10 | arr.push(item)
11 | })
12 | return arr.sort((a, b) => (a > b ? 1 : a == b ? 0 : -1))
13 | }
14 |
15 | export function ArraySplitsGen(length: number, minSplits = -1, maxSplits = -1): Generator {
16 | if ((minSplits >= 0 && length < minSplits) || (maxSplits >= 0 && length < maxSplits))
17 | throw new Error(`length too small: ${length}, minSplits: ${minSplits}, maxSplits: ${maxSplits}`)
18 | else if (length <= 0)
19 | throw new Error(`length too small: ${length}, minSplits: ${minSplits}, maxSplits: ${maxSplits}`)
20 |
21 | if (minSplits < 0) minSplits = 0
22 | if (maxSplits < 0) maxSplits = length
23 |
24 | return interval(minSplits, maxSplits).flatMap(numSplits => {
25 | if (numSplits <= 1) return just([{ from: 0, length }])
26 |
27 | const seqGen = SetGen(interval(1, length - 1), numSplits - 1, numSplits - 1).map(set =>
28 | getSortedArrayFromSet(set),
29 | )
30 |
31 | return seqGen.map(seq => {
32 | // console.log(`random unique seq range: [${1},${length-1}], seqSize: ${numSplits-1}, seq: ${JSONStringify(seq)}`)
33 | const arr: ArraySplit[] = []
34 |
35 | expect(seq.length).toBe(numSplits - 1)
36 |
37 | arr.push({ from: 0, length: seq[0] })
38 | if (seq[0] <= 0) throw new Error(`invalid length: ${JSONStringify(seq)} at ${-1}`)
39 |
40 | for (let i = 0; i < seq.length - 1; i++) {
41 | arr.push({ from: seq[i], length: seq[i + 1] - seq[i] })
42 | if (seq[i + 1] - seq[i] <= 0) throw new Error(`invalid length: ${JSONStringify(seq)} at ${i}`)
43 | }
44 | arr.push({ from: seq[seq.length - 1], length: length - seq[seq.length - 1] })
45 | if (length - seq[seq.length - 1] <= 0)
46 | throw new Error(`invalid length: ${JSONStringify(seq)} at ${seq.length - 1}`)
47 |
48 | const reduced = _.reduce(
49 | arr,
50 | (sum, split) => {
51 | return sum + split.length
52 | },
53 | 0,
54 | )
55 |
56 | if (reduced != length) throw new Error(`reduced(${reduced}) != length(${length}), ${JSONStringify(arr)}`)
57 | return arr
58 | })
59 | })
60 | }
61 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Attribute.ts:
--------------------------------------------------------------------------------
1 | import { integers, interval, just, PrintableASCIIStringGen, TupleGen } from 'jsproptest'
2 | import { Generator } from 'jsproptest'
3 |
4 | type Key = 'i' | 'b'
5 | export type IOrB = { [key in Key]?: string | null }
6 |
7 | export function AttributeMapGen(
8 | allowNullAttr = true,
9 | strGen: Generator = PrintableASCIIStringGen(0, 3),
10 | ): Generator {
11 | return interval(0, allowNullAttr ? 7 : 2).flatMap(kind => {
12 | switch (kind) {
13 | case 0:
14 | return strGen.map(str => {
15 | return { b: str }
16 | })
17 | case 1:
18 | return strGen.map(str => {
19 | return { i: str }
20 | })
21 | case 2:
22 | return TupleGen(strGen, strGen).map(tuple => {
23 | return { b: tuple[0], i: tuple[1] }
24 | })
25 | case 3:
26 | return just({ b: null })
27 | case 4:
28 | return just({ i: null })
29 | case 5:
30 | return just({ b: null, i: null })
31 | case 6:
32 | return strGen.map(str => {
33 | return { b: str, i: null }
34 | })
35 | case 7:
36 | default:
37 | return strGen.map(str => {
38 | return { b: null, i: str }
39 | })
40 | }
41 | })
42 | }
43 |
44 | export function NullableAttributeMapGen(strGen: Generator = PrintableASCIIStringGen(0, 3)): Generator {
45 | return interval(0, 7).flatMap(kind => {
46 | switch (kind) {
47 | case 0:
48 | return strGen.map(str => {
49 | return { b: str }
50 | })
51 | case 1:
52 | return strGen.map(str => {
53 | return { i: str }
54 | })
55 | case 2:
56 | return TupleGen(strGen, strGen).map(tuple => {
57 | return { b: tuple[0], i: tuple[1] }
58 | })
59 | case 3:
60 | return just({ b: null })
61 | case 4:
62 | return just({ i: null })
63 | case 5:
64 | return just({ b: null, i: null })
65 | case 6:
66 | return strGen.map(str => {
67 | return { b: str, i: null }
68 | })
69 | case 7:
70 | default:
71 | return strGen.map(str => {
72 | return { b: null, i: str }
73 | })
74 | }
75 | })
76 | }
77 |
--------------------------------------------------------------------------------
/src/__tests__/generator/ChangeList.ts:
--------------------------------------------------------------------------------
1 | import { DeltaGen } from './Delta'
2 | import { JSONStringify } from '../../core/util'
3 | import { Delta } from '../../core/Delta'
4 | import { contentLengthChanged } from '../../core/primitive'
5 | import { Generator, inRange, interval, just, oneOf, TupleGen } from 'jsproptest'
6 |
7 | export interface ChangeList {
8 | deltas: Delta[]
9 | lengths: number[]
10 | }
11 |
12 | interface DeltaAndLength {
13 | delta: Delta
14 | length: number
15 | }
16 |
17 | export function ChangeListGen(
18 | initialLength = -1,
19 | numChanges = -1,
20 | withEmbed = true,
21 | withAttr = true,
22 | ): Generator {
23 | const initialLengthGen = initialLength != -1 ? just(initialLength) : oneOf(inRange(0, 3), interval(3, 20))
24 | const numChangesGen = numChanges != -1 ? just(numChanges) : interval(1, 20)
25 |
26 | return TupleGen(initialLengthGen, numChangesGen).flatMap(tuple => {
27 | const initialLength = tuple[0]
28 | const numChanges = tuple[1]
29 |
30 | const deltaAndLengthGen = (length: number) =>
31 | DeltaGen(length, withEmbed, withAttr).map<[Delta, number]>(delta => {
32 | const newLength = contentLengthChanged(length, delta)
33 | if (newLength < 0)
34 | throw new Error(
35 | 'unexpected negative length:' + JSONStringify([length, newLength]) + '/' + JSONStringify(delta),
36 | )
37 | return [delta, newLength]
38 | })
39 |
40 | // let deltasAndLengthsGen:Generator<[Delta[], number[]]> = deltaAndLengthGen(initialLength).map(deltaAndLength => [[deltaAndLength[0]], [initialLength, deltaAndLength[1]]])
41 | return deltaAndLengthGen(initialLength)
42 | .accumulate(
43 | deltaAndLength => {
44 | const delta = deltaAndLength[0]
45 | const length = deltaAndLength[1]
46 | if (length < 0) throw new Error('unexpected negative length:' + length + '/' + JSONStringify(delta))
47 | return deltaAndLengthGen(length)
48 | },
49 | numChanges,
50 | numChanges,
51 | )
52 | .map(deltasAndLengths => {
53 | const changeList: ChangeList = { deltas: [], lengths: [] }
54 | deltasAndLengths.forEach(deltaAndLength => {
55 | changeList.deltas.push(deltaAndLength[0])
56 | changeList.lengths.push(deltaAndLength[1])
57 | })
58 | return changeList
59 | })
60 | })
61 | }
62 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Content.ts:
--------------------------------------------------------------------------------
1 | import { Insert, InsertGen } from './Insert'
2 | import { ArraySplitsGen } from './ArraySplit'
3 | import { Generator, interval, just, TupleGen } from 'jsproptest'
4 | import { IDelta } from '../../core/IDelta'
5 |
6 | export function ContentGen(baseLength = -1, withEmbed = true, withAttr = true): Generator {
7 | const baseLengthGen = baseLength >= 0 ? just(baseLength) : interval(1, 20)
8 | return baseLengthGen.flatMap(baseLength => {
9 | if (baseLength > 0) {
10 | const splitsGen = ArraySplitsGen(baseLength)
11 | return splitsGen
12 | .flatMap(splits => {
13 | return TupleGen[]>(
14 | ...splits.map(split => InsertGen(split.length, split.length, withEmbed, withAttr)),
15 | )
16 | })
17 | .map(inserts => {
18 | return { ops: inserts }
19 | })
20 | } else {
21 | return just({ ops: [] })
22 | }
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/src/__tests__/generator/ContentChangeList.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../../core/IDelta'
2 | import { contentLength } from '../../core/primitive'
3 | import { ChangeList, ChangeListGen } from './ChangeList'
4 | import { ContentGen } from './Content'
5 | import { Generator } from 'jsproptest'
6 |
7 | export type ContentChangeList = {
8 | content: IDelta
9 | changeList: ChangeList
10 | }
11 |
12 |
13 | export function ContentChangeListGen(baseLength = -1, numChanges = -1, withEmbed = true, withAttr = true):Generator {
14 | return ContentGen(baseLength, withEmbed, withAttr).flatMap(content => {
15 | const initialLength = contentLength(content)
16 | return ChangeListGen(initialLength, numChanges, withEmbed, withAttr).map(changeList => {
17 | return { content, changeList }
18 | })
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Delta.ts:
--------------------------------------------------------------------------------
1 | import { Delta } from '../../core/Delta'
2 | import { OpsGen } from './Ops'
3 | import { just } from 'jsproptest'
4 |
5 | export function DeltaGen(baseLength = -1, withEmbed = true, withAttr = true) {
6 | return OpsGen(baseLength, withEmbed, withAttr).map(ops => new Delta(ops))
7 | }
8 |
9 | export function EmptyDeltaGen() {
10 | return just(new Delta([]))
11 | }
12 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Embed.ts:
--------------------------------------------------------------------------------
1 | import { Generator, inRange, PrintableASCIIStringGen } from 'jsproptest'
2 |
3 | type Key = 'x' | 'xy' | 'xyz'
4 | export type XorXYorXYZ = { [key in Key]?: string }
5 |
6 | export const EmbedObjGen = (
7 | strGen: Generator = PrintableASCIIStringGen(1, 10),
8 | numKinds = 3,
9 | ): Generator => {
10 | return strGen.flatMap(str =>
11 | inRange(0, numKinds).map(kind => {
12 | if (kind === 0) return { x: str }
13 | else if (kind === 1) return { xy: str }
14 | else return { xyz: str }
15 | }),
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Insert.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import AttributeMap from 'quill-delta/dist/AttributeMap'
3 | import { AttributeMapGen } from './Attribute'
4 | import { EmbedObjGen } from './Embed'
5 | import { booleanGen, Generator, inRange, PrintableASCIIStringGen, TupleGen } from 'jsproptest'
6 |
7 | export interface SimpleInsert extends Op {
8 | insert: string
9 | }
10 |
11 | export interface SimpleEmbed extends Op {
12 | insert: { [key in 'x' | 'xy' | 'xyz']?: string }
13 | }
14 |
15 | export interface Embed extends Op {
16 | insert: { [key in 'x' | 'xy' | 'xyz']?: string }
17 | attributes?: AttributeMap
18 | }
19 |
20 | export interface Insert {
21 | insert: string | { [key in 'x' | 'xy' | 'xyz']?: string }
22 | attributes?: AttributeMap
23 | }
24 |
25 | export function SimpleInsertGen(contentGen: Generator = PrintableASCIIStringGen(1, 10)) {
26 | return contentGen.map(str => {
27 | return { insert: str }
28 | })
29 | }
30 |
31 | export function SimpleEmbedGen() {
32 | return EmbedObjGen().map(obj => {
33 | return { insert: obj }
34 | })
35 | }
36 |
37 | export const EmbedGen = (withAttr: Boolean = true) => {
38 | if (withAttr) {
39 | booleanGen().flatMap(hasAttr => {
40 | if (hasAttr)
41 | return TupleGen(EmbedObjGen(), AttributeMapGen()).map(tuple => {
42 | return {
43 | insert: tuple[0],
44 | attributes: tuple[1],
45 | }
46 | })
47 | else return SimpleEmbedGen()
48 | })
49 | } else {
50 | return SimpleEmbedGen()
51 | }
52 | }
53 |
54 | export function InsertGen(minLength = 1, maxLength = 20, withEmbed = true, withAttr = true) {
55 | const gen: (kind: number) => Generator = kind => {
56 | if (kind === 0)
57 | // insert
58 | return SimpleInsertGen(PrintableASCIIStringGen(minLength, maxLength))
59 | else if (kind == 1) {
60 | // embed
61 | if (withEmbed && minLength <= 1 && maxLength >= 1) return SimpleEmbedGen()
62 | else return SimpleInsertGen(PrintableASCIIStringGen(minLength, maxLength))
63 | } else if (kind == 2) {
64 | // insert with attribute
65 | return TupleGen(
66 | SimpleInsertGen(PrintableASCIIStringGen(minLength, maxLength)).map(obj => obj.insert),
67 | AttributeMapGen(false),
68 | ).map(tuple => {
69 | return { insert: tuple[0], attributes: tuple[1] }
70 | })
71 | } else {
72 | // embed with attribute
73 | if (withEmbed && minLength <= 1 && maxLength >= 1) {
74 | return TupleGen(
75 | SimpleEmbedGen().map(obj => obj.insert),
76 | AttributeMapGen(false),
77 | ).map(tuple => {
78 | return { insert: tuple[0], attributes: tuple[1] }
79 | })
80 | } else
81 | return TupleGen(
82 | SimpleInsertGen(PrintableASCIIStringGen(minLength, maxLength)).map(obj => obj.insert),
83 | AttributeMapGen(false),
84 | ).map(tuple => {
85 | return { insert: tuple[0], attributes: tuple[1] }
86 | })
87 | }
88 | }
89 | return inRange(0, withAttr ? 4 : 2).flatMap(gen)
90 | }
91 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Op.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 |
3 | import { RetainGen, DeleteGen } from './RetainDelete'
4 | import { InsertGen } from './Insert'
5 | import { elementOf, Generator, interval, just } from 'jsproptest'
6 |
7 | export const OpKeyGen = elementOf('retain', 'insert', 'delete')
8 |
9 | function OpGen(minLen = 1, maxLen = 100, withEmbed = true, withAttr = true) {
10 | return interval(0, 2).map(kind => {
11 | if (kind === 0) {
12 | return RetainGen(minLen, maxLen, withAttr)
13 | } else if (kind === 1) {
14 | return InsertGen(minLen, maxLen, withEmbed, withAttr)
15 | } else {
16 | return DeleteGen(minLen, maxLen)
17 | }
18 | })
19 | }
20 |
21 | export const FixedLengthOpGen = (key: string, length: number, withEmbed = true, withAttr = true): Generator => {
22 | if (key === 'retain') {
23 | return RetainGen(length, length, withAttr)
24 | } else if (key === 'insert') {
25 | return InsertGen(length, length, withEmbed, withAttr)
26 | } else {
27 | return DeleteGen(length, length)
28 | }
29 | }
30 |
31 | export const basicOpArbitrary = (minLen: number = 1, maxLen: number = 100) => OpGen(minLen, maxLen, false, false)
32 | export const complexOpArbitrary = (minLen: number = 1, maxLen: number = 100) => OpGen(maxLen, maxLen, true, true)
33 |
34 | // tweak to generate Arbitrary with empty op generator
35 | export const emptyOpsArbitrary = just([])
36 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Ops.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ArrayGen,
3 | booleanGen,
4 | Generator,
5 | inRange,
6 | interval,
7 | just,
8 | oneOf,
9 | SetGen,
10 | TupleGen,
11 | weightedGen,
12 | } from 'jsproptest'
13 | import Op from 'quill-delta/dist/Op'
14 | import * as _ from 'underscore'
15 | import { Delta } from '../../core/Delta'
16 | import { contentLengthChanged, minContentLengthForChange } from '../../core/primitive'
17 | import { ArraySplitsGen } from './ArraySplit'
18 | import { InsertGen } from './Insert'
19 |
20 |
21 | function getSortedArrayFromSet(set: Set): Array {
22 | const arr = new Array()
23 | set.forEach(function(item) {
24 | arr.push(item)
25 | })
26 | return arr.sort((a, b) => (a > b ? 1 : a == b ? 0 : -1))
27 | }
28 |
29 | export function OpsGen(baseLength = -1, withEmbed = true, withAttr = true): Generator {
30 | const baseLengthGen: Generator =
31 | baseLength == -1 ? oneOf(weightedGen(inRange(1, 3), 0.9), inRange(3, 20)) : just(baseLength)
32 |
33 | return baseLengthGen.flatMap(baseLength => {
34 | if (baseLength > 0) {
35 | const splitsGen = ArraySplitsGen(baseLength)
36 | const baseOpsGen = splitsGen.flatMap(splits =>
37 | TupleGen(
38 | ...splits.map(split =>
39 | booleanGen(0.5).flatMap(isRetain => {
40 | // retain
41 | if (isRetain) {
42 | return just({ retain: split.length })
43 | } else {
44 | return just({ delete: split.length })
45 | }
46 | }),
47 | ),
48 | ),
49 | )
50 | return baseOpsGen.flatMap(baseOps => {
51 | expect(contentLengthChanged(baseLength, new Delta(baseOps))).toBeGreaterThanOrEqual(0)
52 |
53 | const numInsertsGen = baseOps.length+1 > 3 ? oneOf(weightedGen(just(0), 0.7), weightedGen(inRange(1, 2), 0.2), inRange(3, baseOps.length+1))
54 | : interval(0, baseOps.length + 1)
55 |
56 | // interval(0, baseOps.length + 1)
57 | return numInsertsGen.flatMap(numInserts => {
58 | const insertPositionsGen = SetGen(interval(0, baseOps.length), numInserts, numInserts).map(set =>
59 | getSortedArrayFromSet(set),
60 | )
61 | const insertsGen = ArrayGen(InsertGen(1, 5, withEmbed, withAttr), numInserts, numInserts)
62 | return TupleGen(insertPositionsGen, insertsGen).map(tuple => {
63 | const insertPositions = tuple[0]
64 | const inserts = tuple[1]
65 | let resultOps: Op[] = []
66 | let pre = 0
67 | for (let i = 0; i < insertPositions.length; i++) {
68 | resultOps = resultOps.concat(baseOps.slice(pre, insertPositions[i]))
69 | pre = insertPositions[i]
70 | resultOps.push(inserts[i])
71 | }
72 | // rest
73 | resultOps = resultOps.concat(baseOps.slice(insertPositions[insertPositions.length - 1]))
74 | expect(minContentLengthForChange(new Delta(baseOps))).toBe(
75 | minContentLengthForChange(new Delta(resultOps)),
76 | )
77 | return resultOps
78 | })
79 | })
80 | })
81 | } else {
82 | return InsertGen(1, 20, withEmbed, withAttr).map(insert => [insert as Op])
83 | }
84 | })
85 | }
86 |
--------------------------------------------------------------------------------
/src/__tests__/generator/Range.ts:
--------------------------------------------------------------------------------
1 | import { Generator, interval, TupleGen } from 'jsproptest'
2 | import { Range } from '../../core/Range'
3 |
4 | export const RangeGen = (minFrom = 0, maxFrom = 100, minLength = 0, maxLength = 100): Generator =>
5 | TupleGen(interval(minFrom, maxFrom), interval(minLength, maxLength)).map(
6 | fromAndLength => new Range(fromAndLength[0], fromAndLength[1]),
7 | )
8 |
--------------------------------------------------------------------------------
/src/__tests__/generator/RetainDelete.ts:
--------------------------------------------------------------------------------
1 | import { AttributeMapGen } from './Attribute'
2 | import Op from 'quill-delta/dist/Op'
3 | import AttributeMap from 'quill-delta/dist/AttributeMap'
4 | import { booleanGen, Generator, inRange, integers, TupleGen } from 'jsproptest'
5 |
6 | export interface SimpleRetain extends Op {
7 | retain: number
8 | }
9 |
10 | export interface Retain extends Op {
11 | retain: number
12 | attributes?: AttributeMap
13 | }
14 |
15 | export function SimpleRetainGen(minLen = 1, maxLen = 100): Generator {
16 | return integers(minLen, maxLen).map(num => {
17 | return { retain: num }
18 | })
19 | }
20 |
21 | export function RetainGen(minLen = 1, maxLen = 100, withAttr = true): Generator {
22 | if (withAttr) {
23 | booleanGen().flatMap(hasAttr => {
24 | if (hasAttr) {
25 | return TupleGen(inRange(minLen, maxLen), AttributeMapGen()).map(tuple => {
26 | return { retain: tuple[0], attributes: tuple[1] } as SimpleRetain
27 | })
28 | } else {
29 | return SimpleRetainGen(minLen, maxLen)
30 | }
31 | })
32 | }
33 | return SimpleRetainGen(minLen, maxLen)
34 | }
35 |
36 | export function DeleteGen(minLen = 1, maxLen = 100) {
37 | return inRange(minLen, maxLen).map(num => {
38 | return { delete: num }
39 | })
40 | }
41 |
--------------------------------------------------------------------------------
/src/__tests__/generator/SharedString.ts:
--------------------------------------------------------------------------------
1 | import { PrintableASCIIStringGen } from 'jsproptest'
2 | import { SharedString } from '../../core/SharedString'
3 |
4 | export function SharedStringGen() {
5 | return PrintableASCIIStringGen(0, 10).map(str => SharedString.fromString(str))
6 | }
7 |
--------------------------------------------------------------------------------
/src/__tests__/jsproptest.spec.ts:
--------------------------------------------------------------------------------
1 | import { Property, interval, stringGen } from 'jsproptest'
2 |
3 | describe('jsproptest', () => {
4 | it('basic', () => {
5 | const prop = new Property((a: number, b: string) => {
6 | return a > 10
7 | })
8 |
9 | expect(() => prop.forAll(interval(0, 10), stringGen(0, 10))).toThrow()
10 | })
11 | })
12 |
--------------------------------------------------------------------------------
/src/__tests__/random.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import * as _ from 'underscore'
3 | import { SharedString } from '../core/SharedString'
4 | import { IDelta } from '../core/IDelta'
5 | import { Delta } from '../core/Delta'
6 | import { Random } from 'jsproptest'
7 |
8 | export function randomString(size: number): string {
9 | // return Math.random()
10 | // .toString(36)
11 | // .substr(2, size)
12 | // return jsc.random(0, Number.MAX_SAFE_INTEGER)
13 | // .toString(36)
14 | // .substr(2, size)
15 |
16 | let text = ''
17 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
18 |
19 | for (let i = 0; i < size; i++) {
20 | text += possible.charAt(Math.floor(Math.random() * possible.length))
21 | }
22 |
23 | return text
24 | }
25 |
26 | export function randomInt(dist: number) {
27 | // return 0~dist-1
28 | // return Math.floor(Math.random() * dist)
29 | const random = new Random()
30 | return random.inRange(0, dist)
31 | }
32 |
33 | export function randomAttribute() {
34 | const kind = randomInt(7)
35 | switch (kind) {
36 | case 0:
37 | return { b: 1 }
38 | case 1:
39 | return { b: null }
40 | case 2:
41 | return { i: 1 }
42 | case 3:
43 | return { i: null }
44 | case 4:
45 | return { b: 1, i: 1 }
46 | case 5:
47 | return { b: null, i: null }
48 | case 6:
49 | default:
50 | return { b: null, i: 1 }
51 | }
52 | }
53 |
54 | export function randomEmbed() {
55 | const kind = randomInt(2)
56 | if (kind === 0) return { x: randomString(2) }
57 | else return { y: randomString(2) }
58 | }
59 |
60 | export function randomInsert(withAttr = true): Op {
61 | const kind = randomInt(withAttr ? 4 : 2)
62 | switch (kind) {
63 | case 0:
64 | // insert
65 | return { insert: randomString(2) }
66 | case 1:
67 | // embed
68 | return { insert: randomEmbed() }
69 | case 2:
70 | // insert with attribute
71 | return { insert: randomString(2), attributes: randomAttribute() }
72 | case 3:
73 | default:
74 | return { insert: randomEmbed(), attributes: randomAttribute() }
75 | // embed with attribute
76 | }
77 | }
78 |
79 | export function randomChanges(baseLength: number, numDeltas: number, withAttr = true): IDelta[] {
80 | const deltas: IDelta[] = []
81 | for (let i = 0; i < numDeltas; i++) {
82 | const delta = new Delta(baseLength > 0 ? randomUserOps(baseLength, withAttr) : [randomInsert(withAttr)])
83 | deltas.push(delta)
84 | baseLength += _.reduce(
85 | delta.ops,
86 | (diff, op) => {
87 | if (op.delete) diff -= op.delete
88 | else if (typeof op.insert === 'string') diff += op.insert.length
89 | else if (op.insert) diff += 1
90 | return diff
91 | },
92 | 0,
93 | )
94 | }
95 | return deltas
96 | }
97 |
98 | function randomSplit(baseLength: number, moreThanOne = true) {
99 | let consumed = 0
100 | let amount = 0
101 | const splits: Array<{ from: number; length: number }> = []
102 |
103 | do {
104 | const remaining = baseLength - consumed
105 | amount = randomInt(Math.max(1, moreThanOne ? remaining - 1 : remaining)) + 1
106 | splits.push({ from: consumed, length: amount })
107 |
108 | consumed += amount
109 | } while (consumed < baseLength)
110 |
111 | return splits
112 | }
113 |
114 | export function randomUserOps(baseLength: number, withAttr = true) {
115 | const baseOps: Op[] = []
116 |
117 | const splits = randomSplit(baseLength)
118 |
119 | for (const split of splits) {
120 | const action = randomInt(withAttr ? 3 : 2)
121 | if (action === 0) {
122 | baseOps.push({ retain: split.length })
123 | } else if (action === 1) {
124 | baseOps.push({ delete: split.length })
125 | } else {
126 | baseOps.push({ retain: split.length, attributes: randomAttribute() })
127 | }
128 | }
129 |
130 | if (randomInt(2) === 0) return baseOps
131 |
132 | const insertPositions = randomSplit(splits.length + 1, false).map(split => split.from)
133 |
134 | let resultOps: Op[] = []
135 | let pre = 0
136 | for (const insertPos of insertPositions) {
137 | resultOps = resultOps.concat(baseOps.slice(pre, insertPos))
138 | resultOps.push(randomInsert(withAttr))
139 | pre = insertPos
140 | }
141 | resultOps = resultOps.concat(baseOps.slice(insertPositions[insertPositions.length - 1]))
142 |
143 | return resultOps
144 | }
145 |
146 | export function randomSharedString() {
147 | return SharedString.fromString(randomString(randomInt(10) + 1))
148 | }
149 |
--------------------------------------------------------------------------------
/src/__tests__/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "max-classes-per-file":false,
5 | "curly": false,
6 | "interface-name": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": [
9 | true,
10 | {
11 | "named-imports-order": "any"
12 | }
13 | ],
14 | "no-bitwise": false,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/__tests__/underscore.spec.ts:
--------------------------------------------------------------------------------
1 | import * as _ from 'underscore'
2 |
3 | describe('underscore', () => {
4 | it('basics', () => {
5 | const double = _.map([1, 2, 3], num => {
6 | return num * 2
7 | })
8 | expect(double[2] === 6).toBe(true)
9 | })
10 |
11 | it('immutability', () => {
12 | const original = [1, 2, 3]
13 | const double = _.map(original, num => {
14 | return num * 2
15 | })
16 | expect(double[2] === 6).toBe(true)
17 | expect(original[2] === 3).toBe(true)
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/core/Delta.ts:
--------------------------------------------------------------------------------
1 | import AttributeMap from 'quill-delta/dist/AttributeMap'
2 | import Op from 'quill-delta/dist/Op'
3 | import { DeltaContext } from './DeltaContext'
4 | import { IDelta } from './IDelta'
5 | import {
6 | contentLength,
7 | hasNoEffect,
8 | cropContent,
9 | normalizeOps,
10 | deltaLength,
11 | minContentLengthForChange,
12 | flattenDeltas,
13 | transformDeltas,
14 | invertChange,
15 | } from './primitive'
16 |
17 | export class Delta implements IDelta {
18 | constructor(public ops: Op[] = [], public context?: DeltaContext) {}
19 |
20 | static clone(delta:IDelta) {
21 | return new Delta(delta.ops.concat(), delta.context)
22 | }
23 |
24 | /* changes the object */
25 | public delete(count: number): Delta {
26 | if (count <= 0) return this
27 |
28 | this.ops.push({ delete: count })
29 | return this
30 | }
31 |
32 | /* changes the object */
33 | public retain(count: number): Delta {
34 | if (count <= 0) return this
35 |
36 | this.ops.push({ retain: count })
37 | return this
38 | }
39 |
40 | /* changes the object */
41 | public insert(content: string | object, attributes?: AttributeMap): Delta {
42 | if (attributes) this.ops.push({ insert: content, attributes })
43 | else this.ops.push({ insert: content })
44 | return this
45 | }
46 |
47 | public length(): number {
48 | return deltaLength(this)
49 | }
50 |
51 | /* only for content (should have no retains or deletes)*/
52 | public contentLength(): number {
53 | return contentLength(this)
54 | }
55 |
56 | public minRequiredBaseContentLength(): number {
57 | return minContentLengthForChange(this)
58 | }
59 |
60 | public hasNoEffect(): boolean {
61 | return hasNoEffect(this)
62 | }
63 |
64 | public take(start: number, end: number): Delta {
65 | return new Delta(cropContent(this, start, end).ops, this.context)
66 | }
67 |
68 | public normalize(): Delta {
69 | return new Delta(normalizeOps(this.ops), this.context)
70 | }
71 |
72 | public compose(other: IDelta): Delta {
73 | return new Delta(flattenDeltas(this, other).ops, this.context)
74 | }
75 |
76 | public apply(other: IDelta): Delta {
77 | return this.compose(other)
78 | }
79 |
80 | public transform(other: IDelta, priority = false): Delta {
81 | return new Delta(transformDeltas(this, other, priority).ops, this.context)
82 | }
83 |
84 | public invert(baseContent: IDelta): IDelta {
85 | return new Delta(invertChange(baseContent, this).ops, this.context)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/core/DeltaComposer.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import { sliceOp, opLength } from './primitive'
3 |
4 | export class DeltaComposer {
5 | private idx = 0
6 | private offset = 0
7 |
8 | constructor(public ops: Op[]) {}
9 |
10 | public retain(amount: number): Op[] {
11 | return this.mapCurrent((op, begin, end) => [sliceOp(op, begin, end)], amount)
12 | }
13 |
14 | public delete(amount: number): Op[] {
15 | return this.mapCurrent((op, begin, end) => {
16 | if (op.insert) return []
17 | end = end ? end : opLength(op)
18 | return [{ delete: end - begin }]
19 | }, amount)
20 | }
21 |
22 | // insert/embed consumes nothing, so no need to advance
23 |
24 | public insert(str: string): Op[] {
25 | return [{ insert: str }]
26 | }
27 |
28 | public insertWithAttribute(str: string, attr: object): Op[] {
29 | return [{ insert: str, attributes: attr }]
30 | }
31 |
32 | public embed(obj: object): Op[] {
33 | return [{ insert: obj }]
34 | }
35 |
36 | public embedWithAttribute(obj: object, attr: object): Op[] {
37 | return [{ insert: obj, attributes: attr }]
38 | }
39 |
40 | public current(): Op {
41 | return this.ops[this.idx]
42 | }
43 |
44 | public currentSize(): number {
45 | const op = this.ops[this.idx]
46 |
47 | return opLength(op)
48 | }
49 |
50 | public sliceCurrent(offset: number): Op {
51 | return sliceOp(this.current(), offset)
52 | }
53 |
54 | public hasNext(): boolean {
55 | return this.idx < this.ops.length
56 | }
57 |
58 | public rest(): Op[] {
59 | const ops = this.hasNext() ? [this.sliceCurrent(this.offset)] : []
60 | return ops.concat(this.ops.slice(this.idx + 1))
61 | }
62 |
63 | private nextOp(): void {
64 | this.idx++
65 | this.offset = 0
66 | }
67 |
68 | private nextUntilVisible(): Op[] {
69 | const ops: Op[] = []
70 | while (this.hasNext() && this.current().delete) {
71 | ops.push(this.current())
72 | this.nextOp()
73 | }
74 | return ops
75 | }
76 |
77 | private mapCurrent(opGen: (op: Op, begin: number, end?: number) => Op[], amount: number): Op[] {
78 | let ops: Op[] = []
79 |
80 | do {
81 | // retain (taking fragments) if current fragment is not visible
82 | ops = ops.concat(this.nextUntilVisible())
83 | if (!this.hasNext() || amount <= 0) break
84 |
85 | // current: visible fragment
86 | const remaining = this.currentSize() - (this.offset + amount)
87 | if (remaining > 0) {
88 | // take some of current and finish
89 | ops = ops.concat(opGen(this.current(), this.offset, this.offset + amount))
90 | this.offset += amount
91 | return ops
92 | } else if (remaining === 0) {
93 | // take rest of current and finish
94 | ops = ops.concat(opGen(this.current(), this.offset))
95 | this.nextOp()
96 | return ops
97 | } else {
98 | // overwhelms current fragment
99 | // first take rest of current
100 | const takeAmount = this.currentSize() - this.offset
101 |
102 | ops = ops.concat(opGen(this.current(), this.offset))
103 | // adjust amount
104 | amount -= takeAmount // > 0 by condition
105 | this.nextOp()
106 | }
107 | } while (amount > 0 && this.hasNext())
108 |
109 | if (amount > 0) {
110 | // pseudo-retain
111 | ops = ops.concat(opGen({ retain: amount }, 0))
112 | }
113 |
114 | return ops
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/core/DeltaContext.ts:
--------------------------------------------------------------------------------
1 | export interface DeltaContext {
2 | sourceUri: string
3 | sourceRev: number
4 | }
5 |
--------------------------------------------------------------------------------
/src/core/DeltaIterator.ts:
--------------------------------------------------------------------------------
1 | import AttributeMap from 'quill-delta/dist/AttributeMap'
2 | import Op from 'quill-delta/dist/Op'
3 | import * as _ from 'underscore'
4 | import { AttributeFragment, Fragment } from './Fragment'
5 |
6 | // result fragments and transformed ops
7 | interface OpsWithDiff {
8 | ops: Op[]
9 | diff: number
10 | }
11 |
12 | export class DeltaIterator {
13 | private fragmentIdx = 0
14 | private offsetAtFragment = 0
15 |
16 | constructor(public readonly branch: string, public fragments: Fragment[]) {}
17 |
18 | public retain(amount: number): OpsWithDiff {
19 | return this.mapCurrent(amnt => ({ retain: amnt }), amount)
20 | }
21 |
22 | public delete(amount: number): OpsWithDiff {
23 | return this.mapCurrent(amnt => ({ delete: amnt }), amount)
24 | }
25 |
26 | // insert/embed consumes nothing, so no need to advance
27 |
28 | public insert(str: string): Op[] {
29 | return this.inserted({ insert: str })
30 | }
31 |
32 | public insertWithAttribute(str: string, attr: object): Op[] {
33 | return this.inserted({ insert: str, attributes: attr })
34 | }
35 |
36 | public embed(obj: object): Op[] {
37 | return this.inserted({ insert: obj })
38 | }
39 |
40 | public embedWithAttribute(obj: object, attr: object): Op[] {
41 | return this.inserted({ insert: obj, attributes: attr })
42 | }
43 |
44 | public current(): Fragment {
45 | return this.fragments[this.fragmentIdx]
46 | }
47 |
48 | public hasNext(): boolean {
49 | return this.fragmentIdx < this.fragments.length
50 | }
51 |
52 | private nextFragment(): void {
53 | this.fragmentIdx++
54 | this.offsetAtFragment = 0
55 | }
56 |
57 | private nextUntilVisible(): Op[] {
58 | const ops: Op[] = []
59 | // deleted by other + deleted by me
60 | while (this.hasNext() && !this.current().isVisibleTo(this.branch)) {
61 | // inserted by other, retain added
62 | if (this.current().isInsertedByNonWildcardOther(this.branch) && !this.current().isDeleted()) {
63 | ops.push({ retain: this.current().size() - this.offsetAtFragment })
64 | }
65 | // else: deleted by me: do nothing
66 | // go to next fragment
67 | this.nextFragment()
68 | }
69 | return ops
70 | }
71 |
72 | private mapCurrent(opGen: (amount: number, attrFragment?: AttributeFragment) => Op, amount: number): OpsWithDiff {
73 | let ops: Op[] = []
74 |
75 | do {
76 | // retain (taking fragments) if current fragment is not visible
77 | ops = ops.concat(this.nextUntilVisible())
78 | if (!this.hasNext() || amount <= 0) break
79 |
80 | // current: visible fragment
81 | const remaining = this.current().size() - (this.offsetAtFragment + amount)
82 | if (remaining > 0) {
83 | // take some of current and finish
84 | if (!this.current().isDeletedByNonWildcardOther(this.branch)) {
85 | ops.push(opGen(amount, this.current().attrs))
86 | }
87 | this.offsetAtFragment += amount
88 | return { ops, diff: 0 }
89 | } else if (remaining === 0) {
90 | // take rest of current and finish
91 | if (!this.current().isDeletedByNonWildcardOther(this.branch)) {
92 | ops.push(opGen(this.current().size() - this.offsetAtFragment, this.current().attrs))
93 | }
94 | this.nextFragment()
95 | return { ops, diff: 0 }
96 | } else {
97 | // overwhelms current fragment
98 | // first take rest of current
99 | const takeAmount = this.current().size() - this.offsetAtFragment
100 | if (!this.current().isDeletedByNonWildcardOther(this.branch)) {
101 | ops.push(opGen(takeAmount, this.current().attrs))
102 | }
103 | // adjust amount
104 | amount -= takeAmount // > 0 by condition
105 | this.nextFragment()
106 | }
107 | } while (amount > 0 && this.hasNext())
108 |
109 | return { ops, diff: amount < 0 ? amount : 0 }
110 | }
111 |
112 | private inserted(op: Op): Op[] {
113 | const ops = this.nextForInsert()
114 | return ops.concat(op)
115 | }
116 |
117 | private nextForInsert(): Op[] {
118 | let retain = 0
119 | // if it's not visible, should advancefor tiebreak
120 | while (this.hasNext() && this.current().shouldAdvanceForTiebreak(this.branch)) {
121 | if (!this.current().isDeleted()) {
122 | retain += this.current().size() - this.offsetAtFragment
123 | }
124 | this.nextFragment()
125 | }
126 | return retain > 0 ? [{ retain }] : []
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/core/DeltaTransformer.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import { sliceOp, opLength } from './primitive'
3 |
4 | export class DeltaTransformer {
5 | private idx = 0
6 | private offset = 0
7 |
8 | constructor(public ops: Op[], private lastWins: boolean) {}
9 |
10 | public retain(amount: number): Op[] {
11 | return this.mapCurrent((op, begin, end) => {
12 | end = end ? end : opLength(op)
13 | return [{ retain: end - begin }]
14 | }, amount)
15 | }
16 |
17 | public delete(amount: number): Op[] {
18 | return this.mapCurrent((op, begin, end) => {
19 | end = end ? end : opLength(op)
20 | // console.log('delete frag:', JSONStringify({delete: end - begin}))
21 | return [{ delete: end - begin }]
22 | }, amount)
23 | }
24 |
25 | // insert/embed consumes nothing, so no need to advance
26 |
27 | public insert(str: string): Op[] {
28 | if (this.current().insert) {
29 | const currentLength = opLength(this.current())
30 | this.nextOp()
31 | if (!this.lastWins) {
32 | return [{ retain: currentLength }, { insert: str }]
33 | } else {
34 | return [{ insert: str }, { retain: currentLength }]
35 | }
36 | } else {
37 | return [{ insert: str }]
38 | }
39 | }
40 |
41 | public insertWithAttribute(str: string, attributes: object): Op[] {
42 | if (this.current().insert) {
43 | const currentLength = opLength(this.current())
44 | this.nextOp()
45 | if (!this.lastWins) {
46 | return [{ retain: currentLength }, { insert: str, attributes }]
47 | } else {
48 | return [{ insert: str, attributes }, { retain: currentLength }]
49 | }
50 | } else {
51 | return [{ insert: str, attributes }]
52 | }
53 | }
54 |
55 | public embed(obj: object): Op[] {
56 | if (this.current().insert) {
57 | const currentLength = opLength(this.current())
58 | this.nextOp()
59 | if (!this.lastWins) {
60 | return [{ retain: currentLength }, { insert: obj }]
61 | } else {
62 | return [{ insert: obj }, { retain: currentLength }]
63 | }
64 | } else {
65 | return [{ insert: obj }]
66 | }
67 | }
68 |
69 | public embedWithAttribute(obj: object, attributes: object): Op[] {
70 | if (this.current().insert) {
71 | const currentLength = opLength(this.current())
72 | this.nextOp()
73 | if (!this.lastWins) {
74 | return [{ retain: currentLength }, { insert: obj, attributes }]
75 | } else {
76 | return [{ insert: obj, attributes }, { retain: currentLength }]
77 | }
78 | } else {
79 | return [{ insert: obj, attributes }]
80 | }
81 | }
82 |
83 | public current(): Op {
84 | return this.ops[this.idx]
85 | }
86 |
87 | public currentSize(): number {
88 | const op = this.ops[this.idx]
89 |
90 | return opLength(op)
91 | }
92 |
93 | public sliceCurrent(offset: number): Op {
94 | return sliceOp(this.current(), offset)
95 | }
96 |
97 | public hasNext(): boolean {
98 | return this.idx < this.ops.length
99 | }
100 |
101 | public rest(): Op[] {
102 | const ops = this.hasNext() ? [this.sliceCurrent(this.offset)] : []
103 | return ops.concat(this.ops.slice(this.idx + 1))
104 | }
105 |
106 | private nextOp(): void {
107 | this.idx++
108 | this.offset = 0
109 | }
110 |
111 | private mapCurrent(opGen: (op: Op, begin: number, end?: number) => Op[], amount: number): Op[] {
112 | let ops: Op[] = []
113 |
114 | do {
115 | // retain (taking fragments) if current fragment is not visible
116 | if (!this.hasNext() || amount <= 0) break
117 |
118 | if (this.current().insert) {
119 | ops.push({ retain: this.currentSize() })
120 | this.nextOp()
121 | continue
122 | }
123 |
124 | // current: visible fragment
125 | const remaining = this.currentSize() - (this.offset + amount)
126 | if (remaining > 0) {
127 | // take some of current and finish
128 | if (!this.current().delete) {
129 | ops = ops.concat(opGen(this.current(), this.offset, this.offset + amount))
130 | }
131 | this.offset += amount
132 | return ops
133 | } else if (remaining === 0) {
134 | // take rest of current and finish
135 | if (!this.current().delete) ops = ops.concat(opGen(this.current(), this.offset))
136 | this.nextOp()
137 | return ops
138 | } else {
139 | // overwhelms current fragment
140 | // first take rest of current
141 | const takeAmount = this.currentSize() - this.offset
142 | if (!this.current().delete) {
143 | ops = ops.concat(opGen(this.current(), this.offset))
144 | }
145 | // adjust amount
146 | amount -= takeAmount // > 0 by condition
147 | this.nextOp()
148 | }
149 | } while (amount > 0 && this.hasNext())
150 |
151 | if (amount > 0) {
152 | ops = ops.concat(opGen(this.current(), 0, amount))
153 | }
154 |
155 | return ops
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/src/core/Embedded.ts:
--------------------------------------------------------------------------------
1 | export class Embedded {
2 | public readonly length: number = 1
3 |
4 | constructor(public readonly value: object) {}
5 |
6 | public slice(begin?: number, end?: number): Embedded {
7 | return new Embedded({ ...this.value })
8 | }
9 |
10 | public concat(): Embedded {
11 | return new Embedded({ ...this.value })
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/core/Fragment.ts:
--------------------------------------------------------------------------------
1 | import AttributeMap from 'quill-delta/dist/AttributeMap'
2 | import Op from 'quill-delta/dist/Op'
3 | import * as _ from 'underscore'
4 | import { Embedded } from './Embedded'
5 | import { Modification, Status } from './Modification'
6 | import { JSONStringify } from './util'
7 |
8 | export interface AttributeFragment {
9 | val?: AttributeMap
10 | }
11 |
12 | export interface JSONEmbed {
13 | type: 'embed'
14 | value: object
15 | }
16 |
17 | export interface JSONStyle {
18 | type: 'initial' | 'inserted' | 'deleted' | 'unknown'
19 | value: string | JSONEmbed
20 | attributes?: AttributeMap
21 | }
22 |
23 | export class Fragment {
24 | public static initial(str: string, attrs?: AttributeMap): Fragment {
25 | if (attrs) {
26 | return new Fragment(str, { val: attrs })
27 | } else return new Fragment(str)
28 | }
29 |
30 | public static initialEmbedded(obj: object, attrs?: AttributeMap): Fragment {
31 | if (attrs) {
32 | return new Fragment(new Embedded(obj), { val: attrs })
33 | } else return new Fragment(new Embedded(obj))
34 | }
35 |
36 | public static insert(str: string, branch: string): Fragment {
37 | return new Fragment(str, undefined, branch)
38 | }
39 |
40 | public static insertWithAttribute(str: string, attrs: AttributeMap, branch: string): Fragment {
41 | return new Fragment(str, { val: attrs }, branch)
42 | }
43 |
44 | public static embed(obj: object, branch: string): Fragment {
45 | return new Fragment(new Embedded(obj), undefined, branch)
46 | }
47 |
48 | public static embedWithAttribute(obj: object, attrs: AttributeMap, branch: string): Fragment {
49 | return new Fragment(new Embedded(obj), { val: attrs }, branch)
50 | }
51 |
52 | public readonly val: string | Embedded
53 | public readonly attrs?: AttributeFragment
54 | public readonly mod: Modification
55 |
56 | constructor(
57 | val: string | Embedded,
58 | attrs?: AttributeFragment,
59 | insertedBy?: string,
60 | deletedBy: Set = new Set(),
61 | ) {
62 | this.val = val
63 | this.attrs = attrs
64 | this.mod = new Modification(insertedBy, deletedBy)
65 | }
66 |
67 | public size() {
68 | return this.val.length
69 | }
70 |
71 | public clone(): Fragment {
72 | return new Fragment(this.val.concat(), this.attrs, this.mod.insertedBy, new Set(this.mod.deletedBy))
73 | }
74 |
75 | public slice(begin: number, end?: number): Fragment {
76 | return new Fragment(this.val.slice(begin, end), this.attrs, this.mod.insertedBy, new Set(this.mod.deletedBy))
77 | }
78 |
79 | public sliceWithDelete(branch: string, begin: number, end?: number): Fragment {
80 | const newDeletedBy = new Set(this.mod.deletedBy).add(branch)
81 | return new Fragment(this.val.slice(begin, end), this.attrs, this.mod.insertedBy, newDeletedBy)
82 | }
83 |
84 | public isInsertedByNonWildcardOther(branch: string) {
85 | return this.mod.isInsertedByNonWildcardOther(branch)
86 | }
87 |
88 | public isVisibleTo(branch: string) {
89 | return this.mod.isVisibleTo(branch)
90 | }
91 |
92 | public shouldAdvanceForTiebreak(branch: string) {
93 | // use tiebreaking string comparison on inserted branch
94 | return this.mod.shouldAdvanceForTiebreak(branch)
95 | }
96 |
97 | public isInserted() {
98 | return this.mod.isInserted()
99 | }
100 |
101 | public isDeleted() {
102 | return this.mod.isDeleted()
103 | }
104 |
105 | public isDeletedByNonWildcardOther(branch: string) {
106 | return this.mod.isDeletedByNonWildcardOther(branch)
107 | }
108 |
109 | public equals(cs: Fragment) {
110 | return _.isEqual(this.val, cs.val) && _.isEqual(this.attrs, this.attrs) && this.mod.equals(cs.mod)
111 | }
112 |
113 | public toFlattenedOp(): Op {
114 | if (this.mod.isDeleted()) return { delete: this.val.length }
115 |
116 | const attributes = this.getAttributes()
117 |
118 | if (this.mod.isInserted()) {
119 | const insert = this.val
120 |
121 | if (_.isEmpty(attributes)) return { insert }
122 | else return { insert, attributes }
123 | }
124 |
125 | const retain = this.val.length
126 | if (_.isEmpty(attributes)) return { retain }
127 | else return { retain, attributes }
128 | }
129 |
130 | public toOp(): Op {
131 | let insert: string | object = ''
132 |
133 | if (typeof this.val === 'string') {
134 | insert = this.val
135 | } else {
136 | insert = this.val.value
137 | }
138 |
139 | if (!this.attrs || _.isEmpty(this.attrs)) return { insert }
140 | else {
141 | const attributes = this.getAttributes()
142 | if (_.isEmpty(attributes)) return { insert }
143 | else return { insert, attributes: this.getAttributes() }
144 | }
145 | }
146 |
147 | public toText(): string {
148 | // TODO: attributes
149 | switch (this.mod.status) {
150 | case Status.INITIAL:
151 | case Status.INSERTED:
152 | return typeof this.val === 'string' ? this.val : this.val.toString()
153 | default:
154 | return ''
155 | }
156 | }
157 |
158 | public toHtml(includeBranches = true): string {
159 | // TODO: attributes
160 | const valueStr =
161 | typeof this.val === 'string' ? this.val : `${JSONStringify(this.val)}`
162 | switch (this.mod.status) {
163 | case Status.INITIAL:
164 | return `${valueStr}`
165 | case Status.DELETED: {
166 | const Bclasses = _.map(Array.from(this.mod.deletedBy), key => {
167 | return 'B' + key
168 | })
169 | if (includeBranches) return `${valueStr}`
170 | else return `${valueStr}`
171 | }
172 | case Status.INSERTED:
173 | if (includeBranches) return `${valueStr}`
174 | else return `${valueStr}`
175 | case Status.INSERTED_THEN_DELETED:
176 | if (includeBranches)
177 | return `${valueStr}`
178 | else return `${valueStr}`
179 | default:
180 | return ''
181 | }
182 | }
183 |
184 | public toStyledJSON(): JSONStyle {
185 | const attributes = this.getAttributes()
186 | const valueStr: string | JSONEmbed =
187 | typeof this.val === 'string' ? this.val : { type: 'embed', value: this.val.value }
188 | switch (this.mod.status) {
189 | case Status.INITIAL:
190 | return { type: 'initial', value: valueStr, attributes }
191 | case Status.INSERTED_THEN_DELETED:
192 | case Status.DELETED: {
193 | return { type: 'deleted', value: valueStr, attributes }
194 | }
195 | case Status.INSERTED:
196 | return { type: 'inserted', value: valueStr, attributes }
197 | default:
198 | return { type: 'unknown', value: valueStr, attributes }
199 | }
200 | }
201 |
202 | // flattened attributes
203 | public getAttributes(): AttributeMap {
204 | if (!this.attrs) return {}
205 |
206 | return this.attrValWithoutNullFields()
207 | }
208 |
209 | private attrValWithoutNullFields(): AttributeMap {
210 | if (!this.attrs || !this.attrs.val) return {}
211 |
212 | const result: AttributeMap = {}
213 | for (const field of Object.keys(this.attrs.val)) {
214 | if (this.attrs.val[field] !== null) {
215 | result[field] = this.attrs.val[field]
216 | }
217 | }
218 | return result
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/src/core/FragmentIterator.ts:
--------------------------------------------------------------------------------
1 | import { Fragment } from './Fragment'
2 |
3 | // result fragments and transformed ops
4 | export interface IResult {
5 | fragments: Fragment[]
6 | diff: number
7 | }
8 |
9 | export class FragmentIterator {
10 | private fragmentIdx = 0
11 | private offsetAtFragment = 0
12 |
13 | constructor(public readonly branch: string, public fragments: Fragment[]) {}
14 |
15 | public current(): Fragment {
16 | return this.fragments[this.fragmentIdx]
17 | }
18 |
19 | public hasNext(): boolean {
20 | return this.fragmentIdx < this.fragments.length
21 | }
22 |
23 | public nextPeek(): Fragment {
24 | return this.fragments[this.fragmentIdx + 1]
25 | }
26 |
27 | public rest(): Fragment[] {
28 | const fragments = this.hasNext() ? [this.current().slice(this.offsetAtFragment)] : []
29 | return fragments.concat(this.fragments.slice(this.fragmentIdx + 1))
30 | }
31 |
32 | public retain(amount: number): Fragment[] {
33 | return this.mapCurrent((fragment, begin, end) => fragment.slice(begin, end), amount)
34 | }
35 |
36 | public delete(amount: number): Fragment[] {
37 | return this.mapCurrent((fragment, begin, end) => fragment.sliceWithDelete(this.branch, begin, end), amount)
38 | }
39 |
40 | // insert/embed consumes nothing, so no need to advance
41 |
42 | public insert(str: string): Fragment[] {
43 | return this.inserted(Fragment.insert(str, this.branch))
44 | }
45 |
46 | public insertWithAttribute(str: string, attr: { [name: string]: object }): Fragment[] {
47 | return this.inserted(Fragment.insertWithAttribute(str, attr, this.branch))
48 | }
49 |
50 | public embed(obj: object): Fragment[] {
51 | return this.inserted(Fragment.embed(obj, this.branch))
52 | }
53 |
54 | public embedWithAttribute(obj: object, attr: { [name: string]: object }): Fragment[] {
55 | return this.inserted(Fragment.embedWithAttribute(obj, attr, this.branch))
56 | }
57 |
58 | private nextFragment(): void {
59 | this.fragmentIdx++
60 | this.offsetAtFragment = 0
61 | }
62 |
63 | private nextUntilVisible(): Fragment[] {
64 | const fragments: Fragment[] = []
65 |
66 | while (this.hasNext() && !this.current().isVisibleTo(this.branch)) {
67 | // take rest of current fragment
68 | fragments.push(this.current().slice(this.offsetAtFragment))
69 | // go to next fragment
70 | this.nextFragment()
71 | }
72 |
73 | return fragments
74 | }
75 |
76 | private mapCurrent(
77 | fragmentGen: (fragment: Fragment, begin: number, end?: number) => Fragment,
78 | amount: number,
79 | ): Fragment[] {
80 | let fragments: Fragment[] = []
81 |
82 | do {
83 | // skip (taking fragments) if current fragment is not visible
84 | fragments = fragments.concat(this.nextUntilVisible())
85 | if (!this.hasNext()) break
86 |
87 | // current: visible fragment
88 | const remaining = this.current().size() - (this.offsetAtFragment + amount)
89 | if (remaining > 0) {
90 | // take some of current and finish
91 | fragments.push(fragmentGen(this.current(), this.offsetAtFragment, this.offsetAtFragment + amount))
92 | this.offsetAtFragment += amount
93 | return fragments
94 | } else if (remaining === 0) {
95 | // take rest of current and finish
96 | fragments.push(fragmentGen(this.current(), this.offsetAtFragment))
97 | this.nextFragment()
98 | return fragments
99 | } else {
100 | // overwhelms current fragment
101 | // first take rest of current
102 | fragments.push(fragmentGen(this.current(), this.offsetAtFragment))
103 |
104 | // adjust amount
105 | amount -= this.current().size() - this.offsetAtFragment
106 | this.nextFragment()
107 | }
108 | } while (amount > 0 && this.hasNext())
109 |
110 | return fragments
111 | }
112 |
113 | private inserted(fragment: Fragment): Fragment[] {
114 | if (this.hasNext() && this.current().isVisibleTo(this.branch)) {
115 | // take current and done, happy
116 | return [fragment]
117 | } else {
118 | // find fragment position with tiebreak
119 | const fragments = this.nextForInsert()
120 | return fragments.concat(fragment)
121 | }
122 | }
123 |
124 | private nextForInsert(): Fragment[] {
125 | const fragments: Fragment[] = []
126 | // if it's not visible, should advancefor tiebreak
127 | while (this.hasNext() && this.current().shouldAdvanceForTiebreak(this.branch)) {
128 | fragments.push(this.current().slice(this.offsetAtFragment))
129 | this.nextFragment()
130 | }
131 | return fragments
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/core/IDelta.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import { DeltaContext } from './DeltaContext'
3 |
4 | export interface IDelta {
5 | ops: Op[]
6 | context?: DeltaContext
7 | }
8 |
--------------------------------------------------------------------------------
/src/core/Modification.ts:
--------------------------------------------------------------------------------
1 | export enum Status {
2 | INITIAL = 0,
3 | DELETED,
4 | INSERTED,
5 | INSERTED_THEN_DELETED,
6 | }
7 |
8 | export class Modification {
9 | public readonly insertedBy?: string
10 | public readonly deletedBy: Set
11 |
12 | constructor(insertedBy?: string, deletedBy: Set = new Set()) {
13 | this.insertedBy = insertedBy
14 | this.deletedBy = deletedBy
15 | }
16 |
17 | public get status(): Status {
18 | if (this.insertedBy) {
19 | return this.isDeleted() ? Status.INSERTED_THEN_DELETED : Status.INSERTED
20 | }
21 | return this.isDeleted() ? Status.DELETED : Status.INITIAL
22 | }
23 |
24 | public isVisibleTo(branch: string): boolean {
25 | // wildcard can see any change unless it's deleted
26 | if (branch === '*' || branch === '_') return !this.isDeleted()
27 |
28 | // if deleted by myself or a wildcard, then it's not visible
29 | if (this.isDeletedBy(branch) || this.isDeletedByWildcard()) return false
30 | // if inserted by other but not a wildcard, then it's not visible
31 | if (this.isInsertedByNonWildcardOther(branch)) return false
32 |
33 | return true
34 | }
35 |
36 | public shouldAdvanceForTiebreak(branch: string): boolean {
37 | // use tiebreaking string comparison on inserted branch
38 | return this.insertedBy !== undefined && this.insertedBy < branch
39 | }
40 |
41 | public isInserted(): boolean {
42 | return this.insertedBy !== undefined
43 | }
44 |
45 | public isInsertedByOther(branch: string): boolean {
46 | return this.insertedBy !== undefined && this.insertedBy !== branch
47 | }
48 |
49 | public isInsertedByNonWildcardOther(branch: string): boolean {
50 | return this.isInsertedByOther(branch) && !this.isInsertedByWildcard()
51 | }
52 |
53 | public isDeleted(): boolean {
54 | return this.deletedBy.size > 0
55 | }
56 |
57 | public isDeletedByOther(branch: string): boolean {
58 | return this.isDeleted() && !this.deletedBy.has(branch)
59 | }
60 |
61 | public isDeletedByNonWildcardOther(branch: string): boolean {
62 | return this.isDeletedByOther(branch) && !this.isDeletedByWildcard()
63 | }
64 |
65 | public equals(md: Modification): boolean {
66 | if (this.deletedBy.size !== md.deletedBy.size) {
67 | return false
68 | }
69 |
70 | for (const elem in this.deletedBy) {
71 | if (!md.deletedBy.has(elem)) {
72 | return false
73 | }
74 | }
75 |
76 | return md.insertedBy === md.insertedBy
77 | }
78 |
79 | private isDeletedBy(branch: string): boolean {
80 | return this.deletedBy.has(branch)
81 | }
82 |
83 | private isInsertedByWildcard(): boolean {
84 | return this.insertedBy === '*'
85 | }
86 |
87 | private isDeletedByWildcard(): boolean {
88 | return this.isDeleted() && this.deletedBy.has('*')
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/core/Range.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import { Delta } from './Delta'
3 | import { IDelta } from './IDelta'
4 | import { normalizeOps, contentLength } from './primitive'
5 | import { SharedString } from './SharedString'
6 |
7 | export interface RangedTransforms {
8 | range: Range
9 | deltas: IDelta[]
10 | }
11 |
12 | export class Range {
13 | constructor(public readonly start: number, public readonly end: number) {}
14 |
15 | // immutable
16 | public applyChanges(changes: IDelta[], open = false): Range {
17 | let range: Range = this
18 | for (const change of changes) {
19 | range = open ? range.applyChangeOpen(change) : range.applyChange(change)
20 | }
21 | return range
22 | }
23 |
24 | public mapChanges(changes: IDelta[], open = false): Range[] {
25 | let range: Range = this
26 | const ranges: Range[] = []
27 | for (const change of changes) {
28 | range = open ? range.applyChangeOpen(change) : range.applyChange(change)
29 | ranges.push(range)
30 | }
31 | return ranges
32 | }
33 |
34 | // immutable
35 | public applyChange(change: IDelta): Range {
36 | let cursor = 0
37 | let start = this.start
38 | let end = this.end
39 |
40 | for (const op of change.ops) {
41 | // console.log(' op:', op, 'cursor:', cursor, 'start:', start, 'end:', end)
42 | if (op.retain) {
43 | cursor += op.retain
44 | } else if (typeof op.insert === 'string') {
45 | const amount = op.insert.toString().length
46 |
47 | if (cursor <= start) {
48 | start += amount
49 | }
50 | end += amount
51 | cursor += amount
52 | } else if (op.insert) {
53 | if (cursor <= start) {
54 | start += 1
55 | }
56 | end += 1
57 | cursor += 1
58 | } else if (op.delete) {
59 | if (cursor <= start) start = Math.max(cursor, start - op.delete)
60 |
61 | end = Math.max(cursor, end - op.delete)
62 | }
63 |
64 | if (cursor >= end)
65 | // 'cursor > end' for same effect as transformPosition(end)
66 | break
67 | }
68 | return new Range(start, end)
69 | }
70 |
71 | // immutable
72 | public applyChangeOpen(change: IDelta): Range {
73 | let cursor = 0
74 | let start = this.start
75 | let end = this.end
76 |
77 | for (const op of change.ops) {
78 | // console.log(' op:', op, 'cursor:', cursor, 'start:', start, 'end:', end)
79 | if (op.retain) {
80 | cursor += op.retain
81 | } else if (typeof op.insert === 'string') {
82 | const amount = op.insert.toString().length
83 |
84 | if (cursor < start) {
85 | start += amount
86 | }
87 | end += amount
88 | cursor += amount
89 | } else if (op.insert) {
90 | if (cursor < start) {
91 | start += 1
92 | }
93 | end += 1
94 | cursor += 1
95 | } else if (op.delete) {
96 | if (cursor <= start) start = Math.max(cursor, start - op.delete)
97 |
98 | end = Math.max(cursor, end - op.delete)
99 | }
100 |
101 | if (cursor > end)
102 | // 'cursor > end' for same effect as transformPosition(end)
103 | break
104 | }
105 | return new Range(start, end)
106 | }
107 |
108 | public cropContent(content: IDelta): IDelta {
109 | const ss = SharedString.fromDelta(content)
110 | const length = contentLength(content)
111 | const cropper = new Delta([
112 | { delete: this.start },
113 | { retain: this.end - this.start },
114 | { delete: length - this.end },
115 | ])
116 | ss.applyChange(cropper, 'any')
117 | return new Delta(ss.toDelta().ops, content.context?.concat())
118 | }
119 |
120 | public cropChanges(changes: IDelta[], open: boolean = false): IDelta[] {
121 | let range: Range = this
122 | const newChanges: IDelta[] = []
123 | for (const change of changes) {
124 | const newChange = open ? range.cropDeltaOpen(change) : range.cropDelta(change)
125 | range = range.applyChange(change)
126 | newChanges.push(newChange)
127 | }
128 |
129 | return newChanges
130 | }
131 |
132 | public cropDelta(delta: IDelta, debug = false): IDelta {
133 | let cursor = 0
134 | let start = this.start
135 | let end = this.end
136 | const ops: Op[] = []
137 |
138 | for (const op of delta.ops) {
139 | if (op.retain) {
140 | const left = Math.max(cursor, start)
141 | const right = cursor + op.retain
142 | if (right > left) {
143 | ops.push({ retain: right - left })
144 | }
145 | cursor += op.retain
146 | } else if (typeof op.insert === 'string') {
147 | // if (debug) {
148 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
149 | // }
150 | const amount = op.insert.toString().length
151 | if (cursor <= start) {
152 | start += amount
153 | } else {
154 | if (op.attributes) ops.push({ insert: op.insert, attributes: op.attributes })
155 | else ops.push({ insert: op.insert })
156 | }
157 | end += amount
158 | cursor += amount
159 | // if (debug) {
160 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
161 | // }
162 | } else if (op.insert) {
163 | // if (debug) {
164 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
165 | // }
166 | if (cursor <= start) {
167 | start += 1
168 | } else {
169 | if (op.attributes) ops.push({ insert: op.insert, attributes: op.attributes })
170 | else ops.push({ insert: op.insert })
171 | }
172 | end += 1
173 | cursor += 1
174 | // if (debug) {
175 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
176 | // }
177 | } else if (op.delete) {
178 | const left = Math.max(cursor, start)
179 | const right = Math.min(cursor + op.delete, end)
180 | if (right > left) {
181 | ops.push({ delete: right - left })
182 | }
183 |
184 | if (cursor <= start) {
185 | start = Math.max(cursor, start - op.delete)
186 | }
187 |
188 | end = Math.max(cursor, end - op.delete)
189 | // if (debug) {
190 | // console.log('cursor:', cursor, 'start:', start, 'end:', end, 'left:', left, 'right:', right)
191 | // }
192 | }
193 |
194 | if (cursor >= end) break
195 | }
196 |
197 | return new Delta(normalizeOps(ops), delta.context)
198 | }
199 |
200 | public cropDeltaOpen(delta: IDelta, debug = false): IDelta {
201 | let cursor = 0
202 | let start = this.start
203 | let end = this.end
204 | const ops: Op[] = []
205 |
206 | for (const op of delta.ops) {
207 | if (op.retain) {
208 | const left = Math.max(cursor, start)
209 | const right = cursor + op.retain
210 | if (right > left) {
211 | ops.push({ retain: right - left })
212 | }
213 |
214 | cursor += op.retain
215 | } else if (typeof op.insert === 'string') {
216 | // if (debug) {
217 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
218 | // }
219 | const amount = op.insert.toString().length
220 | if (cursor < start) {
221 | start += amount
222 | } else {
223 | if (op.attributes) ops.push({ insert: op.insert, attributes: op.attributes })
224 | else ops.push({ insert: op.insert })
225 | }
226 | end += amount
227 | cursor += amount
228 | // if (debug) {
229 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
230 | // }
231 | } else if (op.insert) {
232 | if (cursor < start) {
233 | start += 1
234 | } else {
235 | if (op.attributes) ops.push({ insert: op.insert, attributes: op.attributes })
236 | else ops.push({ insert: op.insert })
237 | }
238 |
239 | end += 1
240 | cursor += 1
241 | // if (debug) {
242 | // console.log('cursor:', cursor, 'start:', start, 'end:', end)
243 | // }
244 | } else if (op.delete) {
245 | const left = Math.max(cursor, start)
246 | const right = Math.min(cursor + op.delete, end)
247 | if (right > left) {
248 | ops.push({ delete: right - left })
249 | }
250 |
251 | if (cursor <= start) {
252 | start = Math.max(cursor, start - op.delete)
253 | }
254 |
255 | end = Math.max(cursor, end - op.delete)
256 | // if (debug) {
257 | // console.log('cursor:', cursor, 'start:', start, 'end:', end, 'left:', left, 'right:', right)
258 | // }
259 | }
260 |
261 | if (cursor > end) break
262 | }
263 |
264 | return new Delta(normalizeOps(ops), delta.context)
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/src/core/SharedString.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import * as _ from 'underscore'
3 | import { Delta } from './Delta'
4 | import { DeltaIterator } from './DeltaIterator'
5 | import { Fragment, JSONStyle } from './Fragment'
6 | import { FragmentIterator } from './FragmentIterator'
7 | import { IDelta } from './IDelta'
8 | import { normalizeOps } from './primitive'
9 |
10 | export class SharedString {
11 | public static fromString(str: string) {
12 | return new SharedString([new Fragment(str)])
13 | }
14 |
15 | public static fromDelta(content: IDelta) {
16 | const fs = _.reduce(
17 | content.ops,
18 | (fragments: Fragment[], op) => {
19 | if (typeof op.insert === 'string') {
20 | fragments.push(Fragment.initial(op.insert, op.attributes))
21 | } else if (op.insert) {
22 | fragments.push(Fragment.initialEmbedded(op.insert, op.attributes))
23 | } else throw new Error('invalid content, should contain only inserts')
24 | return fragments
25 | },
26 | [],
27 | )
28 |
29 | return new SharedString(fs)
30 | }
31 |
32 | private fragments: Fragment[]
33 |
34 | constructor(fragments: Fragment[]) {
35 | this.fragments = fragments
36 | }
37 |
38 | public applyChange(change: IDelta, branch: string, debug = false): IDelta {
39 | const fragmentIter = new FragmentIterator(branch, this.fragments)
40 | const deltaIter = new DeltaIterator(branch, this.fragments)
41 |
42 | let newFragments: Fragment[] = []
43 | let newOps: Op[] = []
44 | let diff = 0 // always <= 0
45 |
46 | for (const op of change.ops) {
47 | // ratain
48 | if (op.retain) {
49 | newFragments = newFragments.concat(fragmentIter.retain(op.retain))
50 |
51 | const retain = op.retain + diff
52 | if (retain > 0) {
53 | const opsWithDiff = deltaIter.retain(retain)
54 | newOps = newOps.concat(opsWithDiff.ops)
55 | diff = opsWithDiff.diff
56 | } else diff += op.retain
57 | }
58 | // delete
59 | else if (op.delete) {
60 | newFragments = newFragments.concat(fragmentIter.delete(op.delete))
61 | const del = op.delete + diff
62 | if (del > 0) {
63 | const opsWithDiff = deltaIter.delete(del)
64 | newOps = newOps.concat(opsWithDiff.ops)
65 | diff = opsWithDiff.diff
66 | } else {
67 | diff += op.delete
68 | }
69 | } else if (op.insert) {
70 | let fragments: Fragment[] = []
71 | let ops: Op[] = []
72 | if (op.attributes) {
73 | if (typeof op.insert === 'string') {
74 | fragments = fragmentIter.insertWithAttribute(op.insert, op.attributes)
75 | ops = deltaIter.insertWithAttribute(op.insert, op.attributes)
76 | } else {
77 | fragments = fragmentIter.embedWithAttribute(op.insert, op.attributes)
78 | ops = deltaIter.embedWithAttribute(op.insert, op.attributes)
79 | }
80 | } else {
81 | if (typeof op.insert === 'string') {
82 | fragments = fragmentIter.insert(op.insert)
83 | ops = deltaIter.insert(op.insert)
84 | } else {
85 | fragments = fragmentIter.embed(op.insert)
86 | ops = deltaIter.embed(op.insert)
87 | }
88 | }
89 | newFragments = newFragments.concat(fragments)
90 | newOps = newOps.concat(ops)
91 | }
92 | }
93 |
94 | this.fragments = newFragments.concat(fragmentIter.rest())
95 | return new Delta(normalizeOps(newOps), change.context)
96 | }
97 |
98 | public clone() {
99 | return new SharedString(this.fragments.concat())
100 | }
101 |
102 | // TODO: source not considered
103 | public equals(ss: SharedString) {
104 | if (this.fragments.length !== ss.fragments.length) return false
105 |
106 | for (let i = 0; i < this.fragments.length; i++) {
107 | if (!this.fragments[i].equals(ss.fragments[i])) return false
108 | }
109 | return true
110 | }
111 |
112 | public toText() {
113 | return _.reduce(
114 | this.fragments,
115 | (result: string, fragment) => {
116 | return result.concat(fragment.toText())
117 | },
118 | '',
119 | )
120 | }
121 |
122 | // flatten changes applied (including insert, delete), regardless of branches
123 | // parts with no changes are represented as retain
124 | public toFlattenedDelta(): IDelta {
125 | const ops = _.map(
126 | this.fragments,
127 | fragment => {
128 | return fragment.toFlattenedOp()
129 | },
130 | [],
131 | )
132 | return { ops }
133 | }
134 |
135 | public toDelta(branch?: string): IDelta {
136 | const ops = _.reduce(
137 | this.fragments,
138 | (result: Op[], fragment) => {
139 | const op = fragment.toOp()
140 | // original content that was not deleted
141 | if (typeof branch === 'undefined' && !fragment.isDeleted() && op.insert !== '')
142 | return result.concat(fragment.toOp())
143 | // inserted content
144 | else if (typeof branch === 'string' && fragment.isVisibleTo(branch) && op.insert !== '')
145 | return result.concat(fragment.toOp())
146 | else return result
147 | },
148 | [],
149 | )
150 | return { ops: normalizeOps(ops) }
151 | }
152 |
153 | public toHtml(includeBranches = true) {
154 | return _.reduce(
155 | this.fragments,
156 | (result: string, fragment) => {
157 | return result + fragment.toHtml(includeBranches)
158 | },
159 | '',
160 | )
161 | }
162 |
163 | public toStyledJSON() {
164 | return _.reduce(
165 | this.fragments,
166 | (result: JSONStyle[], fragment) => {
167 | return result.concat(fragment.toStyledJSON())
168 | },
169 | [],
170 | )
171 | }
172 |
173 | public toString() {
174 | return { fragments: this.fragments }
175 | }
176 |
177 | public getFragmentAtIdx(idx: number, branch: string): Fragment | null {
178 | let current = 0
179 | for (const fragment of this.fragments) {
180 | if (fragment.isVisibleTo(branch)) {
181 | current += fragment.val.length
182 | if (current >= idx) return fragment
183 | }
184 | }
185 | return null
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/src/core/Source.ts:
--------------------------------------------------------------------------------
1 | export interface Source {
2 | type: 'excerpt' | 'sync'
3 | uri: string
4 | rev: number
5 | start: number
6 | end: number
7 | }
8 |
--------------------------------------------------------------------------------
/src/core/__tests__/Delta.props.spec.ts:
--------------------------------------------------------------------------------
1 | import { expectEqual } from '../util'
2 | import { Delta } from '../Delta'
3 | import { contentLength, cropContent, normalizeDeltas } from '../primitive'
4 | import { forAll, inRange, interval } from 'jsproptest'
5 | import { ContentGen } from '../../__tests__/generator/Content'
6 | import { IDelta } from '../IDelta'
7 |
8 | describe('Delta methods', () => {
9 | it('.take', () => {
10 | const contentGen = ContentGen()
11 |
12 | forAll((content:IDelta) => {
13 | const length = contentLength(content)
14 | // check cropContent full
15 | expectEqual(cropContent(content, 0, length), normalizeDeltas(content)[0])
16 | }, contentGen)
17 |
18 | const contentAndRangeGen = contentGen.chain((content:IDelta) => {
19 | const length = contentLength(content)
20 | const start = inRange(0, length)
21 | return start
22 | }).chainAsTuple((contentAndStart:[IDelta,number])=> {
23 | const [content, start] = contentAndStart
24 | const length = contentLength(content)
25 | const end = interval(start, length)
26 | return end
27 | })
28 |
29 | forAll((contentAndRange:[IDelta,number,number]) => {
30 | const [content, start, end] = contentAndRange
31 | // check .take == cropContent
32 | expectEqual(new Delta(content.ops).take(start, end), cropContent(content, start, end))
33 | // check cropContent 0 length
34 | expectEqual(new Delta(content.ops).take(start, start), new Delta())
35 | }, contentAndRangeGen)
36 | })
37 | })
--------------------------------------------------------------------------------
/src/core/__tests__/Fragment.spec.ts:
--------------------------------------------------------------------------------
1 | import * as _ from 'underscore'
2 | import { Fragment } from '../Fragment'
3 | import { FragmentIterator } from '../FragmentIterator'
4 | import { expectEqual } from '../util'
5 |
6 | describe('hand-made scenarios', () => {
7 | it('fragment 1', () => {
8 | const fragment = Fragment.insert('hello', 'me')
9 |
10 | expectEqual(fragment, { val: 'hello', mod: { insertedBy: 'me', deletedBy: [] } })
11 |
12 | const newFragment = fragment.sliceWithDelete('me', 0)
13 |
14 | expectEqual(newFragment, { val: 'hello', mod: { insertedBy: 'me', deletedBy: ['me'] } })
15 | })
16 |
17 | it('fragment iterator 1', () => {
18 | const fragment = Fragment.insert('hello', 'me')
19 | const iter = new FragmentIterator('me', [fragment])
20 | const fragments = iter.delete(4)
21 | expectEqual(fragments, [{ val: 'hell', mod: { insertedBy: 'me', deletedBy: ['me'] } }])
22 | expectEqual(fragments.concat(iter.rest()), [
23 | { val: 'hell', mod: { insertedBy: 'me', deletedBy: ['me'] } },
24 | { val: 'o', mod: { insertedBy: 'me', deletedBy: [] } },
25 | ])
26 | })
27 |
28 | it('fragment tiebreak 1', () => {
29 | const fragment = Fragment.insert('hello', 'me')
30 | const iter = new FragmentIterator('you', [fragment])
31 | // console.log(iter.current().isVisibleTo("you"), iter.current().shouldAdvanceForTiebreak("you"))
32 | const fragments = iter.insert(' world')
33 |
34 | expectEqual(fragments, [
35 | { val: 'hello', mod: { insertedBy: 'me', deletedBy: [] } },
36 | { val: ' world', mod: { insertedBy: 'you', deletedBy: [] } },
37 | ])
38 |
39 | expectEqual(fragments.concat(iter.rest()), [
40 | { val: 'hello', mod: { insertedBy: 'me', deletedBy: [] } },
41 | { val: ' world', mod: { insertedBy: 'you', deletedBy: [] } },
42 | ])
43 | })
44 |
45 | it('fragment tiebreak 2', () => {
46 | const fragment = Fragment.insert('hello', 'you')
47 | const iter = new FragmentIterator('me', [fragment])
48 | // console.log(iter.current().isVisibleTo("me"), iter.current().shouldAdvanceForTiebreak("me"))
49 | const fragments = iter.insert(' world')
50 | expectEqual(fragments, [{ val: ' world', mod: { insertedBy: 'me', deletedBy: [] } }])
51 |
52 | expectEqual(fragments.concat(iter.rest()), [
53 | { val: ' world', mod: { insertedBy: 'me', deletedBy: [] } },
54 | { val: 'hello', mod: { insertedBy: 'you', deletedBy: [] } },
55 | ])
56 | })
57 |
58 | // it('fragment attribute set', () => {
59 | // const fragment = Fragment.initial('hello')
60 | // const iter = new FragmentIterator('me', [fragment])
61 | // expectEqual(iter.retain(1), [{ val: 'h', mod: { deletedBy: [] } }])
62 | // expectEqual(iter.insert('x'), [{ val: 'x', mod: { insertedBy: 'me', deletedBy: [] } }])
63 | // expectEqual(iter.attribute(2, { bold: true }), [
64 | // { val: 'el', attrs: { mod: { me: { bold: true } } }, mod: { deletedBy: [] } },
65 | // ])
66 | // })
67 |
68 | // it('fragment attribute unset', () => {
69 | // const fragment = Fragment.initial('hello', { bold: true })
70 | // const iter = new FragmentIterator('me', [fragment])
71 | // expectEqual(iter.retain(1), [{ val: 'h', attrs: { val: { bold: true } }, mod: { deletedBy: [] } }])
72 | // expectEqual(iter.insert('x'), [{ val: 'x', mod: { insertedBy: 'me', deletedBy: [] } }])
73 | // const fragments = iter.attribute(2, { bold: null })
74 | // expectEqual(fragments, [
75 | // { val: 'el', attrs: { val: { bold: true }, mod: { me: { bold: null } } }, mod: { deletedBy: [] } },
76 | // ])
77 | // expectEqual(fragments[0].getAttributes(), {})
78 | // })
79 | })
80 |
--------------------------------------------------------------------------------
/src/core/__tests__/primitive.props.spec.ts:
--------------------------------------------------------------------------------
1 | import { ContentGen } from '../../__tests__/generator/Content'
2 | import {
3 | invertChange,
4 | contentLength,
5 | minContentLengthForChange,
6 | normalizeOps,
7 | filterOutChangesByIndice,
8 | filterChanges,
9 | applyChanges,
10 | deltaLength,
11 | opLength,
12 | } from '../primitive'
13 | import { SharedString } from '../SharedString'
14 | import { IDelta } from '../IDelta'
15 | import { History } from '../../history/History'
16 | import { JSONStringify, expectEqual, isEqual } from '../util'
17 | import { DeltaGen } from '../../__tests__/generator/Delta'
18 | import { forAll } from 'jsproptest'
19 | import { Delta } from '../Delta'
20 | import { ContentChangeList, ContentChangeListGen } from '../../__tests__/generator/ContentChangeList'
21 |
22 | describe('primitive.ts', () => {
23 | it('asDelta', () => {
24 |
25 | })
26 |
27 | it('opLength, deltaLength, contentLength, minContentLengthForChange, contentLengthChanged', () => {
28 | const contentGen = ContentGen()
29 | forAll((content:IDelta) => {
30 | expectEqual(contentLength(content), deltaLength(content))
31 | }, contentGen)
32 | })
33 |
34 | //normalizeTwoOps
35 | //lastRetainsRemoved
36 | //emptyOpsRemoved
37 | //normalizeOps
38 | //normalizeDeltas
39 | //hasNoEffect
40 | //transformDeltas
41 | //applyChanges
42 | //flattenDeltas
43 | //flattenTransformedDelta
44 | //sliceOp
45 | //cropContent
46 | //invertChange
47 | //filterChanges
48 | //filterOutChangesByIndice
49 | //toQuillStyleOrder
50 | })
51 |
52 | describe('inverse function property', () => {
53 | it('basic', () => {
54 | const contentGen = ContentGen(-1, true, true)
55 | const changeGen = DeltaGen(-1, true, true)
56 | forAll(
57 | (content: IDelta, change: Delta) => {
58 | change.ops = normalizeOps(change.ops)
59 | if (contentLength(content) < minContentLengthForChange(change)) return
60 |
61 | const undo = invertChange(content, change)
62 | const ss1 = SharedString.fromDelta(content)
63 | ss1.applyChange(change, '_')
64 | let result = ss1.toDelta()
65 | result = { ...result, ops: normalizeOps(result.ops) }
66 |
67 | expect(() => contentLength(result) < minContentLengthForChange(undo))
68 |
69 | // invertChange(result, invertChange(content, change)) == change
70 | expectEqual(invertChange(result, undo), change, JSONStringify(result) + " " + JSONStringify(undo) + " " + JSONStringify(invertChange(result, undo)))
71 |
72 | ss1.applyChange(undo, '_')
73 | expectEqual(normalizeOps(content.ops), normalizeOps(ss1.toDelta().ops), JSONStringify(undo))
74 | const ss2 = SharedString.fromDelta(result)
75 | ss2.applyChange(undo, '_')
76 | ss2.applyChange(change, '_')
77 | expectEqual(normalizeOps(result.ops), normalizeOps(ss2.toDelta().ops))
78 | },
79 | contentGen,
80 | changeGen,
81 | )
82 | })
83 | })
84 |
85 | describe('filterChanges', () => {
86 | it('simple 1', () => {
87 | const content = { ops: [{ insert: '1234567' }] }
88 | const changes: IDelta[] = [
89 | { ops: [{ retain: 1 }, { insert: 'a' }, { retain: 1 }, { delete: 1 }] },
90 | { ops: [{ delete: 2 }, { insert: 'a' }] },
91 | ]
92 |
93 | const filtered = filterOutChangesByIndice(content, changes, [0])
94 | expectEqual(filtered.length, 1)
95 |
96 | const undo = invertChange(content, changes[0])
97 | let ss = SharedString.fromDelta(content)
98 | ss.applyChange(changes[0], 'A')
99 | ss = SharedString.fromDelta(ss.toDelta())
100 | ss.applyChange(undo, 'B')
101 | const filt = ss.applyChange(changes[1], 'A')
102 |
103 | const ss2 = SharedString.fromDelta(content)
104 | ss2.applyChange(filtered[0], 'A')
105 | if (!isEqual(ss.toDelta(), ss2.toDelta())) throw new Error('')
106 |
107 | expectEqual(filtered[0].ops, [{ delete: 1 }, { insert: 'a' }])
108 | expectEqual(ss.toDelta().ops, [{ insert: 'a234567' }])
109 | })
110 |
111 | it('simple 2', () => {
112 | const content = { ops: [{ insert: '1234567' }] }
113 | const changes: IDelta[] = [
114 | { ops: [{ retain: 1 }, { insert: 'a' }, { retain: 1 }, { delete: 1 }] },
115 | // 1a24567
116 | { ops: [{ delete: 2 }, { retain: 1 }, { insert: 'a' }] },
117 | // 2a4567
118 | { ops: [{ retain: 1 }, { insert: 'b' }, { delete: 2 }] },
119 | // 2b567
120 | ]
121 |
122 | expectEqual(applyChanges(content, changes.slice(0, 1)), { ops: [{ insert: '1a24567' }] })
123 | expectEqual(applyChanges(content, changes.slice(0, 2)), { ops: [{ insert: '2a4567' }] })
124 | expectEqual(applyChanges(content, changes), { ops: [{ insert: '2b567' }] }) // insert b delete a4
125 |
126 | const zero = filterChanges(content, changes, (idx, change) => false)
127 | expectEqual(zero.length, 0)
128 |
129 | expectEqual(changes, filterOutChangesByIndice(content, changes, []))
130 | expectEqual(changes.slice(0, 2), filterOutChangesByIndice(content, changes, [2]))
131 | expectEqual(changes.slice(0, 1), filterOutChangesByIndice(content, changes, [1, 2]))
132 | expectEqual([], filterOutChangesByIndice(content, changes, [0, 1, 2]))
133 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [])), {
134 | ops: [{ insert: '2b567' }],
135 | })
136 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [2])), {
137 | ops: [{ insert: '2a4567' }],
138 | })
139 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [1, 2])), {
140 | ops: [{ insert: '1a24567' }],
141 | })
142 |
143 | // expectEqual(filterOutChangesByIndice(content, changes, [0,1]), '') // remain [2] ; insert b, delete 1 '4'
144 | // TODO
145 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [0, 1])), {
146 | ops: [{ insert: '12b3567' }],
147 | })
148 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [0, 2])), {
149 | ops: [{ insert: '2a34567' }],
150 | })
151 | expectEqual(applyChanges(content, filterOutChangesByIndice(content, changes, [1])), {
152 | ops: [{ insert: '1a2b567' }],
153 | })
154 | })
155 |
156 | it('one', () => {
157 | const contentChangeGen = ContentChangeListGen(-1, 2, true, true)
158 |
159 | forAll((contentChangeList: ContentChangeList) => {
160 | const content = contentChangeList.content
161 | const changeList = contentChangeList.changeList
162 | const changes = changeList.deltas
163 |
164 | const filtered = filterOutChangesByIndice(content, changes, [0])
165 | expectEqual(filtered.length, 1)
166 |
167 | const undo = invertChange(content, changes[0])
168 | let ss = SharedString.fromDelta(content)
169 | ss.applyChange(changes[0], 'A')
170 | ss = SharedString.fromDelta(ss.toDelta())
171 | ss.applyChange(changes[1], 'A')
172 | ss.applyChange(undo, 'B')
173 |
174 | const ss2 = SharedString.fromDelta(content)
175 | ss2.applyChange(filtered[0], 'A')
176 | if (!isEqual(ss.toDelta(), ss2.toDelta())) throw new Error(JSONStringify(ss) + ' / ' + JSONStringify(ss2))
177 | }, contentChangeGen)
178 | })
179 | it('basic', () => {
180 | const contentChangeGen = ContentChangeListGen(-1, 1, true, true)
181 |
182 | forAll((contentChangeList: ContentChangeList) => {
183 | const content = contentChangeList.content
184 | const changes = contentChangeList.changeList.deltas
185 |
186 | for (let i = 0; i < changes.length; i++) {
187 | const history1 = new History('_', content)
188 | const targetChange = changes[i]
189 |
190 | // do and undo
191 | history1.append(changes.slice(0, i)) // 0~i-1 changes
192 | const undoChange = invertChange(history1.getContent(), targetChange)
193 | history1.append(changes.slice(i))
194 | history1.merge({ branch: 'B', rev: i + 1, changes: [undoChange] })
195 | const result1 = history1.getContent()
196 |
197 | // filtered
198 | const history2 = new History('C', content)
199 | history2.append(filterOutChangesByIndice(content, changes, [i]))
200 | const result2 = history2.getContent()
201 |
202 | // must be equal
203 | expectEqual(result1, result2)
204 | }
205 | }, contentChangeGen)
206 | })
207 | })
208 |
--------------------------------------------------------------------------------
/src/core/__tests__/primitive.spec.ts:
--------------------------------------------------------------------------------
1 | import Delta = require('quill-delta')
2 | import * as _ from 'underscore'
3 | import { contentLength, normalizeOps, lastRetainsRemoved, emptyOpsRemoved } from '../primitive'
4 | import Op from 'quill-delta/dist/Op'
5 | import { ExcerptUtil } from '../../excerpt'
6 | import { expectEqual } from '../util'
7 |
8 | describe('Normalize', () => {
9 | it('lastRetainRemoved', () => {
10 | const nothing: Op[] = []
11 | const one: Op[] = [{ retain: 5 }]
12 | const two: Op[] = [{ retain: 5 }, { retain: 7 }]
13 |
14 | const nothingToRemove = [{ delete: 4 }, { retain: 3 }, { insert: 'first' }, { retain: 2 }, { delete: 6 }]
15 |
16 | const oneRetain = [
17 | { delete: 4 },
18 | { retain: 3 },
19 | { insert: 'first' },
20 | { retain: 2 },
21 | { delete: 6 },
22 | { retain: 2 },
23 | ]
24 |
25 | const twoRetains = [
26 | { delete: 4 },
27 | { retain: 3 },
28 | { insert: 'first' },
29 | { retain: 2 },
30 | { delete: 6 },
31 | { retain: 2 },
32 | { retain: 5 },
33 | ]
34 |
35 | const twoRetainsWithAttr = [
36 | { delete: 4 },
37 | { retain: 3 },
38 | { insert: 'first' },
39 | { retain: 2 },
40 | { delete: 6 },
41 | { retain: 2, attributes: { x: 5 } },
42 | { retain: 5, attributes: { x: 5 } },
43 | ]
44 |
45 | const twoRetainsWithAndWithoutAttr = [
46 | { delete: 4 },
47 | { retain: 3 },
48 | { insert: 'first' },
49 | { retain: 2 },
50 | { delete: 6 },
51 | { retain: 2, attributes: { x: 5 } },
52 | { retain: 5 },
53 | ]
54 |
55 | const twoRetainsWithAndWithAttr2 = [
56 | { delete: 4 },
57 | { retain: 3 },
58 | { insert: 'first' },
59 | { retain: 2 },
60 | { delete: 6 },
61 | { retain: 2 },
62 | { retain: 5, attributes: { x: 5 } },
63 | ]
64 |
65 | expectEqual(lastRetainsRemoved(nothing), nothing)
66 | expectEqual(lastRetainsRemoved(one), [])
67 | expectEqual(lastRetainsRemoved(two), [])
68 | expectEqual(lastRetainsRemoved(nothingToRemove), nothingToRemove)
69 | expectEqual(lastRetainsRemoved(oneRetain), oneRetain.slice(0, -1))
70 | expectEqual(lastRetainsRemoved(twoRetains), twoRetains.slice(0, -2))
71 | expectEqual(lastRetainsRemoved(twoRetainsWithAttr), twoRetainsWithAttr)
72 | expectEqual(lastRetainsRemoved(twoRetainsWithAndWithoutAttr), twoRetainsWithAndWithoutAttr.slice(0, -1))
73 | expectEqual(lastRetainsRemoved(twoRetainsWithAndWithAttr2), twoRetainsWithAndWithAttr2)
74 | })
75 |
76 | it('scenario 1', () => {
77 | expectEqual(
78 | normalizeOps(
79 | new Delta()
80 | .retain(5)
81 | .retain(6)
82 | .insert('a')
83 | .insert('a')
84 | .retain(0)
85 | .insert('b')
86 | .delete(1).ops,
87 | ),
88 | new Delta()
89 | .retain(11)
90 | .insert('aab')
91 | .delete(1).ops,
92 | )
93 |
94 | expectEqual(
95 | normalizeOps(
96 | new Delta()
97 | .retain(-5)
98 | .retain(6)
99 | .insert('a', { a: 5 })
100 | .insert('b')
101 | .delete(1)
102 | .delete(2).ops,
103 | ),
104 | new Delta()
105 | .retain(6)
106 | .insert('a', { a: 5 })
107 | .insert('b')
108 | .delete(3).ops,
109 | ) // * minus retain ignored
110 | })
111 | })
112 |
113 | describe('Misc', () => {
114 | it('isExcerptMarker', () => {
115 | const ops = [
116 | { insert: 'a' },
117 | { retain: 6 },
118 | { delete: 3 },
119 | ExcerptUtil.makeExcerptMarker('left', 'c', 1, 0, 3, 'd', 1, 2),
120 | { retain: 5, attributes: { x: 65 } },
121 | ExcerptUtil.makeExcerptMarker('right', 'a', 1, 2, 3, 'b', 2, 3),
122 | ]
123 |
124 | // temp test
125 | expectEqual('excerpted' in ExcerptUtil.makeExcerptMarker('left', 'c', 1, 0, 3, 'd', 1, 2).insert, true)
126 |
127 | const toBoolean = ops.map(op => ExcerptUtil.isExcerptMarker(op))
128 | expectEqual(toBoolean, [false, false, false, true, false, true])
129 | }),
130 | it('setExcerptMarkersAsCopied', () => {
131 | const ops = [
132 | { insert: 'a' },
133 | { retain: 6 },
134 | { delete: 3 },
135 | ExcerptUtil.makeExcerptMarker('left', 'c', 1, 1, 3, 'd', 1, 2),
136 | { retain: 5, attributes: { x: 65 } },
137 | { ...{ attributes: { x: 1 } }, ...ExcerptUtil.makeExcerptMarker('right', 'a', 1, 2, 4, 'b', 2, 3) }, // not realistic to have attributes in excerpt marker but...
138 | ]
139 |
140 | const marker3: Op = { ...ExcerptUtil.makeExcerptMarker('left', 'c', 1, 1, 3, 'd', 1, 2) }
141 | marker3.attributes!.copied = 'true'
142 | const marker5: Op = { ...ExcerptUtil.makeExcerptMarker('right', 'a', 1, 2, 4, 'b', 2, 3) }
143 | marker5.attributes!.copied = 'true'
144 |
145 | const copiedOps = [
146 | { insert: 'a' },
147 | { retain: 6 },
148 | { delete: 3 },
149 | marker3,
150 | { retain: 5, attributes: { x: 65 } },
151 | marker5,
152 | ]
153 | console.log(ExcerptUtil.setExcerptMarkersAsCopied(ops), copiedOps)
154 | expectEqual(ExcerptUtil.setExcerptMarkersAsCopied(ops), copiedOps)
155 | })
156 | })
157 |
158 | describe('contentLength', () => {
159 | it('0', () => {
160 | expectEqual(contentLength({ ops: [] }), 0)
161 | })
162 | })
163 |
164 | describe('emptyOpsRemoved', () => {
165 | const ops1 = [
166 | { insert: '' },
167 | { delete: 1 },
168 | { delete: 0 },
169 | { retain: 1 },
170 | { retain: 0 },
171 | { insert: 'a' },
172 | { insert: { x: 'b' } },
173 | { retain: 0 },
174 | { retain: 0 },
175 | ]
176 |
177 | expectEqual(emptyOpsRemoved(ops1), [{ delete: 1 }, { retain: 1 }, { insert: 'a' }, { insert: { x: 'b' } }])
178 | })
179 |
--------------------------------------------------------------------------------
/src/core/__tests__/text.spec.ts:
--------------------------------------------------------------------------------
1 | import Delta = require('quill-delta')
2 | import * as _ from 'underscore'
3 | import { SharedString } from '../SharedString'
4 | import { expectEqual, JSONStringify } from '../util'
5 | import { randomInt, randomSharedString, randomChanges } from '../../__tests__/random'
6 | import { IDelta } from '../IDelta'
7 | import { flattenDeltas } from '../primitive'
8 |
9 | describe('hand-made scenarios', () => {
10 | it('scenario 1', () => {
11 | const str = SharedString.fromString('world')
12 | expect(str.toText()).toBe('world')
13 | str.applyChange(new Delta().insert('hello '), 'me')
14 | expect(str.toText()).toBe('hello world')
15 |
16 | const op = str.applyChange(new Delta().retain(6).delete(5), 'me')
17 | expect(str.toText()).toBe('hello ')
18 |
19 | str.applyChange(new Delta().retain(6).insert('world'), 'you')
20 | expect(str.toText()).toBe('hello world')
21 | })
22 |
23 | it('scenario 2', () => {
24 | const str = SharedString.fromString('world')
25 | expect(str.toText()).toBe('world')
26 | str.applyChange(new Delta().retain(5).insert('world'), 'you')
27 | expect(str.toText()).toBe('worldworld')
28 | str.applyChange(new Delta().insert('hello '), 'me')
29 | expect(str.toText()).toBe('hello worldworld')
30 | str.applyChange(new Delta().retain(6).delete(5), 'me')
31 | expect(str.toText()).toBe('hello world')
32 | })
33 |
34 | it('scenario 3', () => {
35 | const str = SharedString.fromString('world')
36 | expect(str.applyChange(new Delta().retain(5).insert('world'), 'you')).toEqual(
37 | new Delta().retain(5).insert('world'),
38 | )
39 | expect(str.toText()).toBe('worldworld')
40 | expect(str.applyChange(new Delta().insert('hello '), 'me')).toEqual(new Delta().insert('hello '))
41 | expect(str.toText()).toBe('hello worldworld')
42 | expect(str.applyChange(new Delta().delete(11), 'me')).toEqual(new Delta().delete(11))
43 | expect(str.toText()).toBe('world')
44 | })
45 |
46 | it('scenario 4 delete', () => {
47 | const str = SharedString.fromString('abcde')
48 | expect(str.toText()).toBe('abcde')
49 | const deltas: IDelta[] = []
50 | // NOTE:
51 | // new Delta().retain(2).delete(1).insert("f"))) is saved as {"ops":[{"retain":2},{"insert":"f"},{"delete":1}]}
52 | let delta = str.applyChange(
53 | new Delta()
54 | .retain(2)
55 | .delete(1)
56 | .insert('f'),
57 | 'user2',
58 | ) // ab(f)[c]de
59 | deltas.push(delta)
60 | console.log(JSONStringify(delta), JSONStringify(str))
61 | expect(str.toText()).toBe('abfde')
62 |
63 | delta = str.applyChange(new Delta().delete(3), 'user1') // [ab](f)[c]de
64 | deltas.push(delta)
65 | console.log(JSONStringify(delta), JSONStringify(str))
66 | expect(str.toText()).toBe('fde')
67 |
68 | delta = str.applyChange(new Delta().retain(1).insert('gh'), 'user1') // [ab](f)[c]dghe
69 | deltas.push(delta)
70 | console.log(JSONStringify(delta), JSONStringify(str))
71 | expect(str.toText()).toBe('fdghe')
72 |
73 | const str2 = SharedString.fromString('abcde')
74 | for (const del of deltas) {
75 | str2.applyChange(del, 'merged')
76 | console.log(JSONStringify(str2))
77 | }
78 | expect(str2.toText()).toBe('fdghe')
79 | console.log('html:', str2.toHtml(false))
80 | console.log('json:', str2.toStyledJSON())
81 | })
82 | })
83 |
84 | function combineRandom(deltasForUsers: IDelta[][]) {
85 | const cpDeltasForUsers = _.map(deltasForUsers, deltas => {
86 | return deltas.slice(0)
87 | })
88 |
89 | const combined: Array<{ delta: IDelta; branch: string }> = []
90 |
91 | while (
92 | _.reduce(
93 | cpDeltasForUsers,
94 | (sum, opsForUser) => {
95 | return (sum += opsForUser.length)
96 | },
97 | 0,
98 | ) > 0
99 | ) {
100 | while (true) {
101 | const chosen = randomInt(cpDeltasForUsers.length)
102 | if (cpDeltasForUsers[chosen].length !== 0) {
103 | const delta = cpDeltasForUsers[chosen].shift()
104 | if (delta) combined.push({ delta, branch: 'user' + (chosen + 1) })
105 | break
106 | }
107 | }
108 | }
109 |
110 | return combined
111 | }
112 |
113 | function testCombination(
114 | ssInitial: SharedString,
115 | user1Deltas: IDelta[],
116 | user2Deltas: IDelta[],
117 | user3Deltas: IDelta[] = [],
118 | ) {
119 | const ssClient1 = ssInitial.clone()
120 | const ssClient2 = ssInitial.clone()
121 | const ssClient3 = ssInitial.clone()
122 | const ssServer = ssInitial.clone()
123 |
124 | expect(ssClient1.equals(ssInitial)).toBe(true)
125 | expect(ssClient2.equals(ssInitial)).toBe(true)
126 | expect(ssClient1.equals(ssClient2)).toBe(true)
127 |
128 | const combined1 = combineRandom([user1Deltas, user2Deltas, user3Deltas])
129 | const combined2 = combineRandom([user1Deltas, user2Deltas, user3Deltas])
130 | const flattened = combineRandom([
131 | [flattenDeltas(...user1Deltas)],
132 | [flattenDeltas(...user2Deltas)],
133 | [flattenDeltas(...user3Deltas)],
134 | ])
135 |
136 | const mergedDeltas: IDelta[] = []
137 | for (const comb of combined1) {
138 | mergedDeltas.push(ssClient1.applyChange(comb.delta, comb.branch))
139 | }
140 |
141 | for (const comb of combined2) {
142 | ssClient2.applyChange(comb.delta, comb.branch)
143 | }
144 |
145 | for (const comb of flattened) {
146 | ssClient3.applyChange(comb.delta, comb.branch)
147 | }
148 | for (const mergedDelta of mergedDeltas) {
149 | ssServer.applyChange(mergedDelta, 'merged')
150 | }
151 |
152 | if (!_.isEqual(JSON.parse(JSONStringify(ssClient1.toDelta())), JSON.parse(JSONStringify(ssClient2.toDelta())))) {
153 | console.log(JSONStringify(ssInitial))
154 | console.log(JSONStringify(combined1))
155 | console.log(JSONStringify(combined2))
156 | console.log(JSONStringify(ssClient1))
157 | console.log(JSONStringify(ssClient2))
158 | expectEqual(ssClient1.toDelta(), ssClient2.toDelta())
159 | }
160 |
161 | if (!_.isEqual(JSON.parse(JSONStringify(ssClient1.toDelta())), JSON.parse(JSONStringify(ssServer.toDelta())))) {
162 | console.error(JSONStringify(ssInitial))
163 | console.error(JSONStringify(combined1))
164 | console.error(JSONStringify(mergedDeltas))
165 | console.error(JSONStringify(ssClient1))
166 | console.error(JSONStringify(ssServer))
167 | expectEqual(ssClient1.toDelta(), ssServer.toDelta())
168 | }
169 |
170 | if (
171 | true &&
172 | !_.isEqual(JSON.parse(JSONStringify(ssClient1.toDelta())), JSON.parse(JSONStringify(ssClient3.toDelta())))
173 | ) {
174 | console.log(JSONStringify(ssInitial))
175 | console.log(JSONStringify(combined1))
176 | console.log(JSONStringify(flattened))
177 | console.log(JSONStringify(ssClient1))
178 | console.log(JSONStringify(ssClient3))
179 | expectEqual(ssClient1.toDelta(), ssClient3.toDelta())
180 | }
181 | }
182 |
183 | describe('commutativity', () => {
184 | it('scenario 0', () => {
185 | for (let j = 0; j < 50; j++) {
186 | const ss = randomSharedString()
187 | const user1Deltas = randomChanges(ss.toText().length, 2)
188 | const user2Deltas: IDelta[] = []
189 |
190 | for (let i = 0; i < 60; i++) {
191 | testCombination(ss, user1Deltas, user2Deltas)
192 | }
193 | }
194 | })
195 | it('scenario 1', () => {
196 | for (let j = 0; j < 50; j++) {
197 | const ss = randomSharedString()
198 | const user1Deltas = randomChanges(ss.toText().length, 2, false)
199 | const user2Deltas = randomChanges(ss.toText().length, 1, false)
200 |
201 | for (let i = 0; i < 60; i++) {
202 | testCombination(ss, user1Deltas, user2Deltas)
203 | }
204 | }
205 | })
206 |
207 | it('scenario 2', () => {
208 | for (let j = 0; j < 50; j++) {
209 | const ss = randomSharedString()
210 | const user1Deltas = randomChanges(ss.toText().length, 4)
211 | const user2Deltas = randomChanges(ss.toText().length, 4)
212 | const user3Deltas = randomChanges(ss.toText().length, 5)
213 |
214 | for (let i = 0; i < 60; i++) {
215 | testCombination(ss, user1Deltas, user2Deltas, user3Deltas)
216 | }
217 | }
218 | })
219 | })
220 |
221 | describe('flatten', () => {
222 | it('scenario 1', () => {
223 | for (let j = 0; j < 50; j++) {
224 | const ss = randomSharedString()
225 | const ss2 = ss.clone()
226 | const deltas = randomChanges(ss.toText().length, 10)
227 | for (const delta of deltas) {
228 | ss.applyChange(delta, 'branch')
229 | }
230 |
231 | ss2.applyChange(flattenDeltas(...deltas), 'branch')
232 |
233 | expectEqual(ss.toDelta(), ss2.toDelta())
234 | }
235 | })
236 | })
237 |
--------------------------------------------------------------------------------
/src/core/__tests__/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "max-classes-per-file":false,
5 | "curly": false,
6 | "interface-name": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": [
9 | true,
10 | {
11 | "named-imports-order": "any"
12 | }
13 | ],
14 | "no-bitwise": false,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/core/printer.ts:
--------------------------------------------------------------------------------
1 | import * as chalk from 'chalk'
2 | import { IDelta } from './IDelta'
3 | import { SharedString } from './SharedString'
4 | import { JSONStringify } from './util'
5 |
6 | const initial = (text: string) => text // black
7 | const initialObj = chalk.default.whiteBright
8 | const insert = chalk.default.green
9 | const insertObj = chalk.default.greenBright
10 | const fill = (char: string, size: number) => {
11 | let str = ''
12 | for (let i = 0; i < size; i++) str += char
13 | return str
14 | }
15 | const retain = (size: number) => chalk.default.underline.gray(fill('?', size))
16 | const del = (size: number) => chalk.default.strikethrough.redBright(fill('X', size))
17 | const delContent = (text: string) => {
18 | text = text.replace(/ /g, '_')
19 | return chalk.default.strikethrough.redBright(text)
20 | }
21 | const meta = chalk.default.magentaBright
22 |
23 | export function printContent(delta: IDelta): string {
24 | let str = ''
25 | for (const op of delta.ops) {
26 | if (typeof op.insert === 'string') {
27 | str += initial(op.insert)
28 | } else if (op.insert) {
29 | str += initialObj(JSON.stringify(op.insert) + (op.attributes ? ':' + JSONStringify(op.attributes) : ''))
30 | } else if (op.retain) {
31 | str += retain(op.retain)
32 | } else if (op.delete) {
33 | str += del(op.delete)
34 | }
35 | }
36 | return str
37 | }
38 |
39 | export function printDelta(delta: IDelta): string {
40 | let str = ''
41 | for (const op of delta.ops) {
42 | if (typeof op.insert === 'string') {
43 | str += insert(op.insert)
44 | } else if (op.insert) {
45 | str += insertObj(JSON.stringify(op.insert))
46 | } else if (op.retain) {
47 | str += retain(op.retain)
48 | } else if (op.delete) {
49 | str += del(op.delete)
50 | }
51 | }
52 | return str
53 | }
54 |
55 | export function printDeltas(deltas: IDelta[]) {
56 | let str = meta('[ ')
57 | for (const change of deltas) {
58 | str += printDelta(change)
59 | str += meta(', ')
60 | }
61 | str += meta(' ]')
62 | return str
63 | }
64 |
65 | export function printChangedContent(content: IDelta, changes: IDelta[]): string {
66 | let ss = SharedString.fromDelta(content)
67 | let str = meta('[ ')
68 | for (const change of changes) {
69 | ss.applyChange(change, '_')
70 |
71 | const styledJSON = ss.toStyledJSON()
72 |
73 | for (const obj of styledJSON) {
74 | if (obj.type === 'initial') {
75 | if (typeof obj.value === 'string') {
76 | str += initial(obj.value)
77 | } else if (obj.value.type === 'embed') {
78 | str += initialObj(JSONStringify(obj.value.value))
79 | }
80 | } else if (obj.type === 'inserted') {
81 | if (typeof obj.value === 'string') {
82 | str += insert(obj.value)
83 | } else if (obj.value.type === 'embed') {
84 | str += insertObj(JSONStringify(obj.value.value))
85 | }
86 | } else if (obj.type === 'deleted') {
87 | if (typeof obj.value === 'string') {
88 | str += delContent(obj.value)
89 | } else if (obj.value.type === 'embed') {
90 | str += delContent(JSONStringify(obj.value.value))
91 | }
92 | }
93 | }
94 | // str += meta(' (') + JSONStringify(ss) + meta(')')
95 | str += meta(', ')
96 |
97 | ss = SharedString.fromDelta(ss.toDelta())
98 | }
99 |
100 | str += meta(' ]')
101 |
102 | return str
103 | }
104 |
--------------------------------------------------------------------------------
/src/core/util.ts:
--------------------------------------------------------------------------------
1 | import * as _ from 'underscore'
2 |
3 | export function JSONStringify(obj: any) {
4 | return JSON.stringify(obj, (key: string, value: any) => {
5 | if (typeof value === 'object' && value instanceof Set) {
6 | return [...Array.from(value)]
7 | }
8 | return value
9 | })
10 | }
11 |
12 | export function toJSON(obj: any) {
13 | return JSON.parse(JSON.stringify(obj))
14 | }
15 | type strfunc = () => string
16 |
17 | export function isEqual(obj1: any, obj2: any): boolean {
18 | return _.isEqual(JSON.parse(JSONStringify(obj1)), JSON.parse(JSONStringify(obj2)))
19 | }
20 |
21 | export function expectEqual(obj1: any, obj2: any, msg: string | strfunc = 'Not equal: ') {
22 | if (!isEqual(obj1, obj2)) {
23 | throw new Error(
24 | (typeof msg === 'string' ? msg : msg()) +
25 | ': ( ' +
26 | JSONStringify(obj1) +
27 | ' and ' +
28 | JSONStringify(obj2) +
29 | ' )',
30 | )
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/document/DocumentSet.ts:
--------------------------------------------------------------------------------
1 | import { Document } from './Document'
2 |
3 | export interface DocumentSet {
4 | getDocument(uri: string): Document
5 | }
6 |
--------------------------------------------------------------------------------
/src/document/__tests__/Document.props.spec.ts:
--------------------------------------------------------------------------------
1 |
2 | describe('Document property', () => {
3 |
4 | it('Document behavior', () => {
5 |
6 | })
7 | })
8 |
--------------------------------------------------------------------------------
/src/document/__tests__/Document.spec.ts:
--------------------------------------------------------------------------------
1 | import { Document } from '../Document'
2 | import { expectEqual } from '../../core/util'
3 | import { ContentGen } from '../../__tests__/generator/Content'
4 | import { Range } from '../../core/Range'
5 | import { contentLength } from '../../core/primitive'
6 | import { forAll, interval } from 'jsproptest'
7 | import { IDelta } from '../../core/IDelta'
8 |
9 | describe('document', () => {
10 | it('take0-1', () => {
11 | const doc = new Document('doc1', 'a')
12 |
13 | expectEqual(doc.take(0, 1), { ops: [{ insert: 'a' }] })
14 | })
15 |
16 | it('take0-0', () => {
17 | const doc = new Document('doc1', 'a')
18 |
19 | expectEqual(doc.take(0, 0), { ops: [] })
20 | })
21 |
22 | it('take0-len', () => {
23 | const doc = new Document('doc1', 'a')
24 |
25 | expectEqual(doc.take(0, contentLength(doc.getContent())), doc.getContent())
26 | })
27 |
28 | it('take empty', () => {
29 | const doc = new Document('doc1', '')
30 |
31 | expectEqual(doc.take(0, 0), doc.getContent())
32 | })
33 |
34 | // invalid range (overflow)
35 | it('take0-len+1', () => {
36 | const doc = new Document('doc1', 'a')
37 | expect(() => {
38 | doc.take(0, contentLength(doc.getContent()) + 1)
39 | }).toThrow('invalid argument')
40 | })
41 |
42 | it('document.take equals range.crop', () => {
43 | // return
44 | forAll(
45 | (content: IDelta, num1: number, num2: number) => {
46 | // console.log(content)
47 | const len = contentLength(content)
48 | const n1 = len > 0 ? num1 % len : 0
49 | const n2 = len > 0 ? num2 % len : 0
50 |
51 | const start = n1 > n2 ? n2 : n1
52 | const end = n1 > n2 ? n1 : n2
53 |
54 | const doc = new Document('doc1', content)
55 | const take = doc.take(start, end)
56 |
57 | const crop = new Range(start, end).cropContent(doc.getContent())
58 | // console.log(take, crop)
59 | },
60 | ContentGen(),
61 | interval(0, 10),
62 | interval(0, 10),
63 | )
64 | })
65 | })
66 |
--------------------------------------------------------------------------------
/src/document/__tests__/Document.stateful.spec.ts:
--------------------------------------------------------------------------------
1 | import { contentLength } from '../../core/primitive'
2 | import { expectEqual, JSONStringify } from '../../core/util'
3 | import { Document } from '../Document'
4 | import { ChangeList, ChangeListGen } from '../../__tests__/generator/ChangeList'
5 | import {
6 | Action,
7 | actionGenOf,
8 | interval,
9 | just,
10 | oneOf,
11 | PrintableASCIIStringGen,
12 | statefulProperty,
13 | TupleGen,
14 | } from 'jsproptest'
15 |
16 | const InitialDocumentGen = (name: string) => PrintableASCIIStringGen(0, 20).map(content => new Document(name, content))
17 |
18 | class DocumentModel {
19 | constructor(public contentLength: number) {}
20 | }
21 |
22 | describe('Document', () => {
23 | it('clone', () => {
24 | const docGen = InitialDocumentGen('doc')
25 | const cloneActionGen = just(
26 | new Action((doc: Document, _: DocumentModel) => {
27 | expectEqual(doc, doc.clone())
28 | }),
29 | )
30 |
31 | const actionGen = actionGenOf(cloneActionGen)
32 |
33 | const prop = statefulProperty(
34 | docGen,
35 | (document: Document) => new DocumentModel(contentLength(document.getContent())),
36 | actionGen,
37 | )
38 | prop.go()
39 | })
40 |
41 | it('append', () => {
42 | const docGen = InitialDocumentGen('doc')
43 | const appendActionGen = TupleGen(interval(0, 100), interval(0, 20))
44 | .flatMap(tuple => ChangeListGen(tuple[0], tuple[1]))
45 | .map(
46 | changeList =>
47 | new Action((doc: Document, model: DocumentModel) => {
48 | const version = doc.getCurrentRev()
49 | const tempDoc = doc.clone()
50 | expectEqual(doc, tempDoc.clone())
51 | // apply
52 | doc.append(changeList.deltas)
53 | // check versions increase by the correct amount
54 | expectEqual(changeList.deltas.length + version, doc.getCurrentRev())
55 |
56 | // check split changes
57 | for (const delta of changeList.deltas) {
58 | tempDoc.append([delta])
59 | }
60 | expectEqual(doc.getContent(), tempDoc.getContent())
61 | // update model
62 | model.contentLength = contentLength(doc.getContent())
63 | }),
64 | )
65 |
66 | const actionGen = actionGenOf(appendActionGen)
67 |
68 | const prop = statefulProperty(
69 | docGen,
70 | (document: Document) => new DocumentModel(contentLength(document.getContent())),
71 | actionGen,
72 | )
73 | prop.setMaxActions(10)
74 | .setNumRuns(10)
75 | .go()
76 | })
77 | })
78 |
--------------------------------------------------------------------------------
/src/document/__tests__/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "max-classes-per-file":false,
5 | "curly": false,
6 | "interface-name": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": [
9 | true,
10 | {
11 | "named-imports-order": "any"
12 | }
13 | ],
14 | "no-bitwise": false,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/excerpt/Excerpt.ts:
--------------------------------------------------------------------------------
1 | import { Source } from '../core/Source'
2 | import { ExcerptTarget } from './ExcerptTarget'
3 |
4 | export class Excerpt {
5 | constructor(public source: Source, public target: ExcerptTarget) {}
6 | }
7 |
--------------------------------------------------------------------------------
/src/excerpt/ExcerptMarker.ts:
--------------------------------------------------------------------------------
1 | export interface ExcerptMarker {
2 | insert: { excerpted: string }
3 | attributes: {
4 | targetUri: string
5 | targetRev: string
6 | targetStart: string
7 | targetEnd: string
8 | markedAt: 'left' | 'right'
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/excerpt/ExcerptSource.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 | import { Source } from '../core/Source'
3 |
4 | export class ExcerptSource implements Source {
5 | public readonly type: 'excerpt' | 'sync' = 'excerpt'
6 |
7 | constructor(
8 | public uri: string,
9 | public rev: number,
10 | public start: number,
11 | public end: number,
12 | public content: IDelta,
13 | ) {}
14 | }
15 |
--------------------------------------------------------------------------------
/src/excerpt/ExcerptSync.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 | import { Range } from '../core/Range'
3 |
4 | export class ExcerptSync {
5 | constructor(public uri: string, public rev: number, public change: IDelta, public range: Range) {}
6 | }
7 |
--------------------------------------------------------------------------------
/src/excerpt/ExcerptTarget.ts:
--------------------------------------------------------------------------------
1 | export class ExcerptTarget {
2 | constructor(public uri: string, public rev: number, public start: number, public end: number) {}
3 | }
4 |
--------------------------------------------------------------------------------
/src/excerpt/ExcerptUtil.ts:
--------------------------------------------------------------------------------
1 | import Op from 'quill-delta/dist/Op'
2 | import { Delta } from '../core/Delta'
3 | import { IDelta } from '../core/IDelta'
4 | import { JSONStringify } from '../core/util'
5 | import { Excerpt } from './Excerpt'
6 | import { ExcerptMarker } from './ExcerptMarker'
7 | import { ExcerptSource } from './ExcerptSource'
8 | import { ExcerptTarget } from './ExcerptTarget'
9 |
10 | export interface ExcerptMarkerWithOffset extends ExcerptMarker {
11 | offset: number
12 | }
13 |
14 | export class ExcerptUtil {
15 | public static take(start: number, end: number, length: number): IDelta {
16 | // ....start....end...length
17 | const ops: Op[] = []
18 | const retain = end - start
19 | if (start > 0) ops.push({ delete: start })
20 |
21 | ops.push({ retain })
22 |
23 | if (length - end > 0) ops.push({ delete: length - end })
24 |
25 | return new Delta(ops)
26 | }
27 |
28 | public static makeExcerptMarker(
29 | markedAt: 'left' | 'right',
30 | sourceUri: string,
31 | sourceRev: number,
32 | sourceStart: number,
33 | sourceEnd: number,
34 | targetUri: string,
35 | targetRev: number,
36 | targetStart: number,
37 | targetEnd: number = -1,
38 | ): ExcerptMarker {
39 | // const header = { sourceUri, sourceRev, targetUri, targetRev, length}
40 | const value = { excerpted: sourceUri + '?rev=' + sourceRev + '&start=' + sourceStart + '&end=' + sourceEnd }
41 |
42 | if (targetEnd < 0) targetEnd = targetStart + sourceEnd - sourceStart + 1 // marker itself is included
43 | const attributes = {
44 | markedAt,
45 | targetUri,
46 | targetRev: targetRev.toString(),
47 | targetStart: targetStart.toString(),
48 | targetEnd: targetEnd.toString(),
49 | }
50 |
51 | const op = { insert: value, attributes }
52 |
53 | if (!ExcerptUtil.isExcerptMarker(op)) throw new Error('error: ' + JSONStringify(op))
54 | return op
55 | }
56 |
57 | // target ranges: marker itself is included
58 | public static getPasteWithMarkers(
59 | source: ExcerptSource,
60 | targetUri: string,
61 | targetRev: number,
62 | targetStart: number,
63 | ): IDelta {
64 | const leftMarkerOp: Op = this.makeExcerptMarker(
65 | 'left',
66 | source.uri,
67 | source.rev,
68 | source.start,
69 | source.end,
70 | targetUri,
71 | targetRev,
72 | targetStart,
73 | )
74 | const rightMarkerOp: Op = this.makeExcerptMarker(
75 | 'right',
76 | source.uri,
77 | source.rev,
78 | source.start,
79 | source.end,
80 | targetUri,
81 | targetRev,
82 | targetStart,
83 | )
84 |
85 | if (!ExcerptUtil.isExcerptMarker(leftMarkerOp))
86 | throw new Error('Unexpected error. Check marker and checker implementation: ' + JSONStringify(leftMarkerOp))
87 | if (!ExcerptUtil.isExcerptMarker(rightMarkerOp))
88 | throw new Error(
89 | 'Unexpected error. Check marker and checker implementation: ' + JSONStringify(rightMarkerOp),
90 | )
91 |
92 | const ops: Op[] = [leftMarkerOp].concat(source.content.ops).concat([rightMarkerOp])
93 |
94 | return new Delta(ops)
95 | }
96 |
97 | public static isExcerptURI(uri: string) {
98 | const split: string[] = uri.split('?')
99 | if (split.length !== 2) return false
100 |
101 | return /^rev=[0-9]+&start=[0-9]+&end=[0-9]+$/.test(split[1])
102 | }
103 |
104 | public static isLeftExcerptMarker(op: Op, includeCopied = false): boolean {
105 | if (!this.isExcerptMarker(op, includeCopied)) return false
106 |
107 | if (!op.attributes) return false
108 |
109 | return op.attributes.markedAt === 'left'
110 | }
111 |
112 | public static isRightExcerptMarker(op: Op, includeCopied = false): boolean {
113 | if (!this.isExcerptMarker(op, includeCopied)) return false
114 |
115 | if (!op.attributes) return false
116 |
117 | return op.attributes.markedAt === 'right'
118 | }
119 |
120 | public static isExcerptMarker(op: Op, includeCopied = false): boolean {
121 | if (!op.insert || typeof op.insert !== 'object') return false
122 |
123 | const insert: any = op.insert
124 | const attributes: any = op.attributes
125 |
126 | if (!insert.hasOwnProperty('excerpted') || !attributes || typeof insert.excerpted !== 'string') return false
127 | // filter out copied
128 | if (!includeCopied && attributes.hasOwnProperty('copied')) return false
129 |
130 | if (!ExcerptUtil.isExcerptURI(insert.excerpted)) return false
131 |
132 | return (
133 | typeof attributes.targetUri === 'string' &&
134 | typeof attributes.targetRev === 'string' &&
135 | typeof attributes.targetStart === 'string' &&
136 | typeof attributes.targetEnd === 'string' &&
137 | typeof attributes.markedAt === 'string'
138 | )
139 | }
140 |
141 | public static setExcerptMarkersAsCopied(ops: Op[]): Op[] {
142 | return ops.map(op => {
143 | if (ExcerptUtil.isExcerptMarker(op)) {
144 | return { ...op, attributes: { ...op.attributes, copied: 'true' } }
145 | } else {
146 | return op
147 | }
148 | })
149 | }
150 |
151 | public static decomposeMarker(op: Op) {
152 | if (!this.isExcerptMarker(op)) throw new Error('Given op is not a marker: ' + JSONStringify(op))
153 |
154 | const marker: any = op
155 | const source = marker.insert.excerpted
156 | const { sourceUri, sourceRev, sourceStart, sourceEnd } = this.splitSource(source)
157 | const targetUri = marker.attributes.targetUri as string
158 | const targetRev = Number.parseInt(marker.attributes.targetRev, 10)
159 | const targetStart = Number.parseInt(marker.attributes.targetStart, 10)
160 | const targetEnd = Number.parseInt(marker.attributes.targetEnd, 10)
161 |
162 | return new Excerpt(
163 | { type: 'excerpt', uri: sourceUri, rev: sourceRev, start: sourceStart, end: sourceEnd },
164 | new ExcerptTarget(targetUri, targetRev, targetStart, targetEnd),
165 | )
166 | }
167 |
168 | public static splitSource(source: string) {
169 | if (!ExcerptUtil.isExcerptURI(source)) throw new Error('unsupported value: ' + source)
170 |
171 | const [sourceUri, rest] = source.split('?')
172 |
173 | const result = /^rev=([0-9]+)&start=([0-9]+)&end=([0-9]+)$/.exec(rest)
174 | if (!result) throw new Error('unsupported value: ' + source)
175 |
176 | const [full, sourceRevStr, sourceStartStr, sourceEndStr] = result
177 | const sourceRev = Number.parseInt(sourceRevStr, 10)
178 | const sourceStart = Number.parseInt(sourceStartStr, 10)
179 | const sourceEnd = Number.parseInt(sourceEndStr, 10)
180 |
181 | return { sourceUri, sourceRev, sourceStart, sourceEnd }
182 | }
183 |
184 | public static getFullExcerpts(content: IDelta): Array<{ offset: number; excerpt: Excerpt }> {
185 | const excerptMarkers: ExcerptMarkerWithOffset[] = []
186 | const excerptMap = new Map()
187 | let offset = 0
188 | for (const op of content.ops) {
189 | if (!op.insert) throw new Error('content is in invalid state: ' + JSONStringify(op))
190 |
191 | if (typeof op.insert === 'string') {
192 | offset += op.insert.length
193 | } else {
194 | if (ExcerptUtil.isExcerptMarker(op)) {
195 | const excerptedOp: ExcerptMarker = op as ExcerptMarker
196 | const targetInfo = { uri: excerptedOp.attributes.targetUri, rev: excerptedOp.attributes.targetRev }
197 | const key = excerptedOp.insert.excerpted + '/' + JSONStringify(targetInfo)
198 | if (excerptedOp.attributes.markedAt === 'left') {
199 | excerptMap.set(key, excerptedOp)
200 | } else if (excerptedOp.attributes.markedAt === 'right') {
201 | if (excerptMap.has(key)) {
202 | const marker = excerptMap.get(key)!
203 | if (
204 | marker.attributes.targetUri === excerptedOp.attributes.targetUri &&
205 | marker.attributes.targetRev === excerptedOp.attributes.targetRev
206 | )
207 | excerptMarkers.push({ offset, ...excerptedOp })
208 | }
209 | }
210 | }
211 | offset++ // all embeds have length of 1
212 | }
213 | }
214 |
215 | return excerptMarkers.map(marker => {
216 | return { offset: marker.offset, excerpt: ExcerptUtil.decomposeMarker(marker) }
217 | })
218 | }
219 |
220 | public static getPartialExcerpts(content: IDelta) {
221 | const fullExcerpts = new Set() // A ^ B
222 | const anyExcerpts = new Map() // A U B
223 | let offset = 0
224 | for (const op of content.ops) {
225 | if (!op.insert) throw new Error('content is in invalid state: ' + JSONStringify(op))
226 |
227 | if (typeof op.insert === 'string') {
228 | offset += op.insert.length
229 | } else {
230 | if (ExcerptUtil.isExcerptMarker(op)) {
231 | const excerptedOp: any = op
232 | const targetInfo = { uri: excerptedOp.attributes.targetUri, rev: excerptedOp.attributes.targetRev }
233 | const key = excerptedOp.insert.excerpted + '/' + JSONStringify(targetInfo)
234 |
235 | if (excerptedOp.attributes.markedAt === 'left') {
236 | anyExcerpts.set(key, { offset, ...op })
237 | } else if (excerptedOp.attributes.markedAt === 'right') {
238 | if (anyExcerpts.has(key)) {
239 | const marker = anyExcerpts.get(key)!
240 | if (
241 | marker.attributes.targetUri === excerptedOp.attributes.targetUri &&
242 | marker.attributes.targetRev === excerptedOp.attributes.targetRev
243 | )
244 | fullExcerpts.add(key)
245 | }
246 | anyExcerpts.set(key, { offset, ...op })
247 | }
248 | }
249 | offset++ // all embeds have length of 1
250 | }
251 | }
252 | const partialExcerpts: ExcerptMarkerWithOffset[] = []
253 | for (const key of Array.from(anyExcerpts.keys())) {
254 | if (!fullExcerpts.has(key)) partialExcerpts.push(anyExcerpts.get(key))
255 | }
256 |
257 | return partialExcerpts
258 | }
259 | }
260 |
--------------------------------------------------------------------------------
/src/excerpt/__tests__/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "max-classes-per-file":false,
5 | "curly": false,
6 | "interface-name": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": [
9 | true,
10 | {
11 | "named-imports-order": "any"
12 | }
13 | ],
14 | "no-bitwise": false,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/excerpt/index.ts:
--------------------------------------------------------------------------------
1 | export { Excerpt } from './Excerpt'
2 | export { ExcerptTarget } from './ExcerptTarget'
3 | export { ExcerptSource } from './ExcerptSource'
4 | export { ExcerptSync } from './ExcerptSync'
5 | export { ExcerptUtil } from './ExcerptUtil'
6 |
--------------------------------------------------------------------------------
/src/history/History.ts:
--------------------------------------------------------------------------------
1 | import * as _ from 'underscore'
2 | import { IDelta } from '../core/IDelta'
3 | import { asDelta, normalizeDeltas, normalizeOps } from '../core/primitive'
4 | import { SharedString } from '../core/SharedString'
5 | import { expectEqual } from '../core/util'
6 | import { SyncRequest } from './SyncRequest'
7 | import { MergeResult } from './SyncResponse'
8 |
9 |
10 | class Savepoint {
11 | constructor(public rev: number, public content: IDelta) {}
12 | }
13 |
14 |
15 | /**
16 | * History
17 | * Revision convention:
18 | * 0: initial content
19 | * getContentAt(getCurrentRev()) => current (latest) content
20 | * Content at n = content at (n-1) + change at (n-1)
21 | * Content at n = content at (n-1) + change for n
22 | */
23 | export interface IHistory {
24 | readonly name: string
25 | getCurrentRev(): number
26 |
27 | getContent(): IDelta
28 | getContentAt(rev: number): IDelta
29 |
30 | getChangeAt(rev: number): IDelta
31 | getChangeFor(rev: number): IDelta
32 | getChangesFrom(fromRev: number): IDelta[]
33 |
34 | /**
35 | * changes in range [fromRev,toRev)
36 | * @param fromRev inclusive start
37 | * @param toRev exclusive end
38 | */
39 | getChangesInRange(fromRev: number, toRev: number): IDelta[]
40 |
41 | append(changes: IDelta[]): number
42 | merge(mergeRequest: SyncRequest): MergeResult
43 | rebase(rebaseRequest: SyncRequest): MergeResult
44 |
45 | clone(): IHistory
46 | }
47 |
48 | export class History implements IHistory {
49 | public static readonly DEFAULT_MIN_SAVEPOINT_RATE = 20
50 |
51 | private savepoints: Savepoint[] = []
52 | private changes: IDelta[] = []
53 |
54 | constructor(private _name: string, initialContent: string | IDelta = '', private initialRev = 0, private minSavepointRate = History.DEFAULT_MIN_SAVEPOINT_RATE) {
55 | this.doSavepoint(initialRev, asDelta(initialContent))
56 | }
57 |
58 | public get name():string {
59 | return this._name
60 | }
61 |
62 | public static create(name:string, initialContent:IDelta, changes:IDelta[], initialRev:number = 0, minSavepointRate = History.DEFAULT_MIN_SAVEPOINT_RATE, savepoints?:Savepoint[]):History {
63 | const history = new History(name, initialContent, initialRev, minSavepointRate)
64 | history.changes = changes
65 | if(savepoints)
66 | history.savepoints = savepoints
67 | else
68 | history.rebuildSavepoints(initialContent)
69 |
70 | return history
71 | }
72 |
73 | public getObject() {
74 | return {
75 | name: this._name.concat(),
76 | changes: this.changes.concat(),
77 | initialRev: this.initialRev,
78 | minSavepointRate: this.minSavepointRate,
79 | savepoints: this.savepoints.concat()
80 | }
81 | }
82 |
83 | public clone(): History {
84 | const cloned = new History(this.name, '', this.initialRev)
85 | cloned.savepoints = this.savepoints.concat()
86 | cloned.changes = this.changes.concat()
87 | return cloned
88 | }
89 |
90 | public getCurrentRev(): number {
91 | return this.changes.length + this.initialRev
92 | }
93 |
94 | public getContent(): IDelta {
95 | return this.getContentAt(this.getCurrentRev())
96 | }
97 |
98 | public getContentAt(rev: number): IDelta {
99 | const savepoint = this.getNearestSavepointForRev(rev)
100 | const ss = SharedString.fromDelta(savepoint.content)
101 | for (let i = savepoint.rev; i < rev; i++) {
102 | ss.applyChange(this.getChangeAt(i), '_')
103 | }
104 |
105 | return ss.toDelta()
106 | }
107 |
108 | public getChangeAt(rev: number): IDelta {
109 | if (!(0 <= rev - this.initialRev && rev - this.initialRev < this.changes.length))
110 | throw new Error(
111 | 'invalid argument: ' +
112 | 'rev=' +
113 | rev +
114 | ' not in [' +
115 | this.initialRev +
116 | ',' +
117 | (this.changes.length + this.initialRev) +
118 | ')',
119 | )
120 | return this.changes[rev - this.initialRev]
121 | }
122 |
123 | public getChangeFor(rev: number): IDelta {
124 | return this.getChangeAt(rev - 1)
125 | }
126 |
127 | public getChangesFrom(fromRev: number): IDelta[] {
128 | return this.changes.slice(fromRev - this.initialRev)
129 | }
130 |
131 | public getChangesInRange(fromRev: number, toRev: number): IDelta[] {
132 | return this.changes.slice(fromRev - this.initialRev, toRev - this.initialRev)
133 | }
134 |
135 | public append(changes: IDelta[]): number {
136 | this.mergeAt(this.getCurrentRev(), changes)
137 | return this.getCurrentRev()
138 | }
139 |
140 | public merge(mergeRequest: SyncRequest): MergeResult {
141 | return this.mergeAt(mergeRequest.rev, mergeRequest.changes, mergeRequest.branch)
142 | }
143 |
144 | // prioritize remote
145 | public rebase(rebaseRequest: SyncRequest): MergeResult {
146 | const baseRev = rebaseRequest.rev
147 | const changes = rebaseRequest.changes
148 | const branchName = rebaseRequest.branch
149 |
150 | const result = this.simulateRebase(branchName ? branchName : this.name, changes, baseRev)
151 |
152 | // old + rebased + transformed
153 | this.changes = this.changes.slice(0, baseRev - this.initialRev)
154 | this.changes = this.changes.concat(result.reqChanges)
155 | this.changes = this.changes.concat(result.resChanges)
156 |
157 | this.invalidateSavepoints(baseRev)
158 | if (this.getLatestSavepointRev() + this.minSavepointRate <= this.getCurrentRev()) {
159 | this.doSavepoint(this.getCurrentRev(), result.content)
160 | this.checkSavepoints()
161 | }
162 | return result
163 | }
164 |
165 | private simulateMergeAt(baseRev: number, remoteChanges: IDelta[], branchName: string): MergeResult {
166 | const baseRevText = this.getContentAt(baseRev)
167 | const ss = SharedString.fromDelta(baseRevText)
168 | const localChanges = this.getChangesFrom(baseRev)
169 |
170 | for (const localChange of localChanges) {
171 | ss.applyChange(localChange, this.name)
172 | }
173 |
174 | let newChanges: IDelta[] = []
175 | for (const change of remoteChanges) {
176 | newChanges = newChanges.concat(ss.applyChange(change, branchName))
177 | }
178 |
179 | return {
180 | rev: baseRev + remoteChanges.length + localChanges.length,
181 | reqChanges: newChanges,
182 | resChanges: localChanges,
183 | content: ss.toDelta(),
184 | }
185 | }
186 |
187 | private mergeAt(baseRev: number, changes: IDelta[], name?: string): MergeResult {
188 | const result = this.simulateMergeAt(baseRev, changes, name ? name : this.name)
189 |
190 | this.changes = this.changes.concat(result.reqChanges)
191 |
192 | if (this.getLatestSavepointRev() + this.minSavepointRate <= this.getCurrentRev()) {
193 | this.doSavepoint(this.getCurrentRev(), result.content)
194 | this.checkSavepoints()
195 | }
196 | return result
197 | }
198 |
199 | private simulateRebase(branchName: string, remoteChanges: IDelta[], baseRev: number): MergeResult {
200 | const baseRevText = this.getContentAt(baseRev)
201 | const ss = SharedString.fromDelta(baseRevText)
202 | let newChanges: IDelta[] = []
203 |
204 | for (const change of remoteChanges) {
205 | ss.applyChange(change, branchName)
206 | }
207 |
208 | const localChanges = this.getChangesFrom(baseRev)
209 | for (const localChange of localChanges) {
210 | newChanges = newChanges.concat(ss.applyChange(localChange, this.name))
211 | }
212 |
213 | return {
214 | rev: baseRev + remoteChanges.length + localChanges.length,
215 | reqChanges: remoteChanges,
216 | resChanges: newChanges,
217 | content: ss.toDelta(),
218 | }
219 | }
220 |
221 | private rebuildSavepoints(initialContent:IDelta): void {
222 | this.savepoints = [
223 | new Savepoint(this.initialRev, initialContent)
224 | ]
225 |
226 | const initial = this.savepoints[0]
227 | const ss = SharedString.fromDelta(initial.content)
228 | for (let rev = this.initialRev; rev < this.getCurrentRev(); rev++) {
229 | ss.applyChange(this.getChangeAt(rev), '_')
230 | if((rev + 1 - this.initialRev) % this.minSavepointRate == 0) {
231 | this.doSavepoint(rev+1, ss.toDelta())
232 | }
233 | }
234 |
235 | this.checkSavepoints()
236 | }
237 |
238 | private checkSavepoints(): void {
239 | const initial = this.savepoints[0]
240 | if (initial.rev !== this.initialRev) throw new Error('initial savepoint rev must be first rev')
241 |
242 | const ss = SharedString.fromDelta(initial.content)
243 | let j = 0
244 | for (let rev = this.initialRev; rev < this.getCurrentRev(); rev++) {
245 | if (rev > this.getLatestSavepointRev()) break
246 |
247 | if (rev === this.savepoints[j].rev) {
248 | expectEqual(
249 | normalizeOps(ss.toDelta().ops),
250 | normalizeOps(this.savepoints[j].content.ops),
251 | 'savepoint is not correct at (' + rev + ',' + j + '):',
252 | )
253 | j++
254 | }
255 | ss.applyChange(this.getChangeAt(rev), '_')
256 | }
257 | }
258 |
259 | private invalidateSavepoints(baseRev: number) {
260 | this.savepoints = this.savepoints.filter(savepoint => savepoint.rev <= baseRev)
261 | }
262 |
263 | private doSavepoint(rev: number, content: IDelta): void {
264 | this.savepoints.push({ rev, content })
265 | }
266 |
267 | private getLatestSavepointRev(): number {
268 | return this.savepoints[this.savepoints.length - 1].rev
269 | }
270 |
271 | private getNearestSavepointForRev(rev: number): Savepoint {
272 | // return this.savepoints[0]
273 | let nearestSavepoint = this.savepoints[0]
274 | for (const savepoint of this.savepoints) {
275 | if (rev <= savepoint.rev) break
276 | nearestSavepoint = savepoint
277 | }
278 |
279 | return nearestSavepoint
280 | }
281 | }
282 |
--------------------------------------------------------------------------------
/src/history/SyncRequest.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 |
3 | export interface SyncRequest {
4 | branch: string
5 | rev: number
6 | changes: IDelta[]
7 | }
--------------------------------------------------------------------------------
/src/history/SyncResponse.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 |
3 | export interface SyncResponse {
4 | rev: number
5 | content: IDelta
6 | reqChanges: IDelta[] // input change (altered)
7 | resChanges: IDelta[] // output change (altered)
8 | }
9 |
10 | export type MergeResult = SyncResponse
11 |
--------------------------------------------------------------------------------
/src/history/__tests__/History.props.spec.ts:
--------------------------------------------------------------------------------
1 | import { interval, Property } from "jsproptest"
2 | import { IDelta } from "../../core/IDelta"
3 | import { normalizeDeltas } from "../../core/primitive"
4 | import { expectEqual } from "../../core/util"
5 | import { ContentChangeList, ContentChangeListGen } from "../../__tests__/generator/ContentChangeList"
6 | import { ChangeList } from "../../__tests__/generator/ChangeList"
7 | import { History } from "../History"
8 | import { GenHistoryAndDelta} from "./generator/GenHistoryAndDelta"
9 | import { GenHistoryAndDivergingChanges } from "./generator/GenHistoryAndDivergingDeltas"
10 |
11 | describe('History', () => {
12 | const minSavepointRateGen = interval(1,5)
13 | const initialRevGen = interval(0, 20)
14 | // content and change list of various lengths (list length at least 1)
15 | const contentChangeListGen = interval(1, 20).flatMap(listLength => ContentChangeListGen(4, listLength, true))
16 |
17 | it('History::create/getObject pair', () => {
18 | const prop = new Property((minSavepointRate:number, initialRev:number, contentAndChanges:ContentChangeList) => {
19 | const history1 = History.create("_", contentAndChanges.content, contentAndChanges.changeList.deltas, initialRev, minSavepointRate)
20 | const obj1 = history1.getObject()
21 | const history2 = History.create(obj1.name, obj1.savepoints[0].content, obj1.changes, obj1.initialRev, obj1.minSavepointRate)
22 | const obj2 = history2.getObject()
23 | expectEqual(obj1, obj2)
24 | })
25 | prop.setNumRuns(1000).forAll(minSavepointRateGen, initialRevGen, contentChangeListGen)
26 | })
27 |
28 | const historyAndDeltaGen = GenHistoryAndDelta()
29 |
30 | it('History::append single', () => {
31 | const prop = new Property((historyAndDelta:[History, IDelta]) => {
32 | const [history, delta] = historyAndDelta
33 |
34 | const rev = history.getCurrentRev()
35 | history.append([delta])
36 |
37 | expectEqual(rev+1, history.getCurrentRev())
38 | expectEqual(normalizeDeltas(delta), normalizeDeltas(history.getChangeFor(rev+1)))
39 | expectEqual(history.getChangeAt(rev), history.getChangeFor(rev+1))
40 | })
41 | prop.setNumRuns(100).forAll(historyAndDeltaGen)
42 | })
43 |
44 | it('History::merge single', () => {
45 | const prop = new Property((historyAndDelta:[History, IDelta]) => {
46 | const [history, delta] = historyAndDelta
47 |
48 | const rev = history.getCurrentRev()
49 | const mergeReq = {branch: "_", rev: rev, changes: [delta]}
50 |
51 | // single merge behaves the same as single append
52 | history.merge(mergeReq)
53 |
54 | expectEqual(rev+1, history.getCurrentRev())
55 | expectEqual(normalizeDeltas(delta), normalizeDeltas(history.getChangeFor(rev+1)))
56 | expectEqual(history.getChangeAt(rev), history.getChangeFor(rev+1))
57 | })
58 | prop.setNumRuns(100).forAll(historyAndDeltaGen)
59 | })
60 |
61 | const historyAndDivergingChanges = GenHistoryAndDivergingChanges()
62 |
63 | it('History::merge arbitrary', () => {
64 | const prop = new Property((historyAndDivergingChanges:[History, ChangeList, ChangeList]) => {
65 | const [history, changes1, changes2] = historyAndDivergingChanges
66 | const initialRev = history.getCurrentRev()
67 |
68 | // append change1
69 | history.append(changes1.deltas)
70 | const rev1 = history.getCurrentRev()
71 | expectEqual(rev1, initialRev+changes1.deltas.length)
72 |
73 | // merge change2
74 | const mergeReq = {branch: "_", rev: initialRev, changes: changes2.deltas}
75 | history.merge(mergeReq)
76 |
77 | const rev2 = history.getCurrentRev()
78 |
79 | // revision is increased
80 | expectEqual(rev2, rev1+changes2.deltas.length)
81 | // merge must not alter existing changes
82 | expectEqual(normalizeDeltas(...history.getChangesInRange(initialRev, rev1)), normalizeDeltas(...changes1.deltas))
83 | })
84 | prop.setNumRuns(100).forAll(historyAndDivergingChanges)
85 | })
86 |
87 | it('History::rebase arbitrary', () => {
88 | const prop = new Property((historyAndDivergingChanges:[History, ChangeList, ChangeList]) => {
89 | const [history, changes1, changes2] = historyAndDivergingChanges
90 | const initialRev = history.getCurrentRev()
91 |
92 | // append change1
93 | history.append(changes1.deltas)
94 | const rev1 = history.getCurrentRev()
95 | expectEqual(rev1, initialRev+changes1.deltas.length)
96 |
97 | // merge change2
98 | const mergeReq = {branch: "_", rev: initialRev, changes: changes2.deltas}
99 | history.rebase(mergeReq)
100 |
101 | const rev2 = history.getCurrentRev()
102 |
103 | expectEqual(rev2, rev1+changes2.deltas.length)
104 | // rebase inserts changes2 to initialRev and shifts changes beyond it
105 | expectEqual(normalizeDeltas(...history.getChangesInRange(initialRev, initialRev + changes2.deltas.length)), normalizeDeltas(...changes2.deltas))
106 | })
107 | prop.setNumRuns(100).forAll(historyAndDivergingChanges)
108 | })
109 |
110 | })
--------------------------------------------------------------------------------
/src/history/__tests__/generator/GenHistory.ts:
--------------------------------------------------------------------------------
1 | import { interval, TupleGen } from "jsproptest"
2 | import { History } from "../../History"
3 | import { ContentChangeList, ContentChangeListGen } from "../../../__tests__/generator/ContentChangeList"
4 |
5 | // make sure changing savepoint rate is safe
6 | const minSavepointRateGen = interval(1,5)
7 | // make sure changing initial rev is safe
8 | const initialRevGen = interval(0, 20)
9 | // content and change list of various lengths (list length at least 1)
10 | const contentChangeListGen = (initialLength:number, maxChanges:number) => interval(0, maxChanges).flatMap(listLength => ContentChangeListGen(initialLength, listLength, true))
11 |
12 | export function GenHistory(initialLength:number = 4, maxChanges:number = 20) {
13 | return TupleGen(minSavepointRateGen, initialRevGen, contentChangeListGen(initialLength, maxChanges)).map((triple:[number, number, ContentChangeList]) => {
14 | const [minSavepointRate, initialRev, contentAndChanges] = triple
15 | const deltas = contentAndChanges.changeList.deltas
16 | return History.create("_", contentAndChanges.content, deltas, initialRev, minSavepointRate)
17 | })
18 | }
--------------------------------------------------------------------------------
/src/history/__tests__/generator/GenHistoryAndDelta.ts:
--------------------------------------------------------------------------------
1 | import { interval, TupleGen } from "jsproptest"
2 | import { History } from "../../History"
3 | import { IDelta } from "../../../core/IDelta"
4 | import { ContentChangeList, ContentChangeListGen } from "../../../__tests__/generator/ContentChangeList"
5 |
6 | // make sure changing savepoint rate is safe
7 | const minSavepointRateGen = interval(1,5)
8 | // make sure changing initial rev is safe
9 | const initialRevGen = interval(0, 20)
10 | // content and change list of various lengths (list length at least 1)
11 | const contentChangeListGen = (initialLength:number, maxChanges:number) => interval(1, maxChanges+1).flatMap(listLength => ContentChangeListGen(initialLength, listLength, true))
12 |
13 | export function GenHistoryAndDelta(initialLength:number = 4, maxChanges:number = 20) {
14 | return TupleGen(minSavepointRateGen, initialRevGen, contentChangeListGen(initialLength, maxChanges)).map((triple:[number, number, ContentChangeList]) => {
15 | const [minSavepointRate, initialRev, contentAndChanges] = triple
16 | const deltas = contentAndChanges.changeList.deltas
17 | const initialDeltas = deltas.slice(0, deltas.length-1)
18 | const lastDelta = deltas[deltas.length-1]
19 | const history = History.create("_", contentAndChanges.content, initialDeltas, initialRev, minSavepointRate)
20 | const result:[History, IDelta] = [history,lastDelta]
21 | return result
22 | })
23 | }
--------------------------------------------------------------------------------
/src/history/__tests__/generator/GenHistoryAndDivergingDeltas.ts:
--------------------------------------------------------------------------------
1 | import { GenHistory } from "./GenHistory"
2 | import { deltaLength } from "../../../core/primitive"
3 | import { ContentChangeListGen } from "../../../__tests__/generator/ContentChangeList"
4 | import { ChangeListGen } from "../../../__tests__/generator/ChangeList"
5 | import { just, TupleGen } from "jsproptest"
6 |
7 | export const GenHistoryAndDivergingChanges = (initialLength = 4, maxChanges = 20) => GenHistory(initialLength, maxChanges).flatMap((history) => {
8 | const content = history.getContent()
9 | const length = deltaLength(content)
10 | const genChanges = ChangeListGen(length)
11 | return TupleGen(just(history), genChanges, genChanges)
12 | })
--------------------------------------------------------------------------------
/src/history/__tests__/history.spec.ts:
--------------------------------------------------------------------------------
1 | // import {StringWithState, Operation} from '../../app/utils/Text'
2 | import * as _ from 'underscore'
3 | import { History } from '../History'
4 | import { expectEqual, JSONStringify } from '../../core/util'
5 | import { DocClient } from '../../service/DocClient'
6 | import { DocServer } from '../../service/DocServer'
7 | import { randomChanges } from '../../__tests__/random'
8 | import { ContentChangeList, ContentChangeListGen } from '../../__tests__/generator/ContentChangeList'
9 | import { Delta } from '../../core/Delta'
10 | import { IDelta } from '../../core/IDelta'
11 | import { contentLength, normalizeOps } from '../../core/primitive'
12 | import { forAll } from 'jsproptest'
13 |
14 | describe('History interface', () => {
15 | it('revision convention', () => {
16 | const initialContent = 'initial'
17 | const history = new History('A', initialContent)
18 | expectEqual(history.getContentAt(0).ops, [{ insert: 'initial' }])
19 | expectEqual(history.getCurrentRev(), 0)
20 | expect(() => history.getChangeAt(0)).toThrow()
21 | expect(() => history.getChangeFor(0)).toThrow()
22 | const change = new Delta().insert('hello')
23 | history.append([change])
24 | expectEqual(history.getChangeAt(0), change)
25 | expectEqual(history.getChangeFor(1), change)
26 | expectEqual(history.getCurrentRev(), 1)
27 | expectEqual(history.getContent(), history.getContentAt(history.getCurrentRev()))
28 | expect(() => history.getChangeAt(1)).toThrow()
29 | })
30 |
31 | it('revision convention 2', () => {
32 | const contentChangeListGen = ContentChangeListGen()
33 | forAll((contentAndChangeList:ContentChangeList) => {
34 | const {content, changeList} = contentAndChangeList
35 | const deltas = changeList.deltas
36 | const history = new History('A', content)
37 | // initial state
38 | expectEqual(history.getContentAt(0).ops, normalizeOps(content.ops))
39 | expectEqual(history.getCurrentRev(), 0)
40 | expect(() => history.getChangeAt(0)).toThrow()
41 | expect(() => history.getChangeFor(0)).toThrow()
42 |
43 | // applied change list
44 | history.append(deltas)
45 | const rev = history.getCurrentRev()
46 | expectEqual(rev, deltas.length)
47 | expectEqual(history.getContent(), history.getContentAt(history.getCurrentRev()))
48 | expect(() => history.getChangeAt(rev+1)).toThrow()
49 | expect(() => history.getChangeFor(rev+1)).toThrow()
50 |
51 | for(let i = 0; i < rev; i++) {
52 | const delta:IDelta = new Delta(normalizeOps(deltas[i].ops))
53 | expectEqual(history.getChangeAt(i), delta)
54 | if(i > 0) {
55 | const prevdelta = new Delta(normalizeOps(deltas[i-1].ops))
56 | expectEqual(history.getChangeFor(i), prevdelta)
57 | }
58 | }
59 | }, contentChangeListGen)
60 | })
61 | })
62 |
63 | describe('server-client scenarios', () => {
64 | it('scenario 1', () => {
65 | const server = new DocServer()
66 | const client1 = new DocClient()
67 | const client2 = new DocClient()
68 |
69 | client1.apply([new Delta().insert('client1 text')])
70 | client2.apply([new Delta().insert('Hello world')])
71 |
72 | let req = client1.getSyncRequest()
73 | let merged = server.merge(req)
74 |
75 | client1.sync(merged)
76 |
77 | req = client2.getSyncRequest()
78 | merged = server.merge(req)
79 |
80 | client2.sync(merged)
81 |
82 | req = client1.getSyncRequest()
83 | merged = server.merge(req)
84 |
85 | client1.sync(merged)
86 |
87 | expect(client1.getContent()).toEqual(client2.getContent())
88 | expect(client1.getContent()).toEqual(server.getContent())
89 |
90 | client1.apply([new Delta().delete(3).insert('replace')])
91 | client1.sync(server.merge(client1.getSyncRequest()))
92 |
93 | client2.sync(server.merge(client2.getSyncRequest()))
94 |
95 | expect(client1.getContent()).toEqual(client2.getContent())
96 | expect(client1.getContent()).toEqual(server.getContent())
97 | })
98 | })
99 |
100 | describe('History hand-made scenarios', () => {
101 | it('scenario 1', () => {
102 | const initialText = 'initial'
103 | const serverHistory = new History('server', initialText)
104 | const clientHistory = new History('client1', initialText)
105 |
106 | const set1 = [new Delta().retain(7).insert(' text'), new Delta().insert('The ')]
107 | serverHistory.append(set1)
108 | // console.log(serverHistory.name, serverHistory.getCurrentRev(), serverHistory.getContent())
109 |
110 | const set2 = [new Delta().retain(7).insert(' string'), new Delta().insert('An ')]
111 | clientHistory.append(set2)
112 | // console.log(clientHistory.name, clientHistory.getCurrentRev(), clientHistory.getContent())
113 |
114 | const set1ForClient = serverHistory.merge({
115 | rev: 0,
116 | branch: clientHistory.name,
117 | changes: set2,
118 | })
119 | clientHistory.merge({
120 | rev: 0,
121 | branch: serverHistory.name,
122 | changes: set1ForClient.resChanges,
123 | })
124 |
125 | expect(clientHistory.getContent()).toEqual(serverHistory.getContent())
126 |
127 | const clientRev = clientHistory.getCurrentRev()
128 | const serverRev = serverHistory.getCurrentRev()
129 | const set3 = [new Delta().retain(3).insert('pending'), new Delta().insert('More Pending').delete(3)]
130 | clientHistory.append(set3)
131 |
132 | const set4 = [
133 | new Delta()
134 | .retain(2)
135 | .delete(2)
136 | .insert(' rebased'),
137 | new Delta().insert('More rebased '),
138 | ]
139 | serverHistory.append(set4)
140 |
141 | const clientRebased = clientHistory.rebase({ rev: clientRev, branch: serverHistory.name, changes: set4 })
142 | const serverRebased = serverHistory.rebase({ rev: serverRev, branch: clientHistory.name, changes: set3 })
143 |
144 | expect(clientHistory.getContent()).toEqual(serverHistory.getContent())
145 | })
146 |
147 | it('scenario 2', () => {
148 | const initialText = 'initial'
149 | const serverHistory = new History('server', initialText)
150 | const c1History = new History('client1', initialText)
151 |
152 | const serverSet = [new Delta().retain(7).insert(' text'), new Delta().insert('The ')]
153 | serverHistory.append(serverSet)
154 | // console.log(serverHistory.name, serverHistory.getCurrentRev(), serverHistory.getContent())
155 |
156 | const client1Set = [new Delta().retain(7).insert(' string'), new Delta().insert('An ')]
157 | c1History.append(client1Set)
158 | // console.log(c1History.name, c1History.getCurrentRev(), c1History.getContent())
159 |
160 | const serverSetForClient1 = serverHistory.merge({
161 | rev: 0,
162 | branch: c1History.name,
163 | changes: client1Set,
164 | })
165 | c1History.merge({
166 | rev: 0,
167 | branch: serverHistory.name,
168 | changes: serverSetForClient1.resChanges,
169 | })
170 |
171 | expect(c1History.getContent()).toEqual(serverHistory.getContent())
172 | })
173 | })
174 |
175 | describe('generated scenarios', () => {
176 | it('scenario 1', () => {
177 | for (let i = 0; i < 40; i++) {
178 | const initialText = 'initial'
179 | const serverHistory = new History('server', initialText, 5)
180 | const clientHistory = new History('client', initialText, 125)
181 |
182 | let serverRev = serverHistory.getCurrentRev()
183 | let clientRev = clientHistory.getCurrentRev()
184 |
185 | expectEqual(clientHistory.getContent(), serverHistory.getContent()) // , "<" + JSONStringify(set1) + " and " + JSONStringify(set2) + " and " + JSONStringify(set1ForClient) + ">")
186 |
187 | const set3 = randomChanges(contentLength(serverHistory.getContent()), 2)
188 | serverHistory.append(set3)
189 |
190 | const set4 = randomChanges(contentLength(clientHistory.getContent()), 2)
191 | clientHistory.append(set4)
192 |
193 | const set3ForClient = serverHistory.merge({
194 | rev: serverRev,
195 | branch: 'client',
196 | changes: set4,
197 | })
198 | clientHistory.merge({
199 | rev: clientRev,
200 | branch: 'server',
201 | changes: set3ForClient.resChanges,
202 | })
203 |
204 | expectEqual(
205 | clientHistory.getContent(),
206 | serverHistory.getContent(),
207 | JSONStringify(set3) + ' and ' + JSONStringify(set4) + ' and ' + JSONStringify(set3ForClient),
208 | )
209 |
210 | serverRev = serverHistory.getCurrentRev()
211 | clientRev = clientHistory.getCurrentRev()
212 |
213 | const set5 = randomChanges(contentLength(serverHistory.getContent()), 2)
214 | serverHistory.append(set5)
215 |
216 | const set6 = randomChanges(contentLength(clientHistory.getContent()), 2)
217 | clientHistory.append(set6)
218 |
219 | const set5ForClient = serverHistory.merge({
220 | rev: serverRev,
221 | branch: 'client',
222 | changes: set6,
223 | })
224 | clientHistory.merge({
225 | rev: clientRev,
226 | branch: 'server',
227 | changes: set5ForClient.resChanges,
228 | })
229 |
230 | expectEqual(
231 | clientHistory.getContent(),
232 | serverHistory.getContent(),
233 | JSONStringify(set5) + ' and ' + JSONStringify(set6) + ' and ' + JSONStringify(set5ForClient),
234 | )
235 | }
236 | })
237 | })
238 |
--------------------------------------------------------------------------------
/src/history/__tests__/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "max-classes-per-file":false,
5 | "curly": false,
6 | "interface-name": false,
7 | "object-literal-sort-keys": false,
8 | "ordered-imports": [
9 | true,
10 | {
11 | "named-imports-order": "any"
12 | }
13 | ],
14 | "no-bitwise": false,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { DocClient } from './service/DocClient'
2 | export { DocServer } from './service/DocServer'
3 | export { IHistory, History } from './history/History'
4 | export { SyncRequest } from './history/SyncRequest'
5 | export { SyncResponse } from './history/SyncResponse'
6 | export { Fragment } from './core/Fragment'
7 | export { SharedString } from './core/SharedString'
8 | export { IDelta } from './core/IDelta'
9 | export { Source } from './core/Source'
10 | export { Delta } from './core/Delta'
11 | export { Range, RangedTransforms } from './core/Range'
12 | export { Document } from './document/Document'
13 | export { ExcerptUtil, ExcerptSync } from './excerpt'
14 | export * from './core/util'
15 | export * from './core/primitive'
16 |
--------------------------------------------------------------------------------
/src/service/DocClient.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 | import { History, IHistory } from '../history/History'
3 | import { SyncResponse } from '../history/SyncResponse'
4 |
5 | export class DocClient {
6 | private history: IHistory
7 | private synchedRev: number = 0
8 | private synchedClientRev: number = 0
9 | private pending: IDelta[] = []
10 |
11 | constructor(history: IHistory = new History('client')) {
12 | this.history = history
13 | }
14 |
15 | public apply(deltas: IDelta[]) {
16 | this.history.append(deltas)
17 | this.pending = this.pending.concat(deltas)
18 | }
19 |
20 | public sync(response: SyncResponse) {
21 | this.history.merge({
22 | rev: this.synchedClientRev,
23 | branch: 'server',
24 | changes: response.resChanges,
25 | })
26 | this.synchedRev = response.rev
27 | this.synchedClientRev = this.history.getCurrentRev()
28 | this.pending = []
29 | }
30 |
31 | public getSyncRequest() {
32 | return {
33 | rev: this.synchedRev,
34 | branch: this.history.name,
35 | changes: this.pending,
36 | }
37 | }
38 |
39 | public getContent() {
40 | return this.history.getContent()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/service/DocServer.ts:
--------------------------------------------------------------------------------
1 | import { History, IHistory } from '../history/History'
2 | import { SyncRequest } from '../history/SyncRequest'
3 | import { SyncResponse } from '../history/SyncResponse'
4 |
5 | export class DocServer {
6 | private history: IHistory
7 |
8 | constructor(history: IHistory = new History('server')) {
9 | this.history = history
10 | }
11 |
12 | public merge(syncRequest: SyncRequest): SyncResponse {
13 | return this.history.merge(syncRequest)
14 | }
15 |
16 | public getContent() {
17 | return this.history.getContent()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/service/RepoClient.ts:
--------------------------------------------------------------------------------
1 | import { IDelta } from '../core/IDelta'
2 | import { History } from '../history/History'
3 | import { SyncRequest } from '../history/SyncRequest'
4 | import { DocClient } from './DocClient'
5 | import { RepoSyncResponse } from './RepoServer'
6 |
7 | export interface RepoSyncRequest {
8 | [name: string]: SyncRequest
9 | }
10 |
11 | export class RepoClient {
12 | public docs: { [name: string]: DocClient } = {}
13 |
14 | constructor(docNames: string[]) {
15 | for (const docName of docNames) {
16 | this.docs[docName] = new DocClient(new History(docName))
17 | }
18 | }
19 |
20 | public apply(docName: string, deltas: IDelta[]) {
21 | this.docs[docName].apply(deltas)
22 | }
23 |
24 | public sync(syncResponse: RepoSyncResponse) {
25 | for (const docName in syncResponse) {
26 | if (this.docs[docName]) {
27 | this.docs[docName].sync(syncResponse[docName])
28 | } else {
29 | throw new Error('Cannnot sync. No document exists with the name: ' + docName)
30 | }
31 | }
32 | }
33 |
34 | public getSyncRequest(): RepoSyncRequest {
35 | const repoSyncRequest: RepoSyncRequest = {}
36 | for (const docName in this.docs) {
37 | if (docName) {
38 | repoSyncRequest[docName] = this.docs[docName].getSyncRequest()
39 | }
40 | }
41 | return repoSyncRequest
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/service/RepoServer.ts:
--------------------------------------------------------------------------------
1 | import { History } from '../history/History'
2 | import { SyncResponse } from '../history/SyncResponse'
3 | import { DocServer } from './DocServer'
4 | import { RepoSyncRequest } from './RepoClient'
5 |
6 | export interface RepoSyncResponse {
7 | [name: string]: SyncResponse
8 | }
9 |
10 | export class RepoServer {
11 | public docs: { [name: string]: DocServer } = {}
12 |
13 | constructor(docNames: string[]) {
14 | for (const docName of docNames) {
15 | this.docs[docName] = new DocServer(new History(docName))
16 | }
17 | }
18 |
19 | public merge(syncRequest: RepoSyncRequest): RepoSyncResponse {
20 | const syncResponse: RepoSyncResponse = {}
21 | for (const docName in syncRequest) {
22 | if (this.docs[docName]) {
23 | const docSyncRequest = syncRequest[docName]
24 | syncResponse[docName] = this.docs[docName].merge(docSyncRequest)
25 | }
26 | }
27 | return syncResponse
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "target": "es5",
5 | "module": "commonjs",
6 | "lib": [
7 | "dom",
8 | "es6",
9 | "dom.iterable",
10 | "scripthost"
11 | ],
12 | "declaration": true,
13 | "outDir": "./lib",
14 | "strict": true
15 | },
16 | "include": ["src"],
17 | "exclude": ["node_modules", "**/__tests__/*"]
18 | }
19 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "curly": false,
5 | "interface-name": false,
6 | "object-literal-sort-keys": false,
7 | "ordered-imports": [
8 | true,
9 | {
10 | "named-imports-order": "any"
11 | }
12 | ]}
13 | }
14 |
--------------------------------------------------------------------------------