wip: range authorships backend storage

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-04-28 10:38:03 +02:00
parent c5dc671398
commit 68780f54e1
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
29 changed files with 316 additions and 133 deletions

View file

@ -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,
};
},
}),

View file

@ -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<Edit[]>;
@OneToMany(() => RangeAuthorship, (edit) => edit.author)
edits: Promise<RangeAuthorship[]>;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}

View file

@ -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 {}

View file

@ -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<Author>,
) {
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<Author> {
// 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;
// }
}

View file

@ -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({})

View file

@ -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({})

View file

@ -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)

View file

@ -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[];
}

View file

@ -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([

View file

@ -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<User | null> {
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;

View file

@ -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({})

View file

@ -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));

View file

@ -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,
},
},
});

View file

@ -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({})

View file

@ -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<EditDto> {
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,
};
}
}

View file

@ -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

View file

@ -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<Revision[]>;
@ManyToOne((_) => Revision, (revision) => revision.rangeAuthorships)
revision: Promise<Revision>;
/**
* Author that created the change
@ -55,9 +54,8 @@ export class Edit {
author: Author,
startPos: number,
endPos: number,
): Omit<Edit, 'id' | 'createdAt' | 'updatedAt'> {
const newEdit = new Edit();
newEdit.revisions = Promise.resolve([]);
): Omit<RangeAuthorship, 'id' | 'createdAt' | 'updatedAt' | 'revision'> {
const newEdit = new RangeAuthorship();
newEdit.author = Promise.resolve(author);
newEdit.startPos = startPos;
newEdit.endPos = endPos;

View file

@ -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<RangeAuthorship[]>((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<RangeAuthorshipDto> {
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,
};
}
}

View file

@ -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[];
}

View file

@ -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<Edit[]>;
@OneToMany(
(_) => RangeAuthorship,
(rangeAuthorship) => rangeAuthorship.revision,
{ onDelete: 'CASCADE' },
)
rangeAuthorships: Promise<RangeAuthorship[]>;
// 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<Revision, 'id' | 'createdAt'> {
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;
}

View file

@ -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 {}

View file

@ -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>({});
revision.edits = Promise.resolve(edits);
@ -210,7 +212,7 @@ describe('RevisionsService', () => {
description: 'mockDescription',
patch: 'mockPatch',
edits: Promise.resolve([
Mock.of<Edit>({
Mock.of<RangeAuthorship>({
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<Edit>({
Mock.of<RangeAuthorship>({
endPos: 93,
startPos: 34,
createdAt: new Date('2020-03-04T22:32:00.000Z'),

View file

@ -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<Revision>,
private editService: EditService,
private rangeAuthorshipService: RangeAuthorshipService,
) {
this.logger.setContext(RevisionsService.name);
}
@ -97,7 +98,9 @@ export class RevisionsService {
async getRevisionUserInfo(revision: Revision): Promise<RevisionUserInfo> {
// 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<Revision | undefined> {
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<void> {
const revision = await this.createRevision(
note,
newContent,
absolutePositionAuthorships,
yjsStateVector,
);
if (revision) {

View file

@ -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,
]);

View file

@ -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

View file

@ -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
}

View file

@ -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'

View file

@ -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]

View file

@ -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,15 +47,35 @@ export class RealtimeDoc extends EventEmitter2<RealtimeDocEvents> {
*
* @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) {
} 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) => {
this.emit('update', Array.from(update), origin)
}
@ -60,6 +91,15 @@ export class RealtimeDoc extends EventEmitter2<RealtimeDocEvents> {
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<RelativePositionAuthorship> {
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<RealtimeDocEvents> {
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<OptionalAbsolutePositionAuthorship>(([relativePosition, user]) => [
createAbsolutePositionFromRelativePosition(relativePosition, this.doc),
user
])
.filter(
<T, S>(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.
*