mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
feat(explore): add backend logic for the explore page
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
committed by
Philip Molares
parent
fa110dbdd3
commit
4e033863ff
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteType, OptionalSortMode, SortMode } from '@hedgedoc/commons';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SessionGuard } from '../../../auth/session.guard';
|
||||
import { NoteExploreEntryDto } from '../../../dtos/note-explore-entry.dto';
|
||||
import { ExploreService } from '../../../explore/explore.service';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { OpenApi } from '../../utils/decorators/openapi.decorator';
|
||||
import { RequestUserId } from '../../utils/decorators/request-user-id.decorator';
|
||||
|
||||
type OptionalNoteType = NoteType | '';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401, 403)
|
||||
@ApiTags('explore')
|
||||
@Controller('explore')
|
||||
export class ExploreController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private readonly exploreService: ExploreService,
|
||||
) {
|
||||
this.logger.setContext(ExploreController.name);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@OpenApi(200)
|
||||
getMyNotes(
|
||||
@RequestUserId() userId: number,
|
||||
@Query('page') page: number,
|
||||
@Query('sort') sort?: OptionalSortMode,
|
||||
@Query('search') search?: string,
|
||||
@Query('type') type?: OptionalNoteType,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
this.checkQueryParams(page, sort, type);
|
||||
return this.exploreService.getMyNoteExploreEntries(
|
||||
userId,
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('shared')
|
||||
@OpenApi(200)
|
||||
getSharedNotes(
|
||||
@RequestUserId() userId: number,
|
||||
@Query('page') page: number,
|
||||
@Query('sort') sort?: OptionalSortMode,
|
||||
@Query('search') search?: string,
|
||||
@Query('type') type?: OptionalNoteType,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
this.checkQueryParams(page, sort, type);
|
||||
return this.exploreService.getSharedWithMeExploreEntries(
|
||||
userId,
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('public')
|
||||
@OpenApi(200)
|
||||
getPublicNotes(
|
||||
@Query('page') page: number,
|
||||
@Query('sort') sort?: OptionalSortMode,
|
||||
@Query('search') search?: string,
|
||||
@Query('type') type?: OptionalNoteType,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
this.checkQueryParams(page, sort, type);
|
||||
return this.exploreService.getPublicNoteExploreEntries(
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('pinned')
|
||||
@OpenApi(200)
|
||||
getMyPinnedNotes(
|
||||
@RequestUserId() userId: number,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
return this.exploreService.getMyPinnedNoteExploreEntries(userId);
|
||||
}
|
||||
|
||||
private checkQueryParams(
|
||||
page: number,
|
||||
sort?: OptionalSortMode,
|
||||
type?: OptionalNoteType,
|
||||
): void {
|
||||
this.ensurePageNumberIsValid(page);
|
||||
this.ensureTypeQueryParamIsValid(type);
|
||||
this.ensureSortQueryParamIsValid(sort);
|
||||
}
|
||||
|
||||
private ensurePageNumberIsValid(page: number): void {
|
||||
if (page < 1) {
|
||||
throw new BadRequestException('Page number must be greater than 0');
|
||||
}
|
||||
}
|
||||
|
||||
private ensureTypeQueryParamIsValid(type?: OptionalNoteType): void {
|
||||
if (type === undefined || type === '') {
|
||||
return;
|
||||
}
|
||||
const validValues = Object.values(NoteType);
|
||||
if (!validValues.includes(type)) {
|
||||
throw new BadRequestException(`Invalid note type in search: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSortQueryParamIsValid(sort?: OptionalSortMode): void {
|
||||
if (sort === undefined || sort === '') {
|
||||
return;
|
||||
}
|
||||
const validValues = Object.values(SortMode);
|
||||
if (!validValues.includes(sort)) {
|
||||
throw new BadRequestException(`Invalid sort mode in search: ${sort}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Module } from '@nestjs/common';
|
||||
import { AliasModule } from '../../alias/alias.module';
|
||||
import { ApiTokenModule } from '../../api-token/api-token.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { ExploreModule } from '../../explore/explore.module';
|
||||
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
|
||||
import { GroupsModule } from '../../groups/groups.module';
|
||||
import { MediaModule } from '../../media/media.module';
|
||||
@@ -23,6 +24,7 @@ import { LdapController } from './auth/ldap/ldap.controller';
|
||||
import { LocalController } from './auth/local/local.controller';
|
||||
import { OidcController } from './auth/oidc/oidc.controller';
|
||||
import { ConfigController } from './config/config.controller';
|
||||
import { ExploreController } from './explore/explore.controller';
|
||||
import { GroupsController } from './groups/groups.controller';
|
||||
import { MeController } from './me/me.controller';
|
||||
import { MediaController } from './media/media.controller';
|
||||
@@ -36,6 +38,7 @@ import { UsersController } from './users/users.controller';
|
||||
FrontendConfigModule,
|
||||
PermissionsModule,
|
||||
AliasModule,
|
||||
ExploreModule,
|
||||
MediaModule,
|
||||
RevisionsModule,
|
||||
AuthModule,
|
||||
@@ -45,6 +48,7 @@ import { UsersController } from './users/users.controller';
|
||||
controllers: [
|
||||
ApiTokensController,
|
||||
ConfigController,
|
||||
ExploreController,
|
||||
GuestController,
|
||||
MediaController,
|
||||
MeController,
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Loglevel } from './config/loglevel.enum';
|
||||
import mediaConfig from './config/media.config';
|
||||
import noteConfig from './config/note.config';
|
||||
import { eventModuleConfig } from './events';
|
||||
import { ExploreModule } from './explore/explore.module';
|
||||
import { FrontendConfigModule } from './frontend-config/frontend-config.module';
|
||||
import { FrontendConfigService } from './frontend-config/frontend-config.service';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
@@ -125,6 +126,7 @@ const routes: Routes = [
|
||||
SessionModule,
|
||||
MediaRedirectModule,
|
||||
MessageModule,
|
||||
ExploreModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteExploreEntrySchema } from '@hedgedoc/commons';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class NoteExploreEntryDto extends createZodDto(NoteExploreEntrySchema) {}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { GroupsModule } from '../groups/groups.module';
|
||||
import { ExploreService } from './explore.service';
|
||||
|
||||
@Module({
|
||||
imports: [GroupsModule],
|
||||
providers: [ExploreService],
|
||||
exports: [ExploreService],
|
||||
})
|
||||
export class ExploreModule {}
|
||||
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { NoteType, OptionalSortMode, SortMode } from '@hedgedoc/commons';
|
||||
import {
|
||||
FieldNameAlias,
|
||||
FieldNameNote,
|
||||
FieldNameNoteGroupPermission,
|
||||
FieldNameNoteUserPermission,
|
||||
FieldNameRevision,
|
||||
FieldNameRevisionTag,
|
||||
FieldNameUser,
|
||||
FieldNameUserPinnedNote,
|
||||
SpecialGroup,
|
||||
TableAlias,
|
||||
TableNote,
|
||||
TableNoteGroupPermission,
|
||||
TableNoteUserPermission,
|
||||
TableRevision,
|
||||
TableRevisionTag,
|
||||
TableUser,
|
||||
TableUserPinnedNote,
|
||||
} from '@hedgedoc/database';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Knex } from 'knex';
|
||||
import { DateTime } from 'luxon';
|
||||
import { InjectConnection } from 'nest-knexjs';
|
||||
|
||||
import { NoteExploreEntryDto } from '../dtos/note-explore-entry.dto';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
|
||||
const ENTRIES_PER_PAGE_LIMIT = 20;
|
||||
|
||||
interface QueryResult {
|
||||
primaryAlias: string;
|
||||
title: string;
|
||||
noteType: NoteType;
|
||||
ownerUsername: string;
|
||||
lastChangedAt: string;
|
||||
revisionUuid: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface QueryResultWithTagList extends Omit<QueryResult, 'tag'> {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExploreService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectConnection()
|
||||
private readonly knex: Knex,
|
||||
private readonly groupsService: GroupsService,
|
||||
) {
|
||||
this.logger.setContext(ExploreService.name);
|
||||
}
|
||||
|
||||
async getPublicNoteExploreEntries(
|
||||
page: number,
|
||||
noteType?: NoteType | '',
|
||||
sortBy?: OptionalSortMode,
|
||||
search?: string,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
const everyoneGroupId = await this.groupsService.getGroupIdByName(
|
||||
SpecialGroup.EVERYONE,
|
||||
);
|
||||
const queryBase = this.knex(TableNoteGroupPermission).join(
|
||||
TableNote,
|
||||
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
);
|
||||
let query = this.applyCommonQuery(queryBase);
|
||||
query = query.andWhere(
|
||||
`${TableNoteGroupPermission}.${FieldNameNoteGroupPermission.groupId}`,
|
||||
everyoneGroupId,
|
||||
);
|
||||
query = this.applyFiltersToQuery(query, noteType, search);
|
||||
query = this.applySortingToQuery(query, sortBy);
|
||||
query = this.applyPaginationToQuery(query, page);
|
||||
const results = (await query) as QueryResult[];
|
||||
return this.transformQueryResultIntoDtos(results);
|
||||
}
|
||||
|
||||
async getMyNoteExploreEntries(
|
||||
userId: number,
|
||||
page: number,
|
||||
noteType?: NoteType | '',
|
||||
sortBy?: OptionalSortMode,
|
||||
search?: string,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
const queryBase = this.knex(TableNote);
|
||||
let query = this.applyCommonQuery(queryBase);
|
||||
query = query.andWhere(`${TableNote}.${FieldNameNote.ownerId}`, userId);
|
||||
query = this.applyFiltersToQuery(query, noteType, search);
|
||||
query = this.applySortingToQuery(query, sortBy);
|
||||
query = this.applyPaginationToQuery(query, page);
|
||||
const results = (await query) as QueryResult[];
|
||||
return this.transformQueryResultIntoDtos(results);
|
||||
}
|
||||
|
||||
async getSharedWithMeExploreEntries(
|
||||
userId: number,
|
||||
page: number,
|
||||
noteType?: NoteType | '',
|
||||
sortBy?: OptionalSortMode,
|
||||
search?: string,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
const queryBase = this.knex(TableNoteUserPermission).join(
|
||||
TableNote,
|
||||
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
);
|
||||
let query = this.applyCommonQuery(queryBase);
|
||||
query = query.andWhere(
|
||||
`${TableNoteUserPermission}.${FieldNameNoteUserPermission.userId}`,
|
||||
userId,
|
||||
);
|
||||
query = this.applyFiltersToQuery(query, noteType, search);
|
||||
query = this.applySortingToQuery(query, sortBy);
|
||||
query = this.applyPaginationToQuery(query, page);
|
||||
const results = (await query) as QueryResult[];
|
||||
return this.transformQueryResultIntoDtos(results);
|
||||
}
|
||||
|
||||
async getMyPinnedNoteExploreEntries(
|
||||
userId: number,
|
||||
): Promise<NoteExploreEntryDto[]> {
|
||||
const queryBase = this.knex(TableUserPinnedNote).join(
|
||||
TableNote,
|
||||
`${TableUserPinnedNote}.${FieldNameUserPinnedNote.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
);
|
||||
let query = this.applyCommonQuery(queryBase);
|
||||
query = query.andWhere(
|
||||
`${TableUserPinnedNote}.${FieldNameUserPinnedNote.userId}`,
|
||||
userId,
|
||||
);
|
||||
query = this.applySortingToQuery(query, SortMode.UPDATED_AT_DESC);
|
||||
const results = (await query) as QueryResult[];
|
||||
return this.transformQueryResultIntoDtos(results);
|
||||
}
|
||||
|
||||
private transformQueryResultIntoDtos(
|
||||
results: QueryResult[],
|
||||
): NoteExploreEntryDto[] {
|
||||
const resultsWithTagList = results.reduce((map, result) => {
|
||||
const existing = map.get(result.primaryAlias);
|
||||
if (!existing) {
|
||||
map.set(result.primaryAlias, {
|
||||
primaryAlias: result.primaryAlias,
|
||||
title: result.title,
|
||||
noteType: result.noteType,
|
||||
ownerUsername: result.ownerUsername,
|
||||
lastChangedAt: result.lastChangedAt,
|
||||
revisionUuid: result.revisionUuid,
|
||||
tags: result.tag ? [result.tag] : [],
|
||||
});
|
||||
} else if (result.tag) {
|
||||
existing.tags.push(result.tag);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, QueryResultWithTagList>());
|
||||
|
||||
return Array.from(resultsWithTagList.values()).map((result) =>
|
||||
NoteExploreEntryDto.create({
|
||||
primaryAlias: result.primaryAlias,
|
||||
title: result.title,
|
||||
type: result.noteType,
|
||||
tags: result.tags,
|
||||
owner: result.ownerUsername,
|
||||
lastChangedAt: DateTime.fromSQL(result.lastChangedAt, {
|
||||
zone: 'UTC',
|
||||
}).toISO(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The correct return type with all joins and selects is very specific and should just be inferred from Knex
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
private applyCommonQuery(query: Knex.QueryBuilder) {
|
||||
return query
|
||||
.select({
|
||||
primaryAlias: `${TableAlias}.${FieldNameAlias.alias}`,
|
||||
title: `${TableRevision}.${FieldNameRevision.title}`,
|
||||
noteType: `${TableRevision}.${FieldNameRevision.noteType}`,
|
||||
ownerUsername: `${TableUser}.${FieldNameUser.username}`,
|
||||
lastChangedAt: `${TableRevision}.${FieldNameRevision.createdAt}`,
|
||||
revisionUuid: `${TableRevision}.${FieldNameRevision.uuid}`,
|
||||
tag: `${TableRevisionTag}.${FieldNameRevisionTag.tag}`,
|
||||
})
|
||||
.join(
|
||||
TableAlias,
|
||||
`${TableAlias}.${FieldNameAlias.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
)
|
||||
.join(
|
||||
TableUser,
|
||||
`${TableUser}.${FieldNameUser.id}`,
|
||||
`${TableNote}.${FieldNameNote.ownerId}`,
|
||||
)
|
||||
.join(
|
||||
this.knex(TableRevision)
|
||||
.select(`${FieldNameRevision.uuid}`, `${FieldNameRevision.noteId}`)
|
||||
.max(`${FieldNameRevision.createdAt}`)
|
||||
.groupBy(`${FieldNameRevision.noteId}`)
|
||||
.as('latest_revision'),
|
||||
function () {
|
||||
this.on(
|
||||
`latest_revision.${FieldNameRevision.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
);
|
||||
},
|
||||
)
|
||||
.join(TableRevision, function () {
|
||||
this.on(
|
||||
`${TableRevision}.${FieldNameRevision.noteId}`,
|
||||
`${TableNote}.${FieldNameNote.id}`,
|
||||
).andOn(
|
||||
`${TableRevision}.${FieldNameRevision.uuid}`,
|
||||
`latest_revision.${FieldNameRevision.uuid}`,
|
||||
);
|
||||
})
|
||||
.leftJoin(
|
||||
TableRevisionTag,
|
||||
`${TableRevisionTag}.${FieldNameRevisionTag.revisionUuid}`,
|
||||
`${TableRevision}.${FieldNameRevision.uuid}`,
|
||||
)
|
||||
.where(`${TableAlias}.${FieldNameAlias.isPrimary}`, true);
|
||||
}
|
||||
|
||||
private applyFiltersToQuery<T extends Knex.QueryBuilder>(
|
||||
query: T,
|
||||
noteType?: NoteType | '',
|
||||
search?: string,
|
||||
): T {
|
||||
let filteredQuery = query;
|
||||
if (noteType) {
|
||||
filteredQuery = filteredQuery.andWhere(
|
||||
`${TableRevision}.${FieldNameRevision.noteType}`,
|
||||
noteType,
|
||||
) as T;
|
||||
}
|
||||
if (search) {
|
||||
const searchLowercase = search.toLowerCase();
|
||||
filteredQuery = filteredQuery.andWhereRaw(
|
||||
'(LOWER(??) LIKE ? OR LOWER(??) LIKE ?)',
|
||||
[
|
||||
`${TableRevision}.${FieldNameRevision.title}`,
|
||||
`%${searchLowercase}%`,
|
||||
`${TableRevisionTag}.${FieldNameRevisionTag.tag}`,
|
||||
`%${searchLowercase}%`,
|
||||
],
|
||||
) as T;
|
||||
}
|
||||
return filteredQuery;
|
||||
}
|
||||
|
||||
private applyPaginationToQuery<T extends Knex.QueryBuilder>(
|
||||
query: T,
|
||||
page: number,
|
||||
): T {
|
||||
return query
|
||||
.limit(ENTRIES_PER_PAGE_LIMIT)
|
||||
.offset((page - 1) * ENTRIES_PER_PAGE_LIMIT) as T;
|
||||
}
|
||||
|
||||
private applySortingToQuery<T extends Knex.QueryBuilder>(
|
||||
query: T,
|
||||
sortBy?: OptionalSortMode,
|
||||
): T {
|
||||
switch (sortBy) {
|
||||
case SortMode.TITLE_ASC:
|
||||
return query.orderBy(
|
||||
`${TableRevision}.${FieldNameRevision.title}`,
|
||||
'asc',
|
||||
) as T;
|
||||
case SortMode.TITLE_DESC:
|
||||
return query.orderBy(
|
||||
`${TableRevision}.${FieldNameRevision.title}`,
|
||||
'desc',
|
||||
) as T;
|
||||
case SortMode.UPDATED_AT_ASC:
|
||||
return query.orderBy(
|
||||
`${TableRevision}.${FieldNameRevision.createdAt}`,
|
||||
'asc',
|
||||
) as T;
|
||||
default:
|
||||
case SortMode.UPDATED_AT_DESC:
|
||||
return query.orderBy(
|
||||
`${TableRevision}.${FieldNameRevision.createdAt}`,
|
||||
'desc',
|
||||
) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './note.dto.js'
|
||||
export * from './note.media-deletion.dto.js'
|
||||
export * from './note-metadata.dto.js'
|
||||
export * from './note-metadata-update.dto.js'
|
||||
export * from './note-explore-entry.dto.js'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { NoteType } from '../../note-frontmatter/index.js'
|
||||
|
||||
export const NoteExploreEntrySchema = z
|
||||
.object({
|
||||
primaryAlias: z.string().describe('The primary alias of the note'),
|
||||
title: z.string().describe('The title of the note'),
|
||||
type: z
|
||||
.nativeEnum(NoteType)
|
||||
.describe('The type of the note (document or slide)'),
|
||||
tags: z.array(z.string()).describe('The tags of the note'),
|
||||
owner: z.string().nullable().describe('The owner of the note'),
|
||||
lastChangedAt: z
|
||||
.string()
|
||||
.datetime()
|
||||
.describe('The last time the note was changed'),
|
||||
})
|
||||
.describe('DTO for a note entry in the explore page')
|
||||
|
||||
export type NoteExploreEntryInterface = z.infer<typeof NoteExploreEntrySchema>
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
export * from './sort-mode.enum.js'
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
export enum SortMode {
|
||||
TITLE_ASC = 'title_asc',
|
||||
TITLE_DESC = 'title_desc',
|
||||
UPDATED_AT_ASC = 'updated_at_asc',
|
||||
UPDATED_AT_DESC = 'updated_at_desc',
|
||||
}
|
||||
|
||||
export type OptionalSortMode = SortMode | ''
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
export * from './dtos/index.js'
|
||||
export * from './explore-page/index.js'
|
||||
export * from './frontmatter-extractor/index.js'
|
||||
export * from './message-transporters/index.js'
|
||||
export * from './note-frontmatter/index.js'
|
||||
|
||||
Reference in New Issue
Block a user