diff --git a/.gitignore b/.gitignore index 008a032a..f12c7031 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ pnpm-lock.yaml *.env coverage +/code/client/bun.lockb diff --git a/code/client/package.json b/code/client/package.json index 2b6e601b..0f82a261 100644 --- a/code/client/package.json +++ b/code/client/package.json @@ -23,7 +23,7 @@ "@emotion/styled": "^11.11.5", "@mui/material": "^5.15.16", "@notespace/shared": "file:..\\shared", - "@testing-library/jest-dom": "^6.4.2", + "@testing-library/jest-dom": "^6.4.5", "dotenv": "^16.4.5", "eslint-plugin-playwright": "^1.6.0", "lodash": "^4.17.21", @@ -41,7 +41,7 @@ "@testing-library/dom": "^10.1.0", "@testing-library/react": "^15.0.6", "@testing-library/user-event": "^14.5.2", - "@types/lodash": "^4.17.0", + "@types/lodash": "^4.17.1", "@types/node": "^20.12.8", "@types/react": "^18.3.1", "@types/react-dom": "^18.3.0", @@ -51,8 +51,8 @@ "@typescript-eslint/parser": "^7.8.0", "@vite-pwa/assets-generator": "^0.2.4", "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^1.5.3", - "@vitest/ui": "^1.5.3", + "@vitest/coverage-v8": "^1.6.0", + "@vitest/ui": "^1.6.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", @@ -61,14 +61,14 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.6", "jsdom": "^24.0.0", - "knip": "^5.11.0", + "knip": "^5.12.2", "prettier": "^3.2.5", "sass": "^1.76.0", "typescript": "^5.4.5", "vite": "^5.2.11", "vite-plugin-qrcode": "^0.2.3", "vite-tsconfig-paths": "^4.3.2", - "vitest": "^1.5.3" + "vitest": "^1.6.0" }, "packageManager": "pnpm@9.0.6+sha256.0624e30eff866cdeb363b15061bdb7fd9425b17bc1bb42c22f5f4efdea21f6b3" } diff --git a/code/client/src/domain/editor/crdt/fugue.ts b/code/client/src/domain/editor/crdt/fugue.ts index 8d17eb6e..507f6af0 100644 --- a/code/client/src/domain/editor/crdt/fugue.ts +++ b/code/client/src/domain/editor/crdt/fugue.ts @@ -1,4 +1,4 @@ -import { type Id, Nodes } from '@notespace/shared/crdt/types/nodes'; +import { type Id } from '@notespace/shared/crdt/types/nodes'; import { BlockStyle, InlineStyle } from '@notespace/shared/types/styles'; import { FugueTree } from '@notespace/shared/crdt/FugueTree'; import { generateReplicaId, nodeInsert } from './utils'; @@ -10,6 +10,7 @@ import { DeleteOperation, InlineStyleOperation, InsertOperation, + Operation, ReviveOperation, } from '@notespace/shared/crdt/types/operations'; @@ -27,12 +28,28 @@ export class Fugue { this.tree = new FugueTree(); } - /** - * Builds the tree from the given nodes map. - * @param nodes - */ - init(nodes: Nodes): void { - this.tree.setTree(nodes); + applyOperations(operations: Operation[]) { + for (const operation of operations) { + switch (operation.type) { + case 'insert': + this.insertRemote(operation); + break; + case 'delete': + this.deleteRemote(operation); + break; + case 'inline-style': + this.updateInlineStyleRemote(operation); + break; + case 'block-style': + this.updateBlockStyleRemote(operation); + break; + case 'revive': + this.reviveRemote(operation); + break; + default: + throw new Error('Invalid operation type'); + } + } } /** @@ -69,8 +86,10 @@ export class Fugue { */ private getInsertOperation({ line, column }: Cursor, { value, styles }: NodeInsert): InsertOperation { const id = { sender: this.replicaId, counter: this.counter++ }; - const lineNode = line === 0 ? this.tree.root : this.findNode('\n', line); + + const lineNode = this.tree.getLineRoot(line); const leftOrigin = column === 0 ? lineNode : this.getNodeByCursor({ line, column })!; + if (isEmpty(leftOrigin.rightChildren)) { return { type: 'insert', id, value, parent: leftOrigin.id, side: 'R', styles }; } @@ -87,7 +106,11 @@ export class Fugue { * @param styles */ private addNode({ id, value, parent, side, styles }: InsertOperation) { - this.tree.addNode(id, value, parent, side, styles); + if (value === '\n') { + this.tree.addLineRoot(id, value, parent, side, styles); + } else { + this.tree.addNode(id, value, parent, side, styles); + } } /** @@ -105,7 +128,7 @@ export class Fugue { */ deleteLocalByCursor(cursor: Cursor) { const node = - cursor.line > 0 && cursor.column === 0 ? this.findNode('\n', cursor.line - 1) : this.getNodeByCursor(cursor); + cursor.line > 0 && cursor.column === 0 ? this.tree.getLineRoot(cursor.line) : this.getNodeByCursor(cursor); if (node) return this.deleteLocalById(node.id); } @@ -148,7 +171,7 @@ export class Fugue { */ reviveLocalByCursor(cursor: Cursor) { const node = - cursor.line > 0 && cursor.column === 0 ? this.findNode('\n', cursor.line - 1) : this.getNodeByCursor(cursor); + cursor.line > 0 && cursor.column === 0 ? this.tree.getLineRoot(cursor.line) : this.getNodeByCursor(cursor); if (node) return this.reviveNode(node.id); } @@ -254,10 +277,13 @@ export class Fugue { */ *traverseBySelection(selection: Selection, returnDeleted: boolean = false): IterableIterator { const { start, end } = selection; - let lineCounter = 0, + let lineCounter = start.line, columnCounter = 0, inBounds = false; - for (const node of this.traverseTree(returnDeleted)) { + + const lineRootNode = this.tree.getLineRoot(start.line); + + for (const node of this.tree.traverse(lineRootNode, returnDeleted)) { // start condition if (lineCounter === start.line && columnCounter === start.column) { inBounds = true; @@ -330,22 +356,6 @@ export class Fugue { return iterator.next().value; } - /** - * Finds the node skip-th node with the given value - * @param value - * @param skip - */ - private findNode(value: string, skip: number): FugueNode { - let lastMatch = this.tree.root; - for (const node of this.traverseTree()) { - if (node.value === value) { - lastMatch = node; - if (--skip === 0) return lastMatch; - } - } - return lastMatch; - } - /** * Returns the string representation of the tree. */ diff --git a/code/client/src/domain/editor/crdt/types.ts b/code/client/src/domain/editor/crdt/types.ts index 15a89720..3d292cc9 100644 --- a/code/client/src/domain/editor/crdt/types.ts +++ b/code/client/src/domain/editor/crdt/types.ts @@ -1,9 +1,9 @@ import { type InlineStyle } from '@notespace/shared/types/styles'; -import { Node } from '@notespace/shared/crdt/types/nodes'; +import { NodeType } from '@notespace/shared/crdt/types/nodes'; export type NodeInsert = { value: string; styles: InlineStyle[]; }; -export type FugueNode = Node; +export type FugueNode = NodeType; diff --git a/code/client/src/domain/editor/operations/fugue/operations.ts b/code/client/src/domain/editor/operations/fugue/operations.ts index 3531bedf..a10c609a 100644 --- a/code/client/src/domain/editor/operations/fugue/operations.ts +++ b/code/client/src/domain/editor/operations/fugue/operations.ts @@ -1,37 +1,8 @@ -import { Operation } from '@notespace/shared/crdt/types/operations'; import { Fugue } from '@/domain/editor/crdt/fugue'; -import { Document } from '@notespace/shared/crdt/types/document'; import { FugueDomainOperations } from '@/domain/editor/operations/fugue/types'; export default (fugue: Fugue): FugueDomainOperations => { - function applyOperations(operations: Operation[]) { - for (const operation of operations) { - switch (operation.type) { - case 'insert': - fugue.insertRemote(operation); - break; - case 'delete': - fugue.deleteRemote(operation); - break; - case 'inline-style': - fugue.updateInlineStyleRemote(operation); - break; - case 'block-style': - fugue.updateBlockStyleRemote(operation); - break; - case 'revive': - fugue.reviveRemote(operation); - break; - default: - throw new Error('Invalid operation type'); - } - } - } - - const initDocument = ({ nodes }: Document) => fugue.init(nodes); - return { - applyOperations, - initDocument, + applyOperations: operations => fugue.applyOperations(operations), }; }; diff --git a/code/client/src/domain/editor/operations/fugue/types.ts b/code/client/src/domain/editor/operations/fugue/types.ts index 2ec72aff..846aff60 100644 --- a/code/client/src/domain/editor/operations/fugue/types.ts +++ b/code/client/src/domain/editor/operations/fugue/types.ts @@ -1,7 +1,5 @@ import { Operation } from '@notespace/shared/crdt/types/operations'; -import { Document } from '@notespace/shared/crdt/types/document'; export type FugueDomainOperations = { applyOperations: (operations: Operation[]) => void; - initDocument: (document: Document) => void; }; diff --git a/code/client/src/services/documentServices.ts b/code/client/src/services/documentServices.ts index 22820823..b2688677 100644 --- a/code/client/src/services/documentServices.ts +++ b/code/client/src/services/documentServices.ts @@ -2,8 +2,8 @@ import { HttpCommunication } from '@domain/communication/http/httpCommunication' import { Document } from '@notespace/shared/crdt/types/document'; async function getDocument(http: HttpCommunication, id: string): Promise { - const { nodes, title } = await http.get(`/documents/${id}`); - return { nodes, title } as Document; + const { operations, title } = await http.get(`/documents/${id}`); + return { operations, title } as Document; } async function createDocument(http: HttpCommunication): Promise { diff --git a/code/client/src/ui/pages/document/Document.tsx b/code/client/src/ui/pages/document/Document.tsx index 27814d0c..0cacc777 100644 --- a/code/client/src/ui/pages/document/Document.tsx +++ b/code/client/src/ui/pages/document/Document.tsx @@ -23,8 +23,8 @@ function Document() { useEffect(() => { async function fetchDocument() { if (!id) return; - const { nodes, title } = await services.getDocument(id); - fugue.init(nodes); + const { operations, title } = await services.getDocument(id); + fugue.applyOperations(operations); setTitle(title); socket.emit('joinDocument', id); setLoaded(true); diff --git a/code/client/tests/editor/domain/document/fugueOperations.test.ts b/code/client/tests/editor/domain/document/fugueOperations.test.ts index ba2870c9..5697c1ef 100644 --- a/code/client/tests/editor/domain/document/fugueOperations.test.ts +++ b/code/client/tests/editor/domain/document/fugueOperations.test.ts @@ -9,7 +9,7 @@ import { import getFugueOperations from '@/domain/editor/operations/fugue/operations'; import { FugueDomainOperations } from '@/domain/editor/operations/fugue/types'; import { Document } from '@notespace/shared/crdt/types/document'; -import { Node } from '@notespace/shared/crdt/types/nodes'; +import { Node, RootNode } from '@notespace/shared/crdt/types/nodes'; import { rootNode, treeNode } from '@notespace/shared/crdt/utils'; describe('Fugue Operations', () => { @@ -86,21 +86,22 @@ describe('Fugue Operations', () => { test('should initialize document', () => { // given - const root: Node = rootNode(); + const root: RootNode = rootNode(); const node1: Node = treeNode({ sender: 'A', counter: 0 }, 'a', root.id, 'R', 1); const node2: Node = treeNode({ sender: 'A', counter: 1 }, 'b', node1.id, 'R', 2); root.rightChildren = [node1.id]; node1.rightChildren = [node2.id]; const document: Document = { + id: 'test', title: 'test', - nodes: { - root: [root], - A: [node1, node2], - }, + operations: [ + { type: 'insert', ...node1, parent: root.id, styles: [] }, + { type: 'insert', ...node2, parent: node1.id, styles: [] }, + ], }; // when - fugueOperations.initDocument(document); + fugueOperations.applyOperations(document.operations); // then expect(fugue.toString()).toEqual('ab'); diff --git a/code/server/bun.lockb b/code/server/bun.lockb new file mode 100644 index 00000000..ab0d3bb5 Binary files /dev/null and b/code/server/bun.lockb differ diff --git a/code/server/package.json b/code/server/package.json index 84eea12a..b193e087 100644 --- a/code/server/package.json +++ b/code/server/package.json @@ -20,7 +20,8 @@ "firebase-admin": "^12.1.0", "lodash": "^4.17.21", "socket.io": "^4.7.5", - "supertest": "^6.3.4" + "supertest": "^6.3.4", + "uuid": "^9.0.1" }, "devDependencies": { "@babel/preset-env": "^7.24.5", @@ -28,7 +29,7 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", - "@types/lodash": "^4.17.0", + "@types/lodash": "^4.17.1", "@types/node": "^20.12.8", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", @@ -38,13 +39,13 @@ "eslint": "^8.57.0", "express-promise-router": "^4.1.1", "jest": "^29.7.0", - "knip": "^5.11.0", + "knip": "^5.12.2", "prettier": "^3.2.5", "socket.io-client": "^4.7.5", "test-jest": "^1.0.1", "ts-jest": "^29.1.2", "tsconfig-paths": "^4.2.0", - "tsx": "^4.8.2", + "tsx": "^4.9.1", "typescript": "^5.4.5" }, "packageManager": "pnpm@9.0.6+sha256.0624e30eff866cdeb363b15061bdb7fd9425b17bc1bb42c22f5f4efdea21f6b3" diff --git a/code/server/src/database/firestore/operations.ts b/code/server/src/database/firestore/operations.ts index b7f5dc00..51f5ba31 100644 --- a/code/server/src/database/firestore/operations.ts +++ b/code/server/src/database/firestore/operations.ts @@ -6,6 +6,8 @@ import { Document, DocumentData, DocumentStorageData } from '@notespace/shared/c import { v4 as uuid } from 'uuid'; import { NotFoundError } from '@domain/errors/errors'; import { Operation } from '@notespace/shared/crdt/types/operations'; +import { firestore } from 'firebase-admin'; +import FieldValue = firestore.FieldValue; export default function DocumentFirestoreDatabase(): DocumentDatabase { initializeApp({ @@ -35,36 +37,32 @@ export default function DocumentFirestoreDatabase(): DocumentDatabase { } async function getDocument(id: string): Promise { - const doc = await documents.doc(id).get(); - if (!doc.exists) { - throw new NotFoundError(`Document with id ${id} not found`); - } - return doc.data() as DocumentStorageData; + const doc = await getDoc(id); + return (await doc.get()).data() as DocumentStorageData; } async function deleteDocument(id: string) { - const doc = await documents.doc(id).get(); - if (!doc.exists) { - throw new NotFoundError(`Document with id ${id} not found`); - } - await documents.doc(id).delete(); + const doc = await getDoc(id); + await doc.delete(); } async function updateDocument(id: string, newOperations: Operation[]) { - const doc = await documents.doc(id).get(); - if (!doc.exists) { - throw new NotFoundError(`Document with id ${id} not found`); - } - const { operations } = doc.data() as DocumentStorageData; - await documents.doc(id).update({ operations: [...operations, ...newOperations] }); + const doc = await getDoc(id); + await doc.update({ operations: FieldValue.arrayUnion(newOperations) }); } async function updateTitle(id: string, title: string) { - const doc = await documents.doc(id).get(); - if (!doc.exists) { + const doc = await getDoc(id); + await doc.update({ title }); + } + + async function getDoc(id: string) { + const query = documents.where('id', '==', id); + const data = await query.get(); + if (data.empty) { throw new NotFoundError(`Document with id ${id} not found`); } - await documents.doc(id).update({ title }); + return data.docs[0].ref; } return { diff --git a/code/server/src/server.ts b/code/server/src/server.ts index 66c42069..1eab0c2d 100644 --- a/code/server/src/server.ts +++ b/code/server/src/server.ts @@ -2,7 +2,7 @@ import express from 'express'; import http from 'http'; import { Server } from 'socket.io'; import cors from 'cors'; -import databaseInit from '@database/memory/operations'; +import databaseInit from '@database/firestore/operations'; import serviceInit from '@services/documentService'; import eventsInit from '@controllers/ws/events'; import router from '@src/controllers/http/router'; diff --git a/code/server/src/services/documentService.ts b/code/server/src/services/documentService.ts index b843d7bc..1f9c4b6a 100644 --- a/code/server/src/services/documentService.ts +++ b/code/server/src/services/documentService.ts @@ -1,20 +1,8 @@ import { DocumentDatabase, DocumentService } from '@src/types'; -import { FugueTree } from '@notespace/shared/crdt/FugueTree'; -import { Nodes } from '@notespace/shared/crdt/types/nodes'; import { Document } from '@notespace/shared/crdt/types/document'; -import { - BlockStyleOperation, - DeleteOperation, - InlineStyleOperation, - InsertOperation, - Operation, - ReviveOperation, -} from '@notespace/shared/crdt/types/operations'; -import { InvalidParameterError } from '@domain/errors/errors'; +import { Operation } from '@notespace/shared/crdt/types/operations'; export default function DocumentService(database: DocumentDatabase): DocumentService { - const tree = new FugueTree(); - async function getDocuments() { return await database.getDocuments(); } @@ -25,9 +13,7 @@ export default function DocumentService(database: DocumentDatabase): DocumentSer async function getDocument(id: string): Promise { const { title, operations } = await database.getDocument(id); - tree.reset(); - await applyOperations(operations); - return { id, title, nodes: getNodes() }; + return { id, title, operations: operations }; } async function deleteDocument(id: string) { @@ -38,54 +24,6 @@ export default function DocumentService(database: DocumentDatabase): DocumentSer await database.updateDocument(id, operations); } - async function applyOperations(operations: Operation[]) { - for (const operation of operations) { - switch (operation.type) { - case 'insert': - await insertCharacter(operation); - break; - case 'delete': - await deleteCharacter(operation); - break; - case 'inline-style': - await updateInlineStyle(operation); - break; - case 'block-style': - await updateBlockStyle(operation); - break; - case 'revive': - await reviveCharacter(operation); - break; - default: - throw new InvalidParameterError('Invalid operation type'); - } - } - } - - async function insertCharacter({ id, value, parent, side, styles }: InsertOperation) { - tree.addNode(id, value, parent, side, styles); - } - - async function deleteCharacter({ id }: DeleteOperation) { - tree.deleteNode(id); - } - - async function updateInlineStyle({ id, style, value }: InlineStyleOperation) { - tree.updateInlineStyle(id, style, value); - } - - async function updateBlockStyle({ style, line, append }: BlockStyleOperation) { - tree.updateBlockStyle(style, line, append); - } - - async function reviveCharacter({ id }: ReviveOperation) { - tree.reviveNode(id); - } - - function getNodes(): Nodes { - return Object.fromEntries(Array.from(tree.nodes.entries())); - } - async function updateTitle(id: string, title: string) { await database.updateTitle(id, title); } diff --git a/code/server/tests/conflict-resolution/crdt.test.ts b/code/server/tests/conflict-resolution/crdt.test.ts index 2564f159..e10f9110 100644 --- a/code/server/tests/conflict-resolution/crdt.test.ts +++ b/code/server/tests/conflict-resolution/crdt.test.ts @@ -1,11 +1,12 @@ import * as http from 'http'; import { io, Socket } from 'socket.io-client'; -import { InsertOperation, DeleteOperation } from '@notespace/shared/crdt/types/operations'; +import {InsertOperation, DeleteOperation, Operation} from '@notespace/shared/crdt/types/operations'; import { Nodes } from '@notespace/shared/crdt/types/nodes'; import { FugueTree } from '@notespace/shared/crdt/FugueTree'; import request = require('supertest'); import { Server } from 'socket.io'; import server from '../../src/server'; +import {applyOperations} from "./utils"; const { app, onConnectionHandler } = server; const PORT = process.env.PORT || 8080; @@ -14,7 +15,7 @@ let ioServer: Server; let httpServer: http.Server; let client1: Socket; let client2: Socket; -const tree = new FugueTree(); +const tree = new FugueTree(); beforeAll(done => { httpServer = http.createServer(app); @@ -72,8 +73,11 @@ describe('Operations must be commutative', () => { // get the document const response = await request(app).get('/documents/' + id); expect(response.status).toBe(200); - const nodes = response.body.nodes as Nodes; - tree.setTree(nodes); + const operations = response.body.operations as Operation[]; + + // apply the operations to the tree + applyOperations(tree, operations); + expect(tree.toString()).toBe('ab'); }); }); diff --git a/code/server/tests/conflict-resolution/utils.ts b/code/server/tests/conflict-resolution/utils.ts new file mode 100644 index 00000000..f22fb231 --- /dev/null +++ b/code/server/tests/conflict-resolution/utils.ts @@ -0,0 +1,39 @@ +import {Operation} from "@notespace/shared/crdt/types/operations"; +import {FugueTree} from "@notespace/shared/crdt/FugueTree"; +import { treeNode } from "@notespace/shared/crdt/utils"; + +/** + * Applies the given operations to the tree + * @param tree the tree to apply the operations to + * @param operations the operations to apply + */ +export function applyOperations(tree : FugueTree, operations: Operation[]) { + for (const operation of operations) { + switch (operation.type) { + case 'insert': + tree.addNode(treeNode( + operation.id, + operation.value, + operation.parent || tree.root.id, + operation.side, + 0, + [] + )); + break; + case 'delete': + tree.deleteNode(operation.id); + break; + case 'inline-style': + tree.updateInlineStyle(operation.id, operation.style, operation.value); + break; + case 'block-style': + tree.updateBlockStyle(operation.style, operation.line); + break; + case 'revive': + tree.reviveNode(operation.id); + break; + default: + throw new Error('Invalid operation type'); + } + } +} \ No newline at end of file diff --git a/code/shared/bun.lockb b/code/shared/bun.lockb new file mode 100644 index 00000000..d105b6be Binary files /dev/null and b/code/shared/bun.lockb differ diff --git a/code/shared/crdt/FugueTree.ts b/code/shared/crdt/FugueTree.ts index 7dddaf43..df67d82e 100644 --- a/code/shared/crdt/FugueTree.ts +++ b/code/shared/crdt/FugueTree.ts @@ -1,15 +1,16 @@ import { Id, Node, Nodes } from "./types/nodes"; import { BlockStyle, InlineStyle } from "../types/styles"; import { isEmpty, isNull } from "lodash"; -import { rootNode, treeNode } from "./utils"; +import {rootNode, treeNode} from "./utils"; +import {RootNode, NodeType} from "./types/nodes"; export class FugueTree { - private _nodes = new Map[]>(); - private _root: Node; + private _root: RootNode; + private _nodes = new Map[]>(); constructor() { this._root = rootNode(); - this._nodes.set("root", [this.root]); + this._nodes.set("root", [this._root]); } /** @@ -17,9 +18,9 @@ export class FugueTree { * @param nodes */ setTree(nodes: Nodes) { - const nodesMap = new Map[]>(Object.entries(nodes)); + const nodesMap = new Map[]>(Object.entries(nodes)); this._nodes = nodesMap; - this._root = nodesMap.get("root")![0]; + this._root = nodesMap.get("root")![0] as RootNode; } /** @@ -30,15 +31,26 @@ export class FugueTree { * @param side the side of the parent node where this node is located * @param styles the styles of the node */ - addNode( - id: Id, - value: T, - parent: Id, - side: "L" | "R", - styles?: InlineStyle[], - ) { + addNode({ id, value, parent, side, styles }: Node) { // create node - const node = treeNode(id, value, parent, side, 0, styles); + const node = treeNode(id, value, parent, side, 0, styles as InlineStyle[]); + + // add to nodes map + const senderNodes = this.nodes.get(id.sender) || []; + if (isEmpty(senderNodes)) this.nodes.set(id.sender, senderNodes); + senderNodes.push(node); + + // insert into parent's siblings + this.insertChild(node); + + // update depths of ancestors + this.updateDepths(node, 1); + } + + addLineRoot({ id, value, parent, side, styles }: Node){ + // create node + const node = treeNode(id, value, parent, side, 0, styles as InlineStyle[]); + this._root.value.push(node); // add to nodes map const senderNodes = this.nodes.get(id.sender) || []; @@ -97,9 +109,9 @@ export class FugueTree { * @param node the node whose ancestors' depths are to be updated. * @param delta the amount by which to update the depths. */ - private updateDepths(node: Node, delta: number) { + private updateDepths(node: NodeType, delta: number) { for ( - let anc: Node | null = node; + let anc: NodeType | null = node; anc !== null; anc = anc.parent && this.getById(anc.parent) ) { @@ -113,7 +125,7 @@ export class FugueTree { * @throws if the id is unknown. * @returns the node with the given id. */ - getById(id: Id): Node { + getById(id: Id): NodeType { const bySender = this.nodes.get(id.sender); if (bySender !== undefined) { const node = bySender[id.counter]; @@ -122,12 +134,16 @@ export class FugueTree { throw new Error("Unknown ID: " + JSON.stringify(id)); } + getLineRoot(line: number): NodeType { + return line === 0 ? this._root : this._root.value[line - 1]; + } + /** * Returns the leftmost left-only descendant of node, i.e., the * first left child of the first left child ... of node. * */ - getLeftmostDescendant(nodeId: Id): Node { + getLeftmostDescendant(nodeId: Id): NodeType { let node = this.getById(nodeId); while (!isEmpty(node.leftChildren)) { node = this.getById(node.leftChildren[0]); @@ -166,16 +182,14 @@ export class FugueTree { * @param returnDeleted * @returns an iterator over the nodes in the subtree. */ - *traverse( - root: Node, - returnDeleted: boolean = false, - ): IterableIterator> { + *traverse(root: NodeType, returnDeleted: boolean = false): IterableIterator> { let current = root; const stack: { side: "L" | "R"; childIndex: number }[] = [ { side: "L", childIndex: 0 }, ]; while (true) { const top = stack[stack.length - 1]; + if (!top) return; const children = top.side === "L" ? current.leftChildren : current.rightChildren; if (top.childIndex === children.length) { @@ -205,19 +219,13 @@ export class FugueTree { } } - reset() { - this._nodes.clear(); - this._root = rootNode(); - this._nodes.set("root", [this.root]); - } - toString() { - return Array.from(this.traverse(this.root)) + return Array.from(this.traverse(this._root)) .map((node) => node.value) .join(""); } - get root(): Node { + get root(): RootNode { return this._root; } diff --git a/code/shared/crdt/types/document.ts b/code/shared/crdt/types/document.ts index 9624bc1e..bd778144 100644 --- a/code/shared/crdt/types/document.ts +++ b/code/shared/crdt/types/document.ts @@ -1,4 +1,3 @@ -import { Nodes } from "./nodes"; import { Operation } from "./operations"; export type DocumentData = { @@ -7,7 +6,7 @@ export type DocumentData = { }; export type Document = DocumentData & { - nodes: Nodes; + operations : Operation[]; }; export type DocumentStorageData = DocumentData & { diff --git a/code/shared/crdt/types/nodes.ts b/code/shared/crdt/types/nodes.ts index 0dc2a8d9..ba4027a9 100644 --- a/code/shared/crdt/types/nodes.ts +++ b/code/shared/crdt/types/nodes.ts @@ -19,7 +19,7 @@ export type Id = { */ export type Node = { id: Id; - value: T | null; + value: T; isDeleted: boolean; parent: Id | null; side: "L" | "R"; @@ -29,4 +29,8 @@ export type Node = { styles: Style[]; }; -export type Nodes = Record[]>; +export type RootNode = Node[]> + +export type NodeType = Node | RootNode + +export type Nodes = Record[]>; diff --git a/code/shared/crdt/utils.ts b/code/shared/crdt/utils.ts index 27d564eb..50a1c72a 100644 --- a/code/shared/crdt/utils.ts +++ b/code/shared/crdt/utils.ts @@ -1,10 +1,12 @@ -import { Node, Id } from "./types/nodes"; +import {Node, Id, RootNode} from "./types/nodes"; import { InlineStyle } from "../types/styles"; -export function rootNode(): Node { + + +export function rootNode(): RootNode { return { id: { sender: "root", counter: 0 }, - value: null, + value: [], isDeleted: true, parent: null, side: "R", @@ -18,7 +20,7 @@ export function rootNode(): Node { export function treeNode( id: Id, value: T, - parent: Id, + parent: Id | null , side: "L" | "R", depth: number, styles: InlineStyle[] = [], diff --git a/code/shared/package.json b/code/shared/package.json index 14e08dc6..aab48126 100644 --- a/code/shared/package.json +++ b/code/shared/package.json @@ -22,6 +22,5 @@ }, "dependencies": { "lodash": "^4.17.21" - }, - "packageManager": "pnpm@9.0.6+sha256.0624e30eff866cdeb363b15061bdb7fd9425b17bc1bb42c22f5f4efdea21f6b3" + } } diff --git a/docs/reports/monthly.zip b/docs/reports/monthly.zip deleted file mode 100644 index f5b1928c..00000000 Binary files a/docs/reports/monthly.zip and /dev/null differ diff --git a/docs/reports/monthly/April-report.md b/docs/reports/monthly/apr/April-report.md similarity index 100% rename from docs/reports/monthly/April-report.md rename to docs/reports/monthly/apr/April-report.md diff --git a/docs/reports/monthly/feb/February-report.md b/docs/reports/monthly/feb/February-report.md index 9d4008a4..f506367a 100644 --- a/docs/reports/monthly/feb/February-report.md +++ b/docs/reports/monthly/feb/February-report.md @@ -38,7 +38,7 @@ This socket connection will be used to send and receive changes to the editor, a Research on conflict resolution algorithms was started. The goal is to find a suitable algorithm that can be used to resolve conflicts in the live editor. -> More details on the conflict resolution algorithms can be found both in the [March report](../March-report.md) +> More details on the conflict resolution algorithms can be found both in the [March report](../mar/March-report.md) > and the [ConflictResolution](../../features/Editor.md#ConflictResolution) section. diff --git a/docs/reports/monthly/jul/July-report.md b/docs/reports/monthly/jul/July-report.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/reports/monthly/jun/June-report.md b/docs/reports/monthly/jun/June-report.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/reports/monthly/March-report.md b/docs/reports/monthly/mar/March-report.md similarity index 96% rename from docs/reports/monthly/March-report.md rename to docs/reports/monthly/mar/March-report.md index 6018e943..da260d21 100644 --- a/docs/reports/monthly/March-report.md +++ b/docs/reports/monthly/mar/March-report.md @@ -1,7 +1,7 @@ # Notespace - March Report ## Introduction This is the March report for the Notespace project. This report will cover the progress made in March. -> For detailed information on the project structure, please refer to [project structure](../project-overview-report.md) report. +> For detailed information on the project structure, please refer to [project structure](../../project-overview-report.md) report. ## Project summary @@ -17,7 +17,7 @@ The following tasks have been completed: A draft for the project's proposal was built and research on conflict algorithms continued. In the end, a CRDT algorithm was chosen, and we started implementing it. The algorithm itself is called Fugue. > More details on the conflict resolution algorithms can be found in the -[ConflictResolution](../features/Editor.md#ConflictResolution) section. +[ConflictResolution](../../features/Editor.md#ConflictResolution) section. 2. ### Fugue CRDT implementation diff --git a/docs/reports/monthly/may/May-report.md b/docs/reports/monthly/may/May-report.md new file mode 100644 index 00000000..e69de29b