chore(esdoc): update and unify ESDoc and parameter names (2)

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-05-29 21:59:40 +00:00
parent f47915dbb3
commit 3278e25dd1
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
22 changed files with 273 additions and 129 deletions

View file

@ -10,7 +10,8 @@ export const PERMISSION_METADATA_KEY = 'requiredPermission';
/** /**
* This decorator gathers the {@link PermissionLevel} a user must hold for the {@link PermissionsGuard} * This decorator gathers the {@link PermissionLevel} a user must hold for the {@link PermissionsGuard}
* @param permissionLevel the required permission for the decorated action. * @param permissionLevel the required permission for the decorated action
* @returns The custom decorator action
*/ */
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export function RequirePermission( export function RequirePermission(

View file

@ -1,3 +0,0 @@
SPDX-FileCopyrightText: The author of https://www.randomlists.com/
SPDX-License-Identifier: CC0-1.0

View file

@ -12,7 +12,7 @@ import * as HedgeDocCommonsModule from '@hedgedoc/commons';
import { FieldNameUser, User } from '@hedgedoc/database'; import { FieldNameUser, User } from '@hedgedoc/database';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import * as NameRandomizerModule from './random-word-lists/name-randomizer'; import * as NameRandomizerModule from '../../users/random-word-lists/name-randomizer';
import { RealtimeConnection } from './realtime-connection'; import { RealtimeConnection } from './realtime-connection';
import { RealtimeNote } from './realtime-note'; import { RealtimeNote } from './realtime-note';
import { import {
@ -21,7 +21,7 @@ import {
} from './realtime-user-status-adapter'; } from './realtime-user-status-adapter';
import * as RealtimeUserStatusModule from './realtime-user-status-adapter'; import * as RealtimeUserStatusModule from './realtime-user-status-adapter';
jest.mock('./random-word-lists/name-randomizer'); jest.mock('../../users/random-word-lists/name-randomizer');
jest.mock('./realtime-user-status-adapter'); jest.mock('./realtime-user-status-adapter');
jest.mock( jest.mock(
'@hedgedoc/commons', '@hedgedoc/commons',

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -26,8 +26,8 @@ export class RealtimeConnection {
* @param username The username of the user of the client * @param username The username of the user of the client
* @param displayName The displayName of the user of the client * @param displayName The displayName of the user of the client
* @param authorStyle The authorStyle of the user of the client * @param authorStyle The authorStyle of the user of the client
* @param realtimeNote The {@link RealtimeNote} that the client connected to. * @param realtimeNote The {@link RealtimeNote} that the client connected to
* @param acceptEdits If edits by this connection should be accepted. * @param acceptEdits If edits by this connection should be accepted
* @throws Error if the socket is not open * @throws Error if the socket is not open
*/ */
constructor( constructor(
@ -62,34 +62,74 @@ export class RealtimeConnection {
); );
} }
/**
* Returns the realtime user state adapter of this connection.
*
* @returns the realtime user state adapter
*/
public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter { public getRealtimeUserStateAdapter(): RealtimeUserStatusAdapter {
return this.realtimeUserStateAdapter; return this.realtimeUserStateAdapter;
} }
/**
* Returns the message transporter of this connection.
*
* @returns the message transporter
*/
public getTransporter(): MessageTransporter { public getTransporter(): MessageTransporter {
return this.transporter; return this.transporter;
} }
/**
* Returns the YDoc sync adapter of this connection.
*
* @returns the YDoc sync adapter
*/
public getSyncAdapter(): YDocSyncServerAdapter { public getSyncAdapter(): YDocSyncServerAdapter {
return this.yDocSyncAdapter; return this.yDocSyncAdapter;
} }
/**
* Returns the user id of the user of this connection.
*
* @returns the user id
*/
public getUserId(): number { public getUserId(): number {
return this.userId; return this.userId;
} }
/**
* Returns the display name of the user of this connection.
*
* @returns the display name
*/
public getDisplayName(): string { public getDisplayName(): string {
return this.displayName; return this.displayName;
} }
/**
* Returns the username of the user of this connection.
*
* @returns the username or null for guest users
*/
public getUsername(): string | null { public getUsername(): string | null {
return this.username; return this.username;
} }
/**
* Returns the author style of the user of this connection.
*
* @returns the author style
*/
public getAuthorStyle(): number { public getAuthorStyle(): number {
return this.authorStyle; return this.authorStyle;
} }
/**
* Returns the realtime note that this connection is connected to.
*
* @returns the realtime note
*/
public getRealtimeNote(): RealtimeNote { public getRealtimeNote(): RealtimeNote {
return this.realtimeNote; return this.realtimeNote;
} }

View file

@ -7,17 +7,21 @@ import { Injectable } from '@nestjs/common';
import { RealtimeNote } from './realtime-note'; import { RealtimeNote } from './realtime-note';
/**
* A store for {@link RealtimeNote} instances that are linked to a specific note.
* It allows creating, finding, and retrieving all realtime notes.
*/
@Injectable() @Injectable()
export class RealtimeNoteStore { export class RealtimeNoteStore {
private noteIdToRealtimeNote = new Map<number, RealtimeNote>(); private noteIdToRealtimeNote = new Map<number, RealtimeNote>();
/** /**
* Creates a new {@link RealtimeNote} for the given {@link Note} and memorizes it. * Creates a new {@link RealtimeNote} for the given note and memorizes it
* *
* @param noteId The note for which the realtime note should be created * @param noteId The id of the note for which the realtime note should be created
* @param initialTextContent the initial text content of realtime doc * @param initialTextContent the initial text content of realtime doc
* @param initialYjsState the initial yjs state. If provided this will be used instead of the text content * @param initialYjsState the initial yjs state. If provided this will be used instead of the text content
* @throws Error if there is already an realtime note for the given note. * @throws Error if there is already a realtime note for the given note.
* @returns The created realtime note * @returns The created realtime note
*/ */
public create( public create(
@ -41,16 +45,19 @@ export class RealtimeNoteStore {
} }
/** /**
* Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id. * Retrieves a {@link RealtimeNote} that is linked to the given note id
* @param noteId The id of the {@link Note} *
* @returns A {@link RealtimeNote} or {@code undefined} if no instance is existing. * @param noteId The id of the note
* @returns A {@link RealtimeNote} or undefined if no instance is existing
*/ */
public find(noteId: number): RealtimeNote | undefined { public find(noteId: number): RealtimeNote | undefined {
return this.noteIdToRealtimeNote.get(noteId); return this.noteIdToRealtimeNote.get(noteId);
} }
/** /**
* Returns all registered {@link RealtimeNote realtime notes}. * Returns all registered {@link RealtimeNote realtime notes}
*
* @returns An array of all realtime notes
*/ */
public getAllRealtimeNotes(): RealtimeNote[] { public getAllRealtimeNotes(): RealtimeNote[] {
return [...this.noteIdToRealtimeNote.values()]; return [...this.noteIdToRealtimeNote.values()];

View file

@ -31,6 +31,10 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
private permissionService: PermissionService, private permissionService: PermissionService,
) {} ) {}
/**
* Cleans up all {@link RealtimeNote} instances before the application is shut down
* This method is called by NestJS when the application is shutting down
*/
beforeApplicationShutdown(): void { beforeApplicationShutdown(): void {
this.realtimeNoteStore this.realtimeNoteStore
.getAllRealtimeNotes() .getAllRealtimeNotes()
@ -38,7 +42,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
} }
/** /**
* Reads the current content from the given {@link RealtimeNote} and creates a new {@link Revision} for the linked {@link Note}. * Reads the current content from the given {@link RealtimeNote} and creates a new revision for the linked note.
* *
* @param realtimeNote The realtime note for which a revision should be created * @param realtimeNote The realtime note for which a revision should be created
*/ */
@ -58,10 +62,11 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
} }
/** /**
* Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id. * Creates or reuses a {@link RealtimeNote} that is handling the real-time-editing of the note which is identified by the given note id
* @param noteId The {@link Note} for which a {@link RealtimeNote realtime note} should be retrieved. *
* @param noteId The id of the note for which a {@link RealtimeNote} should be retrieved
* @returns A RealtimeNote that is linked to the given note.
* @throws NotInDBError if note doesn't exist or has no revisions. * @throws NotInDBError if note doesn't exist or has no revisions.
* @returns A {@link RealtimeNote} that is linked to the given note.
*/ */
public async getOrCreateRealtimeNote(noteId: number): Promise<RealtimeNote> { public async getOrCreateRealtimeNote(noteId: number): Promise<RealtimeNote> {
return ( return (
@ -71,11 +76,12 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
} }
/** /**
* Creates a new {@link RealtimeNote} for the given {@link Note}. * Creates a new {@link RealtimeNote} for the given note and registers event listeners
* to persist the note periodically and before it is destroyed
* *
* @param noteId The note for which the realtime note should be created * @param noteId The id of the note for which the realtime note should be created
* @throws NotInDBError if note doesn't exist or has no revisions.
* @returns The created realtime note * @returns The created realtime note
* @throws NotInDBError if the note doesn't exist or has no revisions
*/ */
private async createNewRealtimeNote(noteId: number): Promise<RealtimeNote> { private async createNewRealtimeNote(noteId: number): Promise<RealtimeNote> {
const lastRevision = await this.revisionsService.getLatestRevision(noteId); const lastRevision = await this.revisionsService.getLatestRevision(noteId);
@ -117,16 +123,31 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
}); });
} }
/**
* Reflects the changes of the note's permissions to all connections of the note
*
* @param noteId The id of the note for that permissions changed
*/
@OnEvent(NoteEvent.PERMISSION_CHANGE) @OnEvent(NoteEvent.PERMISSION_CHANGE)
public async handleNotePermissionChanged(noteId: number): Promise<void> { public async handleNotePermissionChanged(noteId: number): Promise<void> {
const realtimeNote = this.realtimeNoteStore.find(noteId); const realtimeNote = this.realtimeNoteStore.find(noteId);
if (!realtimeNote) return; if (realtimeNote === undefined) {
return;
}
realtimeNote.announceMetadataUpdate(); realtimeNote.announceMetadataUpdate();
const allConnections = realtimeNote.getConnections(); const allConnections = realtimeNote.getConnections();
await this.updateOrCloseConnection(allConnections, noteId); await this.updateOrCloseConnection(allConnections, noteId);
} }
/**
* Updates the connections of the given note based on the current permissions of the user.
* If the user has no permission to edit the note, the connection is closed.
* Otherwise, it updates the acceptEdits property of the connection.
*
* @param connections The connections to update
* @param noteId The id of the note for which the connections should be updated
*/
private async updateOrCloseConnection( private async updateOrCloseConnection(
connections: RealtimeConnection[], connections: RealtimeConnection[],
noteId: number, noteId: number,
@ -145,6 +166,11 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
} }
} }
/**
* Reflects the deletion of a note to all connections of the note
*
* @param noteId The id of the just deleted note
*/
@OnEvent(NoteEvent.DELETION) @OnEvent(NoteEvent.DELETION)
public handleNoteDeleted(noteId: number): void { public handleNoteDeleted(noteId: number): void {
const realtimeNote = this.realtimeNoteStore.find(noteId); const realtimeNote = this.realtimeNoteStore.find(noteId);
@ -153,11 +179,16 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
} }
} }
/**
* Closes the realtime note for the given note id and saves its content
* This is called when the note is updated externally, e.g. by the API
*
* @param noteId The id of the note for which the realtime note should be closed
*/
@OnEvent(NoteEvent.CLOSE_REALTIME) @OnEvent(NoteEvent.CLOSE_REALTIME)
public closeRealtimeNote(noteId: number): void { public closeRealtimeNote(noteId: number): void {
const realtimeNote = this.realtimeNoteStore.find(noteId); const realtimeNote = this.realtimeNoteStore.find(noteId);
if (realtimeNote) { if (realtimeNote) {
this.saveRealtimeNote(realtimeNote);
realtimeNote.destroy(); realtimeNote.destroy();
} }
} }

View file

@ -31,6 +31,13 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
private isClosing = false; private isClosing = false;
private destroyEventTimer: NodeJS.Timeout | null = null; private destroyEventTimer: NodeJS.Timeout | null = null;
/**
* Creates a new realtime note for the given note id and initial text content
*
* @param noteId the id of the note that is being edited
* @param initialTextContent the initial text content of the note
* @param initialYjsState the initial yjs state of the note, if available
*/
constructor( constructor(
private readonly noteId: number, private readonly noteId: number,
initialTextContent: string, initialTextContent: string,
@ -58,9 +65,8 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
} }
/** /**
* Connects a new client to the note. * Connects a new client to the note
* * For this purpose a {@link RealtimeConnection} is created and added to the client map
* For this purpose a {@link RealtimeConnection} is created and added to the client map.
* *
* @param client the websocket connection to the client * @param client the websocket connection to the client
*/ */
@ -73,7 +79,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
/** /**
* Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note. * Disconnects the given websocket client while cleaning-up if it was the last user in the realtime note.
* *
* @param client The websocket client that disconnects. * @param client The websocket client that disconnects
*/ */
public removeClient(client: RealtimeConnection): void { public removeClient(client: RealtimeConnection): void {
this.clients.delete(client); this.clients.delete(client);
@ -94,7 +100,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
} }
/** /**
* Destroys the current realtime note by deleting the y-js doc and disconnecting all clients. * Destroys the current realtime note by deleting the yjs doc and disconnecting all clients
* *
* @throws Error if note has already been destroyed * @throws Error if note has already been destroyed
*/ */
@ -112,60 +118,60 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
} }
/** /**
* Checks if there's still clients connected to this note. * Checks if there are still clients connected to this note
* *
* @returns {@code true} if there a still clinets connected, otherwise {@code false} * @returns true if there are still clients connected, otherwise false
*/ */
public hasConnections(): boolean { public hasConnections(): boolean {
return this.clients.size !== 0; return this.clients.size !== 0;
} }
/** /**
* Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note. * Returns all {@link RealtimeConnection} currently hold by this note
* *
* @returns an array of {@link RealtimeConnection WebsocketConnections} * @returns an array of {@link RealtimeConnection}s
*/ */
public getConnections(): RealtimeConnection[] { public getConnections(): RealtimeConnection[] {
return [...this.clients]; return [...this.clients];
} }
/** /**
* Get the {@link RealtimeDoc realtime note} of the note. * Gets the {@link RealtimeDoc} for the note.
* *
* @returns the {@link RealtimeDoc realtime note} of the note * @returns the {@link RealtimeDoc} for the note
*/ */
public getRealtimeDoc(): RealtimeDoc { public getRealtimeDoc(): RealtimeDoc {
return this.doc; return this.doc;
} }
/** /**
* Get the {@link Note note} that is edited. * Gets the id of the note that is edited.
* *
* @returns the {@link Note note} * @returns the note id
*/ */
public getNoteId(): number { public getNoteId(): number {
return this.noteId; return this.noteId;
} }
/** /**
* Announce to all clients that the metadata of the note have been changed. * Announces to all clients that the metadata of the note has been changed,
* This could for example be a permission change or a revision being saved. * for example on a permission change or a revision being saved
*/ */
public announceMetadataUpdate(): void { public announceMetadataUpdate(): void {
this.sendToAllClients({ type: MessageType.METADATA_UPDATED }); this.sendToAllClients({ type: MessageType.METADATA_UPDATED });
} }
/** /**
* Announce to all clients that the note has been deleted. * Announces to all clients that the note has been deleted
*/ */
public announceNoteDeletion(): void { public announceNoteDeletion(): void {
this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED }); this.sendToAllClients({ type: MessageType.DOCUMENT_DELETED });
} }
/** /**
* Broadcasts the given content to all connected clients. * Broadcasts the given content to all connected clients
* *
* @param {Uint8Array} content The binary message to broadcast * @param content The binary message to broadcast
*/ */
private sendToAllClients(content: Message<MessageType>): void { private sendToAllClients(content: Message<MessageType>): void {
this.getConnections().forEach((connection) => { this.getConnections().forEach((connection) => {
@ -174,7 +180,9 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
} }
/** /**
* Indicates if a realtime note is ready to get destroyed. * Indicates if a realtime note is ready to get destroyed
*
* @returns true if the note can be destroyed, otherwise false
*/ */
private canBeDestroyed(): boolean { private canBeDestroyed(): boolean {
return !this.hasConnections() && !this.isClosing; return !this.hasConnections() && !this.isClosing;

View file

@ -19,6 +19,16 @@ export type OtherAdapterCollector = () => RealtimeUserStatusAdapter[];
export class RealtimeUserStatusAdapter { export class RealtimeUserStatusAdapter {
private readonly realtimeUser: RealtimeUser; private readonly realtimeUser: RealtimeUser;
/**
* Creates a new realtime user status adapter.
*
* @param username the username of the user, or null if the user is a guest
* @param displayName the display name of the user
* @param authorStyle the style index of the author
* @param collectOtherAdapters a function that returns all other adapters to send updates to
* @param messageTransporter the message transporter to use for sending messages
* @param acceptCursorUpdateProvider a function that returns whether cursor updates should be accepted
*/
constructor( constructor(
private readonly username: string | null, private readonly username: string | null,
private readonly displayName: string, private readonly displayName: string,
@ -31,6 +41,11 @@ export class RealtimeUserStatusAdapter {
this.bindRealtimeUserStateEvents(); this.bindRealtimeUserStateEvents();
} }
/**
* Returns the current realtime user state
*
* @returns the current realtime user state
*/
private createInitialRealtimeUserState(): RealtimeUser { private createInitialRealtimeUserState(): RealtimeUser {
return { return {
username: this.username, username: this.username,
@ -46,6 +61,9 @@ export class RealtimeUserStatusAdapter {
}; };
} }
/**
* Registers the listeners for the realtime user state events
*/
private bindRealtimeUserStateEvents(): void { private bindRealtimeUserStateEvents(): void {
const transporterMessagesListener = this.messageTransporter.on( const transporterMessagesListener = this.messageTransporter.on(
MessageType.REALTIME_USER_SINGLE_UPDATE, MessageType.REALTIME_USER_SINGLE_UPDATE,
@ -102,10 +120,19 @@ export class RealtimeUserStatusAdapter {
}); });
} }
/**
* Gets the current real-time user state if the message transporter is ready
*
* @returns the current real-time user state or undefined if the transporter is not ready
*/
private getSendableState(): RealtimeUser | undefined { private getSendableState(): RealtimeUser | undefined {
return this.messageTransporter.isReady() ? this.realtimeUser : undefined; return this.messageTransporter.isReady() ? this.realtimeUser : undefined;
} }
/**
* Sends the current real-time user state to all other clients
* This includes the own user state and the states of all other users
*/
public sendCompleteStateToClient(): void { public sendCompleteStateToClient(): void {
if (!this.messageTransporter.isReady()) { if (!this.messageTransporter.isReady()) {
return; return;
@ -126,29 +153,4 @@ export class RealtimeUserStatusAdapter {
}, },
}); });
} }
private findLeastUsedStyleIndex(map: Map<number, number>): number {
let leastUsedStyleIndex = 0;
let leastUsedStyleIndexCount = map.get(0) ?? 0;
for (let styleIndex = 0; styleIndex < 8; styleIndex++) {
const count = map.get(styleIndex) ?? 0;
if (count < leastUsedStyleIndexCount) {
leastUsedStyleIndexCount = count;
leastUsedStyleIndex = styleIndex;
}
}
return leastUsedStyleIndex;
}
private createStyleIndexToCountMap(): Map<number, number> {
return this.collectOtherAdapters()
.map((adapter) => adapter.realtimeUser.styleIndex)
.reduce((map, styleIndex) => {
if (styleIndex !== undefined) {
const count = (map.get(styleIndex) ?? 0) + 1;
map.set(styleIndex, count);
}
return map;
}, new Map<number, number>());
}
} }

View file

@ -42,7 +42,7 @@ export class MockConnectionBuilder {
public withGuestUser(displayName: string): this { public withGuestUser(displayName: string): this {
this.username = null; this.username = null;
this.displayName = displayName; this.displayName = displayName;
this.authorStyle = 8; this.authorStyle = 2;
this.userId = 1000; this.userId = 1000;
return this; return this;
} }
@ -50,7 +50,7 @@ export class MockConnectionBuilder {
/** /**
* Defines that the user who belongs to this connection is a logged-in user. * Defines that the user who belongs to this connection is a logged-in user.
* *
* @param username the username of the mocked user. If this value is omitted then the builder will user a {@link MOCK_FALLBACK_USERNAME fallback}. * @param username the username of the mocked user
*/ */
public withLoggedInUser(username: string): this { public withLoggedInUser(username: string): this {
this.username = username; this.username = username;
@ -79,7 +79,7 @@ export class MockConnectionBuilder {
/** /**
* Creates a new connection based on the given configuration. * Creates a new connection based on the given configuration.
* *
* @returns {RealtimeConnection} The constructed mocked connection * @returns The constructed mocked connection
* @throws Error if neither withGuestUser nor withLoggedInUser has been called. * @throws Error if neither withGuestUser nor withLoggedInUser has been called.
*/ */
public build(): RealtimeConnection { public build(): RealtimeConnection {

View file

@ -1,12 +1,12 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { MessageTransporter, MessageType } from '@hedgedoc/commons'; import { MessageTransporter, MessageType } from '@hedgedoc/commons';
/** /**
* A message transporter that is only used in testing where certain conditions like resending of requests isn't needed. * A message transporter that is only used in testing where certain conditions like resending of requests aren't needed.
*/ */
export class MockMessageTransporter extends MessageTransporter { export class MockMessageTransporter extends MessageTransporter {
protected startSendingOfReadyRequests(): void { protected startSendingOfReadyRequests(): void {

View file

@ -194,6 +194,13 @@ export class RevisionsService {
}); });
} }
/**
* Get a revision by its UUID
*
* @param revisionUuid The UUID of the revision to get
* @returns The revision DTO
* @throws NotInDBError if the revision with the given UUID does not exist
*/
async getRevisionDto(revisionUuid: string): Promise<RevisionDto> { async getRevisionDto(revisionUuid: string): Promise<RevisionDto> {
const revision = await this.knex(TableRevision) const revision = await this.knex(TableRevision)
.select( .select(
@ -225,9 +232,10 @@ export class RevisionsService {
} }
/** /**
* Get the latest * Gets the latest revision of a note
* @param noteId *
* @param transaction * @param noteId The id of the note for which the latest revision should be retrieved
* @param transaction The optional pre-existing database transaction to use
*/ */
async getLatestRevision( async getLatestRevision(
noteId: number, noteId: number,
@ -249,6 +257,13 @@ export class RevisionsService {
return revision; return revision;
} }
/**
* Gets the user information of the authors of a revision
*
* @param revisionUuid The UUID of the revision for which the user information should be retrieved
* @param transaction The optional pre-existing database transaction to use
* @returns An object containing the usernames and guest UUIDs of the authors and the count of guest users
*/
async getRevisionUserInfo( async getRevisionUserInfo(
revisionUuid: string, revisionUuid: string,
transaction?: Knex, transaction?: Knex,
@ -291,17 +306,15 @@ export class RevisionsService {
} }
/** /**
* Creates (but does not persist(!)) a new {@link Revision} for the given {@link Note}. * Creates a new revision for the given note
* Useful if the revision is saved together with the note in one action. * This method wraps the actual action in a database transaction
*
* *
* @param noteId The note for which the revision should be created * @param noteId The note for which the revision should be created
* @param newContent The new note content * @param newContent The new note content
* @param firstRevision Whether this is called for the first revision of a note * @param firstRevision Whether this is called for the first revision of a note
* @param transaction The optional pre-existing database transaction to use * @param transaction The optional pre-existing database transaction to use
* @param yjsStateVector The yjs state vector that describes the new content * @param yjsStateVector The yjs state vector that describes the new content
* @returns {Revision} the created revision * @returns the created revision or undefined if the revision couldn't be created
* @returns {undefined} if the revision couldn't be created because e.g. the content hasn't changed
*/ */
async createRevision( async createRevision(
noteId: number, noteId: number,
@ -331,6 +344,16 @@ export class RevisionsService {
); );
} }
/**
* Internal method to create a revision for the given note
* This method is used by the public createRevision method and should not be called directly
*
* @param noteId The note for which the revision should be created
* @param newContent The new note content
* @param firstRevision Whether this is called for the first revision of a note
* @param transaction The database transaction to use
* @param yjsStateVector The yjs state vector that describes the new content
*/
private async innerCreateRevision( private async innerCreateRevision(
noteId: number, noteId: number,
newContent: string, newContent: string,
@ -388,6 +411,13 @@ export class RevisionsService {
} }
} }
/**
* Get all tags of a revision
*
* @param revisionUuid The UUID of the revision for which the tags should be retrieved
* @param transaction The optional pre-existing database transaction to use
* @returns An array of tags associated with the revision
*/
async getTagsByRevisionUuid( async getTagsByRevisionUuid(
revisionUuid: string, revisionUuid: string,
transaction?: Knex, transaction?: Knex,
@ -412,7 +442,7 @@ export class RevisionsService {
} }
/** /**
* Delete old {@link Revision}s except the latest one. * Deletes old revisions except the latest one if the clean-up is enabled
*/ */
async removeOldRevisions(): Promise<void> { async removeOldRevisions(): Promise<void> {
const currentTime = new Date().getTime(); const currentTime = new Date().getTime();

View file

@ -29,9 +29,10 @@ interface FrontmatterParserResult {
} }
/** /**
* Parses the frontmatter of the given content and extracts the metadata that are necessary to create a new revision.. * Parses the frontmatter of the given content and extracts the metadata that are necessary to create a new revision
* *
* @param content the revision content that contains the frontmatter. * @param content the revision content that contains the frontmatter
* @returns the extracted metadata, including the title, description, tags, and note type
*/ */
export function extractRevisionMetadataFromContent( export function extractRevisionMetadataFromContent(
content: string, content: string,
@ -52,6 +53,13 @@ export function extractRevisionMetadataFromContent(
return { title, description, tags, noteType }; return { title, description, tags, noteType };
} }
/**
* Generates the content of a revision without the frontmatter.
*
* @param firstLineOfContentIndex the index of the first line of content after the frontmatter
* @param content the full content including frontmatter
* @returns the content without frontmatter
*/
function generateContentWithoutFrontmatter( function generateContentWithoutFrontmatter(
firstLineOfContentIndex: number | undefined, firstLineOfContentIndex: number | undefined,
content: string, content: string,
@ -61,6 +69,12 @@ function generateContentWithoutFrontmatter(
: content.split('\n').slice(firstLineOfContentIndex).join('\n'); : content.split('\n').slice(firstLineOfContentIndex).join('\n');
} }
/**
* Parses the frontmatter from the given content and returns the parsed frontmatter and the index of the first line of content.
*
* @param content the content to parse
* @returns an object containing the parsed frontmatter and the index of the first line of content, or undefined if no frontmatter was found
*/
function parseFrontmatter( function parseFrontmatter(
content: string, content: string,
): FrontmatterParserResult | undefined { ): FrontmatterParserResult | undefined {
@ -82,6 +96,12 @@ function parseFrontmatter(
}; };
} }
/**
* Extracts the first heading from the given markdown content
*
* @param content the content to extract the first heading from
* @returns the first heading or undefined if no heading was found
*/
function extractFirstHeadingFromContent(content: string): string | undefined { function extractFirstHeadingFromContent(content: string): string | undefined {
const markdownIt = new MarkdownIt('default'); const markdownIt = new MarkdownIt('default');
const html = markdownIt.render(content); const html = markdownIt.render(content);

View file

@ -60,7 +60,7 @@ export class SessionService {
} }
/** /**
* Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct. * Extracts the HedgeDoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct.
* *
* @param request The http request that contains a session cookie * @param request The http request that contains a session cookie
* @returns An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found * @returns An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */

View file

@ -6,7 +6,7 @@
import { adjectives, items } from './random-words'; import { adjectives, items } from './random-words';
/** /**
* Generates a random names based on an adjective and a noun. * Generates a random name based on an adjective and a noun
* *
* @returns the generated name * @returns the generated name
*/ */
@ -16,6 +16,12 @@ export function generateRandomName(): string {
return `${adjective} ${things}`; return `${adjective} ${things}`;
} }
/**
* Generates a random word from a given list and capitalizes the first letter
*
* @param list - The list of words to choose from
* @returns a randomly selected word with the first letter capitalized
*/
function generateRandomWord(list: string[]): string { function generateRandomWord(list: string[]): string {
const index = Math.floor(Math.random() * list.length); const index = Math.floor(Math.random() * list.length);
const word = list[index]; const word = list[index];

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */

View file

@ -23,7 +23,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GenericDBError, NotInDBError } from '../errors/errors'; import { GenericDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { generateRandomName } from '../realtime/realtime-note/random-word-lists/name-randomizer'; import { generateRandomName } from './random-word-lists/name-randomizer';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@ -41,13 +41,13 @@ export class UsersService {
* *
* @param username New user's username * @param username New user's username
* @param displayName New user's displayName * @param displayName New user's displayName
* @param [email] New user's email address if exists * @param email New user's email address if exists
* @param [photoUrl] URL of the user's profile picture if exists * @param photoUrl URL of the user's profile picture if exists
* @param transaction The optional transaction to access the db * @param transaction The optional transaction to access the db
* @returns The id of newly created user * @returns The id of newly created user
* @throws {BadRequestException} if the username contains invalid characters or is too short * @throws BadRequestException if the username contains invalid characters or is too short
* @throws {AlreadyInDBError} the username is already taken. * @throws AlreadyInDBError if the username is already taken
* @thorws {GenericDBError} the database returned a non-expected value * @thorws GenericDBError if the database returned a non-expected value
*/ */
async createUser( async createUser(
username: string, username: string,
@ -97,7 +97,7 @@ export class UsersService {
* Creates a new guest user with a random displayName * Creates a new guest user with a random displayName
* *
* @returns The guest uuid and the id of the newly created user * @returns The guest uuid and the id of the newly created user
* @throws {GenericDBError} the database returned a non-expected value * @throws GenericDBError if the database returned a non-expected value
*/ */
async createGuestUser(): Promise<[string, number]> { async createGuestUser(): Promise<[string, number]> {
const randomName = generateRandomName(); const randomName = generateRandomName();
@ -128,7 +128,7 @@ export class UsersService {
* Deletes a user by its id * Deletes a user by its id
* *
* @param userId id of the user to be deleted * @param userId id of the user to be deleted
* @throws {NotInDBError} the username has no user associated with it * @throws NotInDBError if the username has no user associated with it
*/ */
async deleteUser(userId: number): Promise<void> { async deleteUser(userId: number): Promise<void> {
const usersDeleted = await this.knex(TableUser) const usersDeleted = await this.knex(TableUser)
@ -229,7 +229,7 @@ export class UsersService {
* *
* @param username The username to fetch * @param username The username to fetch
* @returns The found user object * @returns The found user object
* @throws {NotInDBError} if the user could not be found * @throws NotInDBError if the user could not be found
*/ */
async getUserIdByUsername(username: string): Promise<number> { async getUserIdByUsername(username: string): Promise<number> {
const userId = await this.knex(TableUser) const userId = await this.knex(TableUser)
@ -247,11 +247,11 @@ export class UsersService {
} }
/** /**
* Fetches the userId for a given username from the database * Fetches the userId for a given guest uuid from the database
* *
* @param uuid The uuid to fetch * @param uuid The guest uuid to fetch
* @returns The found user object * @returns The found user object
* @throws {NotInDBError} if the user could not be found * @throws NotInDBError if the guest user could not be found
*/ */
async getUserIdByGuestUuid(uuid: string): Promise<User[FieldNameUser.id]> { async getUserIdByGuestUuid(uuid: string): Promise<User[FieldNameUser.id]> {
const userId = await this.knex(TableUser) const userId = await this.knex(TableUser)
@ -273,7 +273,7 @@ export class UsersService {
* *
* @param username The username to fetch * @param username The username to fetch
* @returns The found user object * @returns The found user object
* @throws {NotInDBError} if the user could not be found * @throws NotInDBError if the user could not be found
*/ */
async getUserDtoByUsername(username: string): Promise<UserInfoDto> { async getUserDtoByUsername(username: string): Promise<UserInfoDto> {
const user = await this.knex(TableUser) const user = await this.knex(TableUser)

View file

@ -1,9 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function hasArrayDuplicates<T>(array: Array<T>): boolean {
return new Set(array).size !== array.length;
}

View file

@ -1,10 +0,0 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* The base class for all DTOs.
*/
export abstract class BaseDto {}

View file

@ -37,11 +37,11 @@ export async function checkPassword(
} }
/** /**
* Transform a {@link Buffer} into a base64Url encoded string * Transforms a {@link Buffer} into a base64Url encoded string
* *
* This is necessary as the is no base64url encoding in the toString method * This is necessary as there is no base64url encoding in the toString method
* but as can be seen on https://tools.ietf.org/html/rfc4648#page-7 * but as can be seen on https://tools.ietf.org/html/rfc4648#page-7
* base64url is quite easy buildable from base64 * base64url is quite easily buildable from base64
* *
* @param text The buffer we want to decode * @param text The buffer we want to decode
* @returns The base64Url encoded string * @returns The base64Url encoded string
@ -73,7 +73,7 @@ export function hashApiToken(token: string): string {
* *
* @param userSecret The secret of the token the user gave us * @param userSecret The secret of the token the user gave us
* @param databaseSecretHash The secret hash we have saved in the database. * @param databaseSecretHash The secret hash we have saved in the database.
* @returns Wether or not the tokens are the equal * @returns Whether or not the tokens are equal
*/ */
export function checkTokenEquality( export function checkTokenEquality(
userSecret: string, userSecret: string,

View file

@ -11,10 +11,10 @@ import { join as joinPath } from 'path';
let versionCache: ServerVersionDto | undefined = undefined; let versionCache: ServerVersionDto | undefined = undefined;
/** /**
* Reads the HedgeDoc version from the root package.json. This is done only once per run. * Reads the HedgeDoc version from the root package.json. This is done only once per run and then cached for further calls
* *
* @returns {Promise<ServerVersionDto>} A Promise that contains the parsed server version. * @returns A Promise that contains the parsed server version.
* @throws {Error} if the package.json couldn't be found or doesn't contain a correct version. * @throws Error if the package.json couldn't be found or doesn't contain a correct version.
*/ */
export async function getServerVersionFromPackageJson(): Promise<ServerVersionDto> { export async function getServerVersionFromPackageJson(): Promise<ServerVersionDto> {
if (!versionCache) { if (!versionCache) {
@ -23,6 +23,12 @@ export async function getServerVersionFromPackageJson(): Promise<ServerVersionDt
return versionCache; return versionCache;
} }
/**
* Parses the version from the root package.json file.
*
* @returns A Promise that contains the parsed server version.
* @throws Error if the package.json couldn't be found or doesn't contain a correct version.
*/
async function parseVersionFromPackageJson(): Promise<ServerVersionDto> { async function parseVersionFromPackageJson(): Promise<ServerVersionDto> {
const rawFileContent: string = await fs.readFile( const rawFileContent: string = await fs.readFile(
joinPath(__dirname, '../../../package.json'), joinPath(__dirname, '../../../package.json'),
@ -47,6 +53,11 @@ async function parseVersionFromPackageJson(): Promise<ServerVersionDto> {
}; };
} }
/**
* Clears the cached version information
*
* This function is useful for testing purposes or when the version information needs to be reloaded
*/
export function clearCachedVersion(): void { export function clearCachedVersion(): void {
versionCache = undefined; versionCache = undefined;
} }

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -10,6 +10,11 @@ import { PrivateApiModule } from '../api/private/private-api.module';
import { PublicApiModule } from '../api/public/public-api.module'; import { PublicApiModule } from '../api/public/public-api.module';
import { getServerVersionFromPackageJson } from './server-version'; import { getServerVersionFromPackageJson } from './server-version';
/**
* Sets up the public API documentation for HedgeDoc.
*
* @param app The NestJS application instance to set up the Swagger module on.
*/
export async function setupPublicApiDocs(app: INestApplication): Promise<void> { export async function setupPublicApiDocs(app: INestApplication): Promise<void> {
const version = await getServerVersionFromPackageJson(); const version = await getServerVersionFromPackageJson();
const publicApiOptions = new DocumentBuilder() const publicApiOptions = new DocumentBuilder()
@ -26,6 +31,11 @@ export async function setupPublicApiDocs(app: INestApplication): Promise<void> {
SwaggerModule.setup('api/doc/v2', app, publicApi); SwaggerModule.setup('api/doc/v2', app, publicApi);
} }
/**
* Sets up the private API documentation for HedgeDoc.
*
* @param app The NestJS application instance to set up the Swagger module on.
*/
export async function setupPrivateApiDocs( export async function setupPrivateApiDocs(
app: INestApplication, app: INestApplication,
): Promise<void> { ): Promise<void> {