From 68780f54e1cc32b2bdf7c02af6e841741c4c3230 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Sun, 28 Apr 2024 10:38:03 +0200 Subject: [PATCH] wip: range authorships backend storage Co-authored-by: Philip Molares Signed-off-by: Philip Molares Signed-off-by: Erik Michelson --- backend/src/app.module.ts | 5 +- backend/src/authors/author.entity.ts | 8 +-- backend/src/authors/authors.module.ts | 8 ++- backend/src/authors/authors.service.ts | 42 +++++++++++ backend/src/history/history.service.spec.ts | 6 +- backend/src/media/media.service.spec.ts | 6 +- backend/src/notes/alias.service.spec.ts | 4 +- backend/src/notes/note.dto.ts | 10 +-- backend/src/notes/notes.service.spec.ts | 8 +-- backend/src/notes/notes.service.ts | 10 +-- .../permissions/permissions.service.spec.ts | 6 +- .../realtime-note/realtime-note.service.ts | 3 +- .../realtime-user-status-adapter.ts | 3 +- .../websocket/websocket.gateway.spec.ts | 6 +- backend/src/revisions/edit.service.ts | 24 ------- .../{edit.dto.ts => range-authorship.dto.ts} | 4 +- ...t.entity.ts => range-authorship.entity.ts} | 16 ++--- .../src/revisions/range-authorship.service.ts | 50 ++++++++++++++ backend/src/revisions/revision.dto.ts | 12 ++-- backend/src/revisions/revision.entity.ts | 17 +++-- backend/src/revisions/revisions.module.ts | 12 ++-- .../src/revisions/revisions.service.spec.ts | 28 ++++---- backend/src/revisions/revisions.service.ts | 32 +++++++-- backend/src/seed.ts | 17 +++-- commons/src/message-transporters/message.ts | 13 ++-- .../src/message-transporters/realtime-user.ts | 13 ++-- commons/src/y-doc-sync/index.ts | 3 +- commons/src/y-doc-sync/position-authorship.ts | 14 ++++ commons/src/y-doc-sync/realtime-doc.ts | 69 +++++++++++++++++-- 29 files changed, 316 insertions(+), 133 deletions(-) create mode 100644 backend/src/authors/authors.service.ts delete mode 100644 backend/src/revisions/edit.service.ts rename backend/src/revisions/{edit.dto.ts => range-authorship.dto.ts} (92%) rename backend/src/revisions/{edit.entity.ts => range-authorship.entity.ts} (70%) create mode 100644 backend/src/revisions/range-authorship.service.ts create mode 100644 commons/src/y-doc-sync/position-authorship.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 170d2a012..73c49b594 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -76,7 +76,8 @@ const routes: Routes = [ detectTsNode() ? 'ts' : 'js' }`, ], - migrationsRun: true, + migrationsRun: false, + synchronize: true, }; }, }), diff --git a/backend/src/authors/author.entity.ts b/backend/src/authors/author.entity.ts index 4bb5bcf0d..cf4fdc0e6 100644 --- a/backend/src/authors/author.entity.ts +++ b/backend/src/authors/author.entity.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -11,7 +11,7 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/range-authorship.entity'; import { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; @@ -52,8 +52,8 @@ export class Author { * List of edits that this author created * All edits must belong to the same note */ - @OneToMany(() => Edit, (edit) => edit.author) - edits: Promise; + @OneToMany(() => RangeAuthorship, (edit) => edit.author) + edits: Promise; // eslint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} diff --git a/backend/src/authors/authors.module.ts b/backend/src/authors/authors.module.ts index 06c09ba7a..ef3325709 100644 --- a/backend/src/authors/authors.module.ts +++ b/backend/src/authors/authors.module.ts @@ -1,14 +1,18 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { LoggerModule } from '../logger/logger.module'; import { Author } from './author.entity'; +import { AuthorsService } from './authors.service'; @Module({ - imports: [TypeOrmModule.forFeature([Author])], + imports: [TypeOrmModule.forFeature([Author]), LoggerModule], + providers: [AuthorsService], + exports: [AuthorsService], }) export class AuthorsModule {} diff --git a/backend/src/authors/authors.service.ts b/backend/src/authors/authors.service.ts new file mode 100644 index 000000000..0a2485624 --- /dev/null +++ b/backend/src/authors/authors.service.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { ShortRealtimeUser } from '@hedgedoc/commons'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { ConsoleLoggerService } from '../logger/console-logger.service'; +import { Author } from './author.entity'; + +@Injectable() +export class AuthorsService { + constructor( + private readonly logger: ConsoleLoggerService, + @InjectRepository(Author) private authorsRepository: Repository, + ) { + this.logger.setContext(AuthorsService.name); + } + + // /** + // * @async + // * Get or create the author specified by the short realtime user + // * @param {Username} username the username by which the user is specified + // * @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations + // * @return {User} the specified user + // */ + // async findOrCreateAuthor( + // shortRealtimeUser: ShortRealtimeUser, + // ): Promise { + // const author = await this.authorsRepository.findOne({ + // where: { username: username }, + // relations: withRelations, + // }); + // if (user === null) { + // throw new NotInDBError(`User with username '${username}' not found`); + // } + // return user; + // } +} diff --git a/backend/src/history/history.service.spec.ts b/backend/src/history/history.service.spec.ts index 1c4121507..d5723e9ef 100644 --- a/backend/src/history/history.service.spec.ts +++ b/backend/src/history/history.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -28,7 +28,7 @@ import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; import { RevisionsService } from '../revisions/revisions.service'; @@ -116,7 +116,7 @@ describe('HistoryService', () => { .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useValue({}) diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts index ee7810cdf..f94201556 100644 --- a/backend/src/media/media.service.spec.ts +++ b/backend/src/media/media.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -28,7 +28,7 @@ import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; @@ -82,7 +82,7 @@ describe('MediaService', () => { EventEmitterModule.forRoot(eventModuleConfig), ], }) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(AuthToken)) .useValue({}) diff --git a/backend/src/notes/alias.service.spec.ts b/backend/src/notes/alias.service.spec.ts index 876990a8f..a17aeb9c1 100644 --- a/backend/src/notes/alias.service.spec.ts +++ b/backend/src/notes/alias.service.spec.ts @@ -30,7 +30,7 @@ import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; import { Session } from '../sessions/session.entity'; @@ -122,7 +122,7 @@ describe('AliasService', () => { .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useClass(Repository) diff --git a/backend/src/notes/note.dto.ts b/backend/src/notes/note.dto.ts index deff740f4..724ab8ccb 100644 --- a/backend/src/notes/note.dto.ts +++ b/backend/src/notes/note.dto.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsString, ValidateNested } from 'class-validator'; -import { EditDto } from '../revisions/edit.dto'; +import { RangeAuthorshipDto } from '../revisions/range-authorship.dto'; import { BaseDto } from '../utils/base.dto.'; import { NoteMetadataDto } from './note-metadata.dto'; @@ -33,7 +33,7 @@ export class NoteDto extends BaseDto { */ @IsArray() @ValidateNested({ each: true }) - @Type(() => EditDto) - @ApiProperty({ isArray: true, type: EditDto }) - editedByAtPosition: EditDto[]; + @Type(() => RangeAuthorshipDto) + @ApiProperty({ isArray: true, type: RangeAuthorshipDto }) + editedByAtPosition: RangeAuthorshipDto[]; } diff --git a/backend/src/notes/notes.service.spec.ts b/backend/src/notes/notes.service.spec.ts index e10d126a8..7aead7058 100644 --- a/backend/src/notes/notes.service.spec.ts +++ b/backend/src/notes/notes.service.spec.ts @@ -41,7 +41,7 @@ import { LoggerModule } from '../logger/logger.module'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { RevisionsModule } from '../revisions/revisions.module'; import { RevisionsService } from '../revisions/revisions.service'; @@ -198,7 +198,7 @@ describe('NotesService', () => { .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useClass(Repository) @@ -248,13 +248,13 @@ describe('NotesService', () => { endPos: 1, updatedAt: new Date(1549312452000), author: Promise.resolve(author), - } as Edit, + } as RangeAuthorship, { startPos: 0, endPos: 1, updatedAt: new Date(1549312452001), author: Promise.resolve(author), - } as Edit, + } as RangeAuthorship, ]), createdAt: new Date(1549312452000), tags: Promise.resolve([ diff --git a/backend/src/notes/notes.service.ts b/backend/src/notes/notes.service.ts index 0bc8e3691..df36d3596 100644 --- a/backend/src/notes/notes.service.ts +++ b/backend/src/notes/notes.service.ts @@ -104,6 +104,7 @@ export class NotesService { const newRevision = await this.revisionsService.createRevision( newNote, noteContent, + [], // TODO Use the correct rangeAuthorships ); newNote.revisions = Promise.resolve( newRevision === undefined ? [] : [newRevision], @@ -261,7 +262,7 @@ export class NotesService { .createQueryBuilder('user') .innerJoin('user.authors', 'author') .innerJoin('author.edits', 'edit') - .innerJoin('edit.revisions', 'revision') + .innerJoin('edit.revision', 'revision') .innerJoin('revision.note', 'note') .where('note.id = :id', { id: note.id }) .getMany(); @@ -351,6 +352,7 @@ export class NotesService { const newRevision = await this.revisionsService.createRevision( note, noteContent, + [], // TODO Use the correct rangeAuthorships ); if (newRevision !== undefined) { revisions.push(newRevision); @@ -367,12 +369,12 @@ export class NotesService { */ async calculateUpdateUser(note: Note): Promise { const lastRevision = await this.revisionsService.getLatestRevision(note); - const edits = await lastRevision.edits; - if (edits.length > 0) { + const rangeAuthorships = await lastRevision.rangeAuthorships; + if (rangeAuthorships.length > 0) { // Sort the last Revisions Edits by their updatedAt Date to get the latest one // the user of that Edit is the updateUser return await ( - await edits.sort( + await rangeAuthorships.sort( (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(), )[0].author ).user; diff --git a/backend/src/permissions/permissions.service.spec.ts b/backend/src/permissions/permissions.service.spec.ts index 320c97b4f..bb8025ddc 100644 --- a/backend/src/permissions/permissions.service.spec.ts +++ b/backend/src/permissions/permissions.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -38,7 +38,7 @@ import { import { Note } from '../notes/note.entity'; import { NotesModule } from '../notes/notes.module'; import { Tag } from '../notes/tag.entity'; -import { Edit } from '../revisions/edit.entity'; +import { RangeAuthorship } from '../revisions/edit.entity'; import { Revision } from '../revisions/revision.entity'; import { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; @@ -166,7 +166,7 @@ describe('PermissionsService', () => { .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useValue({}) diff --git a/backend/src/realtime/realtime-note/realtime-note.service.ts b/backend/src/realtime/realtime-note/realtime-note.service.ts index 19ea69b8f..f2f20ee51 100644 --- a/backend/src/realtime/realtime-note/realtime-note.service.ts +++ b/backend/src/realtime/realtime-note/realtime-note.service.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -47,6 +47,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown { .createAndSaveRevision( realtimeNote.getNote(), realtimeNote.getRealtimeDoc().getCurrentContent(), + realtimeNote.getRealtimeDoc().getAbsolutePositionAuthorships(), realtimeNote.getRealtimeDoc().encodeStateAsUpdate(), ) .catch((reason) => this.logger.error(reason)); diff --git a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts index fef3f7983..a21f18475 100644 --- a/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts +++ b/backend/src/realtime/realtime-note/realtime-user-status-adapter.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -125,6 +125,7 @@ export class RealtimeUserStatusAdapter { ownUser: { displayName: this.realtimeUser.displayName, styleIndex: this.realtimeUser.styleIndex, + username: this.realtimeUser.username, }, }, }); diff --git a/backend/src/realtime/websocket/websocket.gateway.spec.ts b/backend/src/realtime/websocket/websocket.gateway.spec.ts index 16ff225da..74cd35d93 100644 --- a/backend/src/realtime/websocket/websocket.gateway.spec.ts +++ b/backend/src/realtime/websocket/websocket.gateway.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -33,7 +33,7 @@ import { NotePermission } from '../../permissions/note-permission.enum'; import { NoteUserPermission } from '../../permissions/note-user-permission.entity'; import { PermissionsModule } from '../../permissions/permissions.module'; import { PermissionsService } from '../../permissions/permissions.service'; -import { Edit } from '../../revisions/edit.entity'; +import { RangeAuthorship } from '../../revisions/range-authorship.entity'; import { Revision } from '../../revisions/revision.entity'; import { Session } from '../../sessions/session.entity'; import { SessionModule } from '../../sessions/session.module'; @@ -126,7 +126,7 @@ describe('Websocket gateway', () => { .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useValue({}) diff --git a/backend/src/revisions/edit.service.ts b/backend/src/revisions/edit.service.ts deleted file mode 100644 index 4cdf731fa..000000000 --- a/backend/src/revisions/edit.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) - * - * SPDX-License-Identifier: AGPL-3.0-only - */ -import { Injectable } from '@nestjs/common'; - -import { EditDto } from './edit.dto'; -import { Edit } from './edit.entity'; - -@Injectable() -export class EditService { - async toEditDto(edit: Edit): Promise { - const authorUser = await (await edit.author).user; - - return { - username: authorUser ? authorUser.username : null, - startPos: edit.startPos, - endPos: edit.endPos, - createdAt: edit.createdAt, - updatedAt: edit.updatedAt, - }; - } -} diff --git a/backend/src/revisions/edit.dto.ts b/backend/src/revisions/range-authorship.dto.ts similarity index 92% rename from backend/src/revisions/edit.dto.ts rename to backend/src/revisions/range-authorship.dto.ts index fcc6facdf..e18d007c6 100644 --- a/backend/src/revisions/edit.dto.ts +++ b/backend/src/revisions/range-authorship.dto.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,7 +10,7 @@ import { IsDate, IsNumber, IsOptional, IsString, Min } from 'class-validator'; import { UserInfoDto } from '../users/user-info.dto'; import { BaseDto } from '../utils/base.dto.'; -export class EditDto extends BaseDto { +export class RangeAuthorshipDto extends BaseDto { /** * Username of the user who authored this section * Is `null` if the user is anonymous diff --git a/backend/src/revisions/edit.entity.ts b/backend/src/revisions/range-authorship.entity.ts similarity index 70% rename from backend/src/revisions/edit.entity.ts rename to backend/src/revisions/range-authorship.entity.ts index bcb64d014..643f6c942 100644 --- a/backend/src/revisions/edit.entity.ts +++ b/backend/src/revisions/range-authorship.entity.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,6 @@ import { Column, CreateDateColumn, Entity, - ManyToMany, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, @@ -17,18 +16,18 @@ import { Author } from '../authors/author.entity'; import { Revision } from './revision.entity'; /** - * The Edit represents a change in the content of a note by a particular {@link Author} + * The RangeAuthorship represents a change in the content of a note by a particular {@link Author} */ @Entity() -export class Edit { +export class RangeAuthorship { @PrimaryGeneratedColumn() id: number; /** * Revisions this edit appears in */ - @ManyToMany((_) => Revision, (revision) => revision.edits) - revisions: Promise; + @ManyToOne((_) => Revision, (revision) => revision.rangeAuthorships) + revision: Promise; /** * Author that created the change @@ -55,9 +54,8 @@ export class Edit { author: Author, startPos: number, endPos: number, - ): Omit { - const newEdit = new Edit(); - newEdit.revisions = Promise.resolve([]); + ): Omit { + const newEdit = new RangeAuthorship(); newEdit.author = Promise.resolve(author); newEdit.startPos = startPos; newEdit.endPos = endPos; diff --git a/backend/src/revisions/range-authorship.service.ts b/backend/src/revisions/range-authorship.service.ts new file mode 100644 index 000000000..de5ce0cbb --- /dev/null +++ b/backend/src/revisions/range-authorship.service.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { AbsolutePositionAuthorship } from '@hedgedoc/commons'; +import { Injectable } from '@nestjs/common'; + +import { Author } from '../authors/author.entity'; +import { RangeAuthorshipDto } from './range-authorship.dto'; +import { RangeAuthorship } from './range-authorship.entity'; + +@Injectable() +export class RangeAuthorshipService { + createRangeAuthorshipsFromAbsolutePositions( + absolutePositions: AbsolutePositionAuthorship[], + noteLength: number, + ): RangeAuthorship[] { + return absolutePositions + .sort(([positionA], [positionB]) => { + return positionB - positionA; + }) + .reduce((authorships, [startPosition, _]) => { + const author = Author.create(1) as Author; // ToDo: use the real author here + const endPosition = authorships[0]?.startPos - 1 ?? noteLength; + authorships.unshift( + RangeAuthorship.create( + author, + startPosition, + endPosition, + ) as RangeAuthorship, + ); + return authorships; + }, []); + } + + async toRangeAuthorshipDto( + rangeAuthorship: RangeAuthorship, + ): Promise { + const authorUser = await (await rangeAuthorship.author).user; + + return { + username: authorUser ? authorUser.username : null, + startPos: rangeAuthorship.startPos, + endPos: rangeAuthorship.endPos, + createdAt: rangeAuthorship.createdAt, + updatedAt: rangeAuthorship.updatedAt, + }; + } +} diff --git a/backend/src/revisions/revision.dto.ts b/backend/src/revisions/revision.dto.ts index 53269b8be..c9247021b 100644 --- a/backend/src/revisions/revision.dto.ts +++ b/backend/src/revisions/revision.dto.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,7 +7,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsString, ValidateNested } from 'class-validator'; -import { EditDto } from './edit.dto'; +import { RangeAuthorshipDto } from './range-authorship.dto'; import { RevisionMetadataDto } from './revision-metadata.dto'; export class RevisionDto extends RevisionMetadataDto { @@ -27,10 +27,10 @@ export class RevisionDto extends RevisionMetadataDto { patch: string; /** - * All edit objects which are used in the revision. + * All range authorship objects which are used in the revision. */ - @Type(() => EditDto) + @Type(() => RangeAuthorshipDto) @ValidateNested({ each: true }) - @ApiProperty({ isArray: true, type: EditDto }) - edits: EditDto[]; + @ApiProperty({ isArray: true, type: RangeAuthorshipDto }) + rangeAuthorships: RangeAuthorshipDto[]; } diff --git a/backend/src/revisions/revision.entity.ts b/backend/src/revisions/revision.entity.ts index cdd770220..c60008ba1 100644 --- a/backend/src/revisions/revision.entity.ts +++ b/backend/src/revisions/revision.entity.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,12 +10,13 @@ import { JoinTable, ManyToMany, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { Note } from '../notes/note.entity'; import { Tag } from '../notes/tag.entity'; -import { Edit } from './edit.entity'; +import { RangeAuthorship } from './range-authorship.entity'; /** * The state of a note at a particular point in time, @@ -84,9 +85,12 @@ export class Revision { /** * All edit objects which are used in the revision. */ - @ManyToMany((_) => Edit, (edit) => edit.revisions) - @JoinTable() - edits: Promise; + @OneToMany( + (_) => RangeAuthorship, + (rangeAuthorship) => rangeAuthorship.revision, + { onDelete: 'CASCADE' }, + ) + rangeAuthorships: Promise; // eslint-disable-next-line @typescript-eslint/no-empty-function private constructor() {} @@ -99,6 +103,7 @@ export class Revision { title: string, description: string, tags: Tag[], + rangeAuthorships: RangeAuthorship[], ): Omit { const newRevision = new Revision(); newRevision.patch = patch; @@ -108,7 +113,7 @@ export class Revision { newRevision.description = description; newRevision.tags = Promise.resolve(tags); newRevision.note = Promise.resolve(note); - newRevision.edits = Promise.resolve([]); + newRevision.rangeAuthorships = Promise.resolve(rangeAuthorships); newRevision.yjsStateVector = yjsStateVector ?? null; return newRevision; } diff --git a/backend/src/revisions/revisions.module.ts b/backend/src/revisions/revisions.module.ts index 453339c6b..55a7b02f7 100644 --- a/backend/src/revisions/revisions.module.ts +++ b/backend/src/revisions/revisions.module.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -9,19 +9,19 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthorsModule } from '../authors/authors.module'; import { LoggerModule } from '../logger/logger.module'; -import { Edit } from './edit.entity'; -import { EditService } from './edit.service'; +import { RangeAuthorship } from './range-authorship.entity'; +import { RangeAuthorshipService } from './range-authorship.service'; import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Revision, Edit]), + TypeOrmModule.forFeature([Revision, RangeAuthorship]), LoggerModule, ConfigModule, AuthorsModule, ], - providers: [RevisionsService, EditService], - exports: [RevisionsService, EditService], + providers: [RevisionsService, RangeAuthorshipService], + exports: [RevisionsService, RangeAuthorshipService], }) export class RevisionsModule {} diff --git a/backend/src/revisions/revisions.service.spec.ts b/backend/src/revisions/revisions.service.spec.ts index 1b1154a44..135f662ca 100644 --- a/backend/src/revisions/revisions.service.spec.ts +++ b/backend/src/revisions/revisions.service.spec.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -29,8 +29,8 @@ import { NoteGroupPermission } from '../permissions/note-group-permission.entity import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; -import { Edit } from './edit.entity'; -import { EditService } from './edit.service'; +import { RangeAuthorship } from './edit.entity'; +import { RangeAuthorshipService } from './range-authorship.service'; import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; @@ -42,7 +42,7 @@ describe('RevisionsService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ RevisionsService, - EditService, + RangeAuthorshipService, { provide: getRepositoryToken(Revision), useClass: Repository, @@ -63,7 +63,7 @@ describe('RevisionsService', () => { EventEmitterModule.forRoot(eventModuleConfig), ], }) - .overrideProvider(getRepositoryToken(Edit)) + .overrideProvider(getRepositoryToken(RangeAuthorship)) .useValue({}) .overrideProvider(getRepositoryToken(User)) .useValue({}) @@ -183,12 +183,14 @@ describe('RevisionsService', () => { author.user = Promise.resolve(user); const anonAuthor = Author.create(123) as Author; const anonAuthor2 = Author.create(123) as Author; - const edits = [Edit.create(author, 12, 15) as Edit]; - edits.push(Edit.create(author, 16, 18) as Edit); - edits.push(Edit.create(author, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor, 29, 20) as Edit); - edits.push(Edit.create(anonAuthor2, 29, 20) as Edit); + const edits = [RangeAuthorship.create(author, 12, 15) as RangeAuthorship]; + edits.push(RangeAuthorship.create(author, 16, 18) as RangeAuthorship); + edits.push(RangeAuthorship.create(author, 29, 20) as RangeAuthorship); + edits.push(RangeAuthorship.create(anonAuthor, 29, 20) as RangeAuthorship); + edits.push(RangeAuthorship.create(anonAuthor, 29, 20) as RangeAuthorship); + edits.push( + RangeAuthorship.create(anonAuthor2, 29, 20) as RangeAuthorship, + ); const revision = Mock.of({}); revision.edits = Promise.resolve(edits); @@ -210,7 +212,7 @@ describe('RevisionsService', () => { description: 'mockDescription', patch: 'mockPatch', edits: Promise.resolve([ - Mock.of({ + Mock.of({ endPos: 93, startPos: 34, createdAt: new Date('2020-03-04T20:12:00.000Z'), @@ -259,7 +261,7 @@ describe('RevisionsService', () => { description: 'mockDescription', patch: 'mockPatch', edits: Promise.resolve([ - Mock.of({ + Mock.of({ endPos: 93, startPos: 34, createdAt: new Date('2020-03-04T22:32:00.000Z'), diff --git a/backend/src/revisions/revisions.service.ts b/backend/src/revisions/revisions.service.ts index bc038e114..a1ee8adb7 100644 --- a/backend/src/revisions/revisions.service.ts +++ b/backend/src/revisions/revisions.service.ts @@ -1,8 +1,9 @@ /* - * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ +import type { AbsolutePositionAuthorship } from '@hedgedoc/commons'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { createPatch } from 'diff'; @@ -12,7 +13,7 @@ import { NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { Note } from '../notes/note.entity'; import { Tag } from '../notes/tag.entity'; -import { EditService } from './edit.service'; +import { RangeAuthorshipService } from './range-authorship.service'; import { RevisionMetadataDto } from './revision-metadata.dto'; import { RevisionDto } from './revision.dto'; import { Revision } from './revision.entity'; @@ -29,7 +30,7 @@ export class RevisionsService { private readonly logger: ConsoleLoggerService, @InjectRepository(Revision) private revisionRepository: Repository, - private editService: EditService, + private rangeAuthorshipService: RangeAuthorshipService, ) { this.logger.setContext(RevisionsService.name); } @@ -97,7 +98,9 @@ export class RevisionsService { async getRevisionUserInfo(revision: Revision): Promise { // get a deduplicated list of all authors let authors = await Promise.all( - (await revision.edits).map(async (edit) => await edit.author), + (await revision.rangeAuthorships).map( + async (rangeAuthorship) => await rangeAuthorship.author, + ), ); authors = [...new Set(authors)]; // remove duplicates with Set @@ -142,9 +145,12 @@ export class RevisionsService { authorUsernames: revisionUserInfo.usernames, anonymousAuthorCount: revisionUserInfo.anonymousUserCount, patch: revision.patch, - edits: await Promise.all( - (await revision.edits).map( - async (edit) => await this.editService.toEditDto(edit), + rangeAuthorships: await Promise.all( + (await revision.rangeAuthorships).map( + async (rangeAuthorship) => + await this.rangeAuthorshipService.toRangeAuthorshipDto( + rangeAuthorship, + ), ), ), }; @@ -158,12 +164,14 @@ export class RevisionsService { * @param note The note for which the revision should be created * @param newContent The new note content * @param yjsStateVector The yjs state vector that describes the new content + * @param absolutePositionAuthorships The absolute positions for authorship marks * @return {Revision} the created revision * @return {undefined} if the revision couldn't be created because e.g. the content hasn't changed */ async createRevision( note: Note, newContent: string, + absolutePositionAuthorships: AbsolutePositionAuthorship[], yjsStateVector?: number[], ): Promise { const latestRevision = @@ -186,6 +194,12 @@ export class RevisionsService { return entity; }); + const rangeAuthorships = + this.rangeAuthorshipService.createRangeAuthorshipsFromAbsolutePositions( + absolutePositionAuthorships, + newContent.length, + ); + return Revision.create( newContent, patch, @@ -194,6 +208,7 @@ export class RevisionsService { title, description, tagEntities, + rangeAuthorships, ) as Revision; } @@ -203,16 +218,19 @@ export class RevisionsService { * @async * @param note The note for which the revision should be created * @param newContent The new note content + * @param absolutePositionAuthorships The absolute positions for authorship marks * @param yjsStateVector The yjs state vector that describes the new content */ async createAndSaveRevision( note: Note, newContent: string, + absolutePositionAuthorships: AbsolutePositionAuthorship[], yjsStateVector?: number[], ): Promise { const revision = await this.createRevision( note, newContent, + absolutePositionAuthorships, yjsStateVector, ); if (revision) { diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 956599450..ed54a88bc 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -17,7 +17,7 @@ import { Note } from './notes/note.entity'; import { Tag } from './notes/tag.entity'; import { NoteGroupPermission } from './permissions/note-group-permission.entity'; import { NoteUserPermission } from './permissions/note-user-permission.entity'; -import { Edit } from './revisions/edit.entity'; +import { RangeAuthorship } from './revisions/range-authorship.entity'; import { Revision } from './revisions/revision.entity'; import { Session } from './sessions/session.entity'; import { User } from './users/user.entity'; @@ -33,7 +33,7 @@ const dataSource = new DataSource({ User, Note, Revision, - Edit, + RangeAuthorship, NoteGroupPermission, NoteUserPermission, Group, @@ -81,9 +81,14 @@ dataSource 'Test note', '', [], + [], ) as Revision; - const edit = Edit.create(author, 1, 42) as Edit; - revision.edits = Promise.resolve([edit]); + const rangeAuthorship = RangeAuthorship.create( + author, + 1, + 42, + ) as RangeAuthorship; + revision.rangeAuthorships = Promise.resolve([rangeAuthorship]); notes[i].revisions = Promise.all([revision]); notes[i].userPermissions = Promise.resolve([]); notes[i].groupPermissions = Promise.resolve([]); @@ -92,7 +97,7 @@ dataSource notes[i], user, revision, - edit, + rangeAuthorship, author, identity, ]); diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts index f45e33c06..594208bdb 100644 --- a/commons/src/message-transporters/message.ts +++ b/commons/src/message-transporters/message.ts @@ -1,9 +1,13 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import { RealtimeUser, RemoteCursor } from './realtime-user.js' +import { + RealtimeUser, + RemoteCursor, + ShortRealtimeUser +} from './realtime-user.js' export enum MessageType { NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST', @@ -33,10 +37,7 @@ export interface MessagePayloads { [MessageType.NOTE_CONTENT_UPDATE]: number[] [MessageType.REALTIME_USER_STATE_SET]: { users: RealtimeUser[] - ownUser: { - displayName: string - styleIndex: number - } + ownUser: ShortRealtimeUser } [MessageType.REALTIME_USER_SINGLE_UPDATE]: RemoteCursor diff --git a/commons/src/message-transporters/realtime-user.ts b/commons/src/message-transporters/realtime-user.ts index c6de286b7..1072c94dc 100644 --- a/commons/src/message-transporters/realtime-user.ts +++ b/commons/src/message-transporters/realtime-user.ts @@ -1,14 +1,11 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -export interface RealtimeUser { - displayName: string - username: string | null +export interface RealtimeUser extends ShortRealtimeUser { active: boolean - styleIndex: number cursor: RemoteCursor | null } @@ -16,3 +13,9 @@ export interface RemoteCursor { from: number to?: number } + +export interface ShortRealtimeUser { + displayName: string + styleIndex: number + username: string | null +} diff --git a/commons/src/y-doc-sync/index.ts b/commons/src/y-doc-sync/index.ts index 72c76aa79..264b36414 100644 --- a/commons/src/y-doc-sync/index.ts +++ b/commons/src/y-doc-sync/index.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -7,3 +7,4 @@ export * from './y-doc-sync-client-adapter.js' export * from './y-doc-sync-server-adapter.js' export * from './y-doc-sync-adapter.js' export * from './realtime-doc.js' +export * from './position-authorship.js' diff --git a/commons/src/y-doc-sync/position-authorship.ts b/commons/src/y-doc-sync/position-authorship.ts new file mode 100644 index 000000000..3a4c81e9b --- /dev/null +++ b/commons/src/y-doc-sync/position-authorship.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { ShortRealtimeUser } from '../message-transporters/index.js' +import { AbsolutePosition, RelativePosition } from 'yjs' + +export type RelativePositionAuthorship = [RelativePosition, ShortRealtimeUser] +export type OptionalAbsolutePositionAuthorship = [ + AbsolutePosition | null, + ShortRealtimeUser +] +export type AbsolutePositionAuthorship = [number, ShortRealtimeUser] diff --git a/commons/src/y-doc-sync/realtime-doc.ts b/commons/src/y-doc-sync/realtime-doc.ts index 5ac782ed4..a40968a40 100644 --- a/commons/src/y-doc-sync/realtime-doc.ts +++ b/commons/src/y-doc-sync/realtime-doc.ts @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ @@ -10,10 +10,21 @@ import { Doc, encodeStateAsUpdate, encodeStateVector, - Text as YText + Text as YText, + Array as YArray, + createAbsolutePositionFromRelativePosition, + createRelativePositionFromTypeIndex, + AbsolutePosition } from 'yjs' +import { + AbsolutePositionAuthorship, + OptionalAbsolutePositionAuthorship, + RelativePositionAuthorship +} from './position-authorship.js' +import { RealtimeUser } from '../message-transporters/index.js' const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent' +const RELATIVE_POSITION_AUTHORSHIPS_CHANNEL_NAME = 'relativePositionAuthorships' export interface RealtimeDocEvents extends EventMap { update: (update: number[], origin: unknown) => void @@ -36,13 +47,33 @@ export class RealtimeDoc extends EventEmitter2 { * * @param initialTextContent the initial text content of the {@link Doc YDoc} * @param initialYjsState the initial yjs state. If provided this will be used instead of the text content + * @param initialAbsolutePositionAuthorships the initial realtime range authorships */ - constructor(initialTextContent?: string, initialYjsState?: number[]) { + constructor( + initialTextContent?: string, + initialYjsState?: number[], + initialAbsolutePositionAuthorships?: AbsolutePositionAuthorship[] + ) { super() if (initialYjsState) { this.applyUpdate(initialYjsState, this) - } else if (initialTextContent) { - this.getMarkdownContentChannel().insert(0, initialTextContent) + } else { + if (initialTextContent) { + this.getMarkdownContentChannel().insert(0, initialTextContent) + } + + if (initialAbsolutePositionAuthorships) { + this.getRelativePositionAuthorshipsChannel().insert( + 0, + initialAbsolutePositionAuthorships.map(([index, user]) => [ + createRelativePositionFromTypeIndex( + this.getMarkdownContentChannel(), + index + ), + user + ]) + ) + } } this.docUpdateListener = (update, origin) => { @@ -60,6 +91,15 @@ export class RealtimeDoc extends EventEmitter2 { return this.doc.getText(MARKDOWN_CONTENT_CHANNEL_NAME) } + /** + * Extracts the {@link YMap map channel} that contains the realtime range authorships. + * + * @return The realtime range authorships channel + */ + public getRelativePositionAuthorshipsChannel(): YArray { + return this.doc.getArray(RELATIVE_POSITION_AUTHORSHIPS_CHANNEL_NAME) + } + /** * Gets the current content of the note as it's currently edited in realtime. * @@ -72,6 +112,25 @@ export class RealtimeDoc extends EventEmitter2 { return this.getMarkdownContentChannel().toString() } + /** + * Gets the current authorship positions for the realtime. + * + * Please be aware that the return of this method may be very quickly outdated. + * + * @return An array of . + */ + public getAbsolutePositionAuthorships(): AbsolutePositionAuthorship[] { + return this.getRelativePositionAuthorshipsChannel() + .map(([relativePosition, user]) => [ + createAbsolutePositionFromRelativePosition(relativePosition, this.doc), + user + ]) + .filter( + (value: [T | null, S]): value is [T, S] => value[0] !== null + ) + .map(([absolutePosition, user]) => [absolutePosition.index, user]) + } + /** * Encodes the current state of the doc as update so it can be applied to other y-docs. *