mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-15 07:34:42 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
60
backend/src/revisions/edit.dto.ts
Normal file
60
backend/src/revisions/edit.dto.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
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 {
|
||||
/**
|
||||
* Username of the user who authored this section
|
||||
* Is `null` if the user is anonymous
|
||||
* @example "john.smith"
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
username: UserInfoDto['username'] | null;
|
||||
|
||||
/**
|
||||
* Character index of the start of this section
|
||||
* @example 102
|
||||
*/
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@ApiProperty()
|
||||
startPos: number;
|
||||
|
||||
/**
|
||||
* Character index of the end of this section
|
||||
* Must be greater than {@link startPos}
|
||||
* @example 154
|
||||
*/
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@ApiProperty()
|
||||
endPos: number;
|
||||
|
||||
/**
|
||||
* Datestring of the time this section was created
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* Datestring of the last time this section was edited
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
66
backend/src/revisions/edit.entity.ts
Normal file
66
backend/src/revisions/edit.entity.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
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}
|
||||
*/
|
||||
@Entity()
|
||||
export class Edit {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* Revisions this edit appears in
|
||||
*/
|
||||
@ManyToMany((_) => Revision, (revision) => revision.edits)
|
||||
revisions: Promise<Revision[]>;
|
||||
|
||||
/**
|
||||
* Author that created the change
|
||||
*/
|
||||
@ManyToOne(() => Author, (author) => author.edits)
|
||||
author: Promise<Author>;
|
||||
|
||||
@Column()
|
||||
startPos: number;
|
||||
|
||||
@Column()
|
||||
endPos: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static create(
|
||||
author: Author,
|
||||
startPos: number,
|
||||
endPos: number,
|
||||
): Omit<Edit, 'id' | 'createdAt' | 'updatedAt'> {
|
||||
const newEdit = new Edit();
|
||||
newEdit.revisions = Promise.resolve([]);
|
||||
newEdit.author = Promise.resolve(author);
|
||||
newEdit.startPos = startPos;
|
||||
newEdit.endPos = endPos;
|
||||
return newEdit;
|
||||
}
|
||||
}
|
24
backend/src/revisions/edit.service.ts
Normal file
24
backend/src/revisions/edit.service.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
53
backend/src/revisions/revision-metadata.dto.ts
Normal file
53
backend/src/revisions/revision-metadata.dto.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDate, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { Revision } from './revision.entity';
|
||||
|
||||
export class RevisionMetadataDto extends BaseDto {
|
||||
/**
|
||||
* ID of this revision
|
||||
* @example 13
|
||||
*/
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
id: Revision['id'];
|
||||
|
||||
/**
|
||||
* Datestring of the time this revision was created
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* Number of characters in this revision
|
||||
* @example 142
|
||||
*/
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
length: number;
|
||||
|
||||
/**
|
||||
* List of the usernames that have contributed to this revision
|
||||
* Does not include anonymous users
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
authorUsernames: string[];
|
||||
|
||||
/**
|
||||
* Count of anonymous users that have contributed to this revision
|
||||
*/
|
||||
@IsNumber()
|
||||
@ApiProperty()
|
||||
anonymousAuthorCount: number;
|
||||
}
|
34
backend/src/revisions/revision.dto.ts
Normal file
34
backend/src/revisions/revision.dto.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, ValidateNested } from 'class-validator';
|
||||
|
||||
import { EditDto } from './edit.dto';
|
||||
import { RevisionMetadataDto } from './revision-metadata.dto';
|
||||
|
||||
export class RevisionDto extends RevisionMetadataDto {
|
||||
/**
|
||||
* Markdown content of the revision
|
||||
* @example "# I am a heading"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* Patch from the preceding revision to this one
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
patch: string;
|
||||
|
||||
/**
|
||||
* All edit objects which are used in the revision.
|
||||
*/
|
||||
@ValidateNested()
|
||||
@ApiProperty()
|
||||
edits: EditDto[];
|
||||
}
|
86
backend/src/revisions/revision.entity.ts
Normal file
86
backend/src/revisions/revision.entity.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { Edit } from './edit.entity';
|
||||
|
||||
/**
|
||||
* The state of a note at a particular point in time,
|
||||
* with the content at that time and the diff to the previous revision.
|
||||
*
|
||||
*/
|
||||
@Entity()
|
||||
export class Revision {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The patch from the previous revision to this one.
|
||||
*/
|
||||
@Column({
|
||||
type: 'text',
|
||||
})
|
||||
patch: string;
|
||||
|
||||
/**
|
||||
* The note content at this revision.
|
||||
*/
|
||||
@Column({
|
||||
type: 'text',
|
||||
})
|
||||
content: string;
|
||||
|
||||
/**
|
||||
* The length of the note content.
|
||||
*/
|
||||
@Column()
|
||||
length: number;
|
||||
|
||||
/**
|
||||
* Date at which the revision was created.
|
||||
*/
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* Note this revision belongs to.
|
||||
*/
|
||||
@ManyToOne((_) => Note, (note) => note.revisions, { onDelete: 'CASCADE' })
|
||||
note: Promise<Note>;
|
||||
|
||||
/**
|
||||
* All edit objects which are used in the revision.
|
||||
*/
|
||||
@ManyToMany((_) => Edit, (edit) => edit.revisions)
|
||||
@JoinTable()
|
||||
edits: Promise<Edit[]>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static create(
|
||||
content: string,
|
||||
patch: string,
|
||||
note: Note,
|
||||
): Omit<Revision, 'id' | 'createdAt'> {
|
||||
const newRevision = new Revision();
|
||||
newRevision.patch = patch;
|
||||
newRevision.content = content;
|
||||
newRevision.length = content.length;
|
||||
newRevision.note = Promise.resolve(note);
|
||||
newRevision.edits = Promise.resolve([]);
|
||||
return newRevision;
|
||||
}
|
||||
}
|
27
backend/src/revisions/revisions.module.ts
Normal file
27
backend/src/revisions/revisions.module.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 { Revision } from './revision.entity';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Revision, Edit]),
|
||||
LoggerModule,
|
||||
ConfigModule,
|
||||
AuthorsModule,
|
||||
],
|
||||
providers: [RevisionsService, EditService],
|
||||
exports: [RevisionsService, EditService],
|
||||
})
|
||||
export class RevisionsModule {}
|
243
backend/src/revisions/revisions.service.spec.ts
Normal file
243
backend/src/revisions/revisions.service.spec.ts
Normal file
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthToken } from '../auth/auth-token.entity';
|
||||
import { Author } from '../authors/author.entity';
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import databaseConfigMock from '../config/mock/database.config.mock';
|
||||
import noteConfigMock from '../config/mock/note.config.mock';
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { eventModuleConfig } from '../events';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { Alias } from '../notes/alias.entity';
|
||||
import { Note } from '../notes/note.entity';
|
||||
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 { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { Edit } from './edit.entity';
|
||||
import { EditService } from './edit.service';
|
||||
import { Revision } from './revision.entity';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
|
||||
describe('RevisionsService', () => {
|
||||
let service: RevisionsService;
|
||||
let revisionRepo: Repository<Revision>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RevisionsService,
|
||||
EditService,
|
||||
{
|
||||
provide: getRepositoryToken(Revision),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
NotesModule,
|
||||
LoggerModule,
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfigMock,
|
||||
databaseConfigMock,
|
||||
authConfigMock,
|
||||
noteConfigMock,
|
||||
],
|
||||
}),
|
||||
EventEmitterModule.forRoot(eventModuleConfig),
|
||||
],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(Edit))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(AuthToken))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Note))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Revision))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(Tag))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteGroupPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteUserPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Group))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Alias))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Session))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Author))
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
service = module.get<RevisionsService>(RevisionsService);
|
||||
revisionRepo = module.get<Repository<Revision>>(
|
||||
getRepositoryToken(Revision),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getRevision', () => {
|
||||
it('returns a revision', async () => {
|
||||
const note = Mock.of<Note>({});
|
||||
const revision = Revision.create('', '', note) as Revision;
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision);
|
||||
expect(await service.getRevision({} as Note, 1)).toEqual(revision);
|
||||
});
|
||||
it('throws if the revision is not in the databse', async () => {
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(service.getRevision({} as Note, 1)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purgeRevisions', () => {
|
||||
it('purges the revision history', async () => {
|
||||
const note = {} as Note;
|
||||
note.id = 4711;
|
||||
let revisions: Revision[] = [];
|
||||
const revision1 = Revision.create('a', 'a', note) as Revision;
|
||||
revision1.id = 1;
|
||||
const revision2 = Revision.create('b', 'b', note) as Revision;
|
||||
revision2.id = 2;
|
||||
const revision3 = Revision.create('c', 'c', note) as Revision;
|
||||
revision3.id = 3;
|
||||
revisions.push(revision1, revision2, revision3);
|
||||
note.revisions = Promise.resolve(revisions);
|
||||
jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions);
|
||||
jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision3);
|
||||
revisionRepo.remove = jest
|
||||
.fn()
|
||||
.mockImplementation((deleteList: Revision[]) => {
|
||||
revisions = revisions.filter(
|
||||
(item: Revision) => !deleteList.includes(item),
|
||||
);
|
||||
return Promise.resolve(deleteList);
|
||||
});
|
||||
|
||||
// expected to return all the purged revisions
|
||||
expect(await service.purgeRevisions(note)).toHaveLength(2);
|
||||
|
||||
// expected to have only the latest revision
|
||||
const updatedRevisions: Revision[] = [revision3];
|
||||
expect(revisions).toEqual(updatedRevisions);
|
||||
});
|
||||
it('has no effect on revision history when a single revision is present', async () => {
|
||||
const note = {} as Note;
|
||||
note.id = 4711;
|
||||
let revisions: Revision[] = [];
|
||||
const revision1 = Revision.create('a', 'a', note) as Revision;
|
||||
revision1.id = 1;
|
||||
revisions.push(revision1);
|
||||
note.revisions = Promise.resolve(revisions);
|
||||
jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions);
|
||||
jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision1);
|
||||
revisionRepo.remove = jest
|
||||
.fn()
|
||||
.mockImplementation((deleteList: Revision[]) => {
|
||||
revisions = revisions.filter(
|
||||
(item: Revision) => !deleteList.includes(item),
|
||||
);
|
||||
return Promise.resolve(deleteList);
|
||||
});
|
||||
|
||||
// expected to return all the purged revisions
|
||||
expect(await service.purgeRevisions(note)).toHaveLength(0);
|
||||
|
||||
// expected to have only the latest revision
|
||||
const updatedRevisions: Revision[] = [revision1];
|
||||
expect(revisions).toEqual(updatedRevisions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRevisionUserInfo', () => {
|
||||
it('counts users correctly', async () => {
|
||||
const user = User.create('test', 'test') as User;
|
||||
const author = Author.create(123) as Author;
|
||||
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 revision = Revision.create('', '', {} as Note) as Revision;
|
||||
revision.edits = Promise.resolve(edits);
|
||||
|
||||
const userInfo = await service.getRevisionUserInfo(revision);
|
||||
expect(userInfo.usernames.length).toEqual(1);
|
||||
expect(userInfo.anonymousUserCount).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRevision', () => {
|
||||
it('creates a new revision', async () => {
|
||||
const note = Mock.of<Note>({});
|
||||
const oldContent = 'old content\n';
|
||||
const newContent = 'new content\n';
|
||||
|
||||
const oldRevision = Mock.of<Revision>({ content: oldContent });
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision);
|
||||
jest
|
||||
.spyOn(revisionRepo, 'save')
|
||||
.mockImplementation((revision) =>
|
||||
Promise.resolve(revision as Revision),
|
||||
);
|
||||
|
||||
const createdRevision = await service.createRevision(note, newContent);
|
||||
expect(createdRevision).not.toBeUndefined();
|
||||
expect(createdRevision?.content).toBe(newContent);
|
||||
await expect(createdRevision?.note).resolves.toBe(note);
|
||||
expect(createdRevision?.patch).toMatchInlineSnapshot(`
|
||||
"Index: markdownContent
|
||||
===================================================================
|
||||
--- markdownContent
|
||||
+++ markdownContent
|
||||
@@ -1,1 +1,1 @@
|
||||
-old content
|
||||
+new content
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it("won't create a revision if content is unchanged", async () => {
|
||||
const note = Mock.of<Note>({});
|
||||
const oldContent = 'old content\n';
|
||||
|
||||
const oldRevision = Mock.of<Revision>({ content: oldContent });
|
||||
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision);
|
||||
const saveSpy = jest.spyOn(revisionRepo, 'save').mockImplementation();
|
||||
|
||||
const createdRevision = await service.createRevision(note, oldContent);
|
||||
expect(createdRevision).toBeUndefined();
|
||||
expect(saveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
163
backend/src/revisions/revisions.service.ts
Normal file
163
backend/src/revisions/revisions.service.ts
Normal file
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createPatch } from 'diff';
|
||||
import { Equal, Repository } from 'typeorm';
|
||||
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { EditService } from './edit.service';
|
||||
import { RevisionMetadataDto } from './revision-metadata.dto';
|
||||
import { RevisionDto } from './revision.dto';
|
||||
import { Revision } from './revision.entity';
|
||||
|
||||
class RevisionUserInfo {
|
||||
usernames: string[];
|
||||
anonymousUserCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RevisionsService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectRepository(Revision)
|
||||
private revisionRepository: Repository<Revision>,
|
||||
private editService: EditService,
|
||||
) {
|
||||
this.logger.setContext(RevisionsService.name);
|
||||
}
|
||||
|
||||
async getAllRevisions(note: Note): Promise<Revision[]> {
|
||||
this.logger.debug(`Getting all revisions for note ${note.id}`);
|
||||
return await this.revisionRepository
|
||||
.createQueryBuilder('revision')
|
||||
.where('revision.note = :note', { note: note.id })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Purge revision history of a note.
|
||||
* @param {Note} note - the note to purge the history
|
||||
* @return {Revision[]} an array of purged revisions
|
||||
*/
|
||||
async purgeRevisions(note: Note): Promise<Revision[]> {
|
||||
const revisions = await this.revisionRepository.find({
|
||||
where: {
|
||||
note: Equal(note),
|
||||
},
|
||||
});
|
||||
const latestRevision = await this.getLatestRevision(note);
|
||||
// get all revisions except the latest
|
||||
const oldRevisions = revisions.filter(
|
||||
(item) => item.id !== latestRevision.id,
|
||||
);
|
||||
// delete the old revisions
|
||||
return await this.revisionRepository.remove(oldRevisions);
|
||||
}
|
||||
|
||||
async getRevision(note: Note, revisionId: number): Promise<Revision> {
|
||||
const revision = await this.revisionRepository.findOne({
|
||||
where: {
|
||||
id: revisionId,
|
||||
note: Equal(note),
|
||||
},
|
||||
});
|
||||
if (revision === null) {
|
||||
throw new NotInDBError(
|
||||
`Revision with ID ${revisionId} for note ${note.id} not found.`,
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
async getLatestRevision(note: Note): Promise<Revision> {
|
||||
const revision = await this.revisionRepository.findOne({
|
||||
where: {
|
||||
note: Equal(note),
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
if (revision === null) {
|
||||
throw new NotInDBError(`Revision for note ${note.id} not found.`);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
authors = [...new Set(authors)]; // remove duplicates with Set
|
||||
|
||||
// retrieve user objects of the authors
|
||||
const users = await Promise.all(
|
||||
authors.map(async (author) => await author.user),
|
||||
);
|
||||
// collect usernames of the users
|
||||
const usernames = users.flatMap((user) => (user ? [user.username] : []));
|
||||
return {
|
||||
usernames: usernames,
|
||||
anonymousUserCount: users.length - usernames.length,
|
||||
};
|
||||
}
|
||||
|
||||
async toRevisionMetadataDto(
|
||||
revision: Revision,
|
||||
): Promise<RevisionMetadataDto> {
|
||||
const revisionUserInfo = await this.getRevisionUserInfo(revision);
|
||||
return {
|
||||
id: revision.id,
|
||||
length: revision.length,
|
||||
createdAt: revision.createdAt,
|
||||
authorUsernames: revisionUserInfo.usernames,
|
||||
anonymousAuthorCount: revisionUserInfo.anonymousUserCount,
|
||||
};
|
||||
}
|
||||
|
||||
async toRevisionDto(revision: Revision): Promise<RevisionDto> {
|
||||
const revisionUserInfo = await this.getRevisionUserInfo(revision);
|
||||
return {
|
||||
id: revision.id,
|
||||
content: revision.content,
|
||||
length: revision.length,
|
||||
createdAt: revision.createdAt,
|
||||
authorUsernames: revisionUserInfo.usernames,
|
||||
anonymousAuthorCount: revisionUserInfo.anonymousUserCount,
|
||||
patch: revision.patch,
|
||||
edits: await Promise.all(
|
||||
(
|
||||
await revision.edits
|
||||
).map(async (edit) => await this.editService.toEditDto(edit)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async createRevision(
|
||||
note: Note,
|
||||
newContent: string,
|
||||
): Promise<Revision | undefined> {
|
||||
// TODO: Save metadata
|
||||
const latestRevision = await this.getLatestRevision(note);
|
||||
const oldContent = latestRevision.content;
|
||||
if (oldContent === newContent) {
|
||||
return undefined;
|
||||
}
|
||||
const patch = createPatch(
|
||||
'markdownContent',
|
||||
latestRevision.content,
|
||||
newContent,
|
||||
);
|
||||
const revision = Revision.create(newContent, patch, note);
|
||||
return await this.revisionRepository.save(revision);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue