diff --git a/.vscode/launch.json b/.vscode/launch.json index 71d3b9ae5..ba8f3f84f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,7 @@ "type": "node", "request": "launch", "name": "Launch with Debugger", - "program": "${workspaceFolder}/dist/packages/server/main.js", + "program": "${workspaceFolder}/dist/apps/api/main.js", "smartStep": true, } ] diff --git a/libs/api/database/src/customTypes/mongoose-fuzzy-searching/index.d.ts b/libs/api/database/src/customTypes/mongoose-fuzzy-searching/index.d.ts new file mode 100644 index 000000000..4547c8e57 --- /dev/null +++ b/libs/api/database/src/customTypes/mongoose-fuzzy-searching/index.d.ts @@ -0,0 +1,68 @@ +declare module 'mongoose-fuzzy-searching' { + import { Document, DocumentQuery, HookAsyncCallback, HookSyncCallback, Model, Schema } from 'mongoose' + + export type FuzzyFieldStringOptions = (keyof T & string)[]; + + export interface FuzzyFieldOptions { + /** + * Collection key name. If unspecified, defualts to **null**. + */ + name?: string | null; + + /** + * N-grams min size. If unspecified, defaults to **2**. + */ + minSize?: number; + + /** + * Denotes the significance of the field relative to the other indexed fields in terms of the text search score. + * If unspecified, defaults to **1**. + */ + weight?: number; + + /** + * Only return ngrams from start of word. (It gives more precise results). + * If unspecified, defaults to **false**. + */ + prefixOnly?: boolean; + + /** + * Remove special characters from N-grams. + * If unspecified, defaults to **true**. + */ + escapeSpecialCharacters?: boolean; + + /** + * Defines which attributes on this object to be used for fuzzy searching. + * If unspecified, defaults to **null**. + */ + keys: FuzzyFieldStringOptions; + } + + export interface MongooseFuzzyOptions { + /** + * Defines the fields to fuzzy search. Can either be an array of strings + * (in which case defaults will be used), or an array of objects, + * that define the options for each field. + */ + fields: FuzzyFieldStringOptions | FuzzyFieldOptions; + middlewares?: { + preSave?: HookSyncCallback | HookAsyncCallback; + preInsertMany?: HookSyncCallback | HookAsyncCallback; + preUpdate?: HookSyncCallback | HookAsyncCallback; + preUpdateOne?: HookSyncCallback | HookAsyncCallback; + preFindOneAndUpdate?: HookSyncCallback | HookAsyncCallback; + preUpdateMany?: HookSyncCallback | HookAsyncCallback; + } + } + + export interface MongooseFuzzyModel> + extends Model { + fuzzySearch( + search: string, + callBack?: (err: any, data: Model[]) => void + ): DocumentQuery + } + + export default function registerFuzzySearch(schema: Schema, options: MongooseFuzzyOptions): void +} diff --git a/libs/api/database/src/lib/content/schemas/content.schema.ts b/libs/api/database/src/lib/content/schemas/content.schema.ts index fcde8e059..ff611d563 100644 --- a/libs/api/database/src/lib/content/schemas/content.schema.ts +++ b/libs/api/database/src/lib/content/schemas/content.schema.ts @@ -17,6 +17,7 @@ export class ContentDocument extends Document implements ContentModel { }) readonly author: string | Pseudonym; + // Indexes by title as text in index file @Prop({ trim: true, required: true }) title: string; @@ -73,7 +74,7 @@ export class ContentDocument extends Document implements ContentModel { readonly kind: ContentKind; @Prop({type: [{ - type: String, + type: String, ref: 'Tags', autopopulate: { select: '_id name desc parent kind createdAt updatedAt', diff --git a/libs/api/database/src/lib/content/schemas/index.ts b/libs/api/database/src/lib/content/schemas/index.ts index f39c7a72a..5c0ee9541 100644 --- a/libs/api/database/src/lib/content/schemas/index.ts +++ b/libs/api/database/src/lib/content/schemas/index.ts @@ -5,10 +5,12 @@ import { ContentDocument, ContentSchema } from './content.schema'; import { RatingsSchema } from './ratings.schema'; import { ReadingHistorySchema } from './reading-history.schema'; import { SectionsDocument, SectionsSchema } from './sections.schema'; -import { TagsDocument, TagsSchema } from './tags.schema'; +import { TagsSchema } from './tags.schema'; import * as MongooseAutopopulate from 'mongoose-autopopulate'; import * as MongoosePaginate from 'mongoose-paginate-v2'; import { countWords, stripTags } from 'voca'; +import { MongooseFuzzyOptions } from 'mongoose-fuzzy-searching'; +import registerFuzzySearch from 'mongoose-fuzzy-searching'; //#region ---EXPORTS--- @@ -35,19 +37,24 @@ export async function setupContentCollection() { // making a text index on the title field for search schema.index({ title: 'text' }); - schema.pre('save', async function (next: HookNextFunction) { - this.set('title', sanitizeHtml(this.title, sanitizeOptions)); - this.set('body', sanitizeHtml(this.body, sanitizeOptions)); - - // this will only trigger if any creation or editing functions has modified the `desc` field, - // otherwise we'll leave it alone - if (this.isModified('desc')) { - this.set('desc', sanitizeHtml(this.desc, sanitizeOptions)); + schema.plugin>(registerFuzzySearch,{ + fields: ['title'], + // Middlewares must be defined inside the fuzzy-search plugin, otherwise it will override them. + middlewares: { + preSave: async function (next: HookNextFunction) { + this.set('title', sanitizeHtml(this.title, sanitizeOptions)); + this.set('body', sanitizeHtml(this.body, sanitizeOptions)); + + // this will only trigger if any creation or editing functions has modified the `desc` field, + // otherwise we'll leave it alone + if (this.isModified('desc')) { + this.set('desc', sanitizeHtml(this.desc, sanitizeOptions)); + } + + return next(); + } } - - return next(); }); - schema.plugin(MongooseAutopopulate); schema.plugin(MongoosePaginate); diff --git a/libs/api/database/src/lib/content/stores/content-group.store.ts b/libs/api/database/src/lib/content/stores/content-group.store.ts index 5068043db..bd2610b9e 100644 --- a/libs/api/database/src/lib/content/stores/content-group.store.ts +++ b/libs/api/database/src/lib/content/stores/content-group.store.ts @@ -7,6 +7,7 @@ import { RatingOption } from '@dragonfish/shared/models/reading-history'; import { JwtPayload } from '@dragonfish/shared/models/auth'; import { ContentFilter, ContentKind, ContentRating, PubStatus } from '@dragonfish/shared/models/content'; import { Pseudonym } from '@dragonfish/shared/models/accounts'; +import { MongooseFuzzyModel } from 'mongoose-fuzzy-searching'; /** * ## Content Group Store @@ -18,6 +19,7 @@ export class ContentGroupStore { readonly NEWEST_FIRST = -1 constructor( @InjectModel('Content') private readonly content: PaginateModel, + @InjectModel('Content') private readonly fuzzySearchableContent: MongooseFuzzyModel, @InjectModel('Sections') private readonly sections: Model, @InjectModel('Ratings') private readonly ratings: Model, @InjectModel('ReadingHistory') private readonly history: Model, @@ -201,7 +203,8 @@ export class ContentGroupStore { kind: { $in: kinds }, }; await ContentGroupStore.determineContentFilter(paginateQuery, filter); - return await this.content.paginate(paginateQuery, paginateOptions); + const fuzzySearchedContent = await this.fuzzySearchableContent.fuzzySearch(query); + return await this.content.paginate(paginateQuery, paginateOptions); } /** @@ -211,7 +214,7 @@ export class ContentGroupStore { * @param pageNum The page of results to retrieve. * @param maxPerPage The maximum number of results per page. * @param filter The content filter to apply to returned results. - * @returns + * @returns */ public async getContentByFandomTag( tagId: string, diff --git a/libs/api/database/src/lib/content/stores/content.store.ts b/libs/api/database/src/lib/content/stores/content.store.ts index 271561ab7..79a30b25f 100644 --- a/libs/api/database/src/lib/content/stores/content.store.ts +++ b/libs/api/database/src/lib/content/stores/content.store.ts @@ -25,6 +25,7 @@ import { NotificationsService, UnsubscribeResult } from '../../notifications'; import { ApprovalQueueStore } from '../../approval-queue'; import { PublishSection, SectionForm } from '@dragonfish/shared/models/sections'; import { SectionsStore } from './sections.store'; +import { MongooseFuzzyModel } from 'mongoose-fuzzy-searching'; /** * ## Content Store @@ -36,6 +37,7 @@ export class ContentStore { constructor( @InjectModel('Content') private readonly content: PaginateModel, @InjectModel('Sections') private readonly sections: Model, + @InjectModel('Content') private readonly fuzzySearchableContent: MongooseFuzzyModel, private readonly users: UsersStore, private readonly blogContent: BlogsStore, private readonly newsContent: NewsStore, diff --git a/libs/api/database/tsconfig.lib.json b/libs/api/database/tsconfig.lib.json index f709813e3..c7f86d924 100644 --- a/libs/api/database/tsconfig.lib.json +++ b/libs/api/database/tsconfig.lib.json @@ -5,7 +5,8 @@ "outDir": "../../../dist/out-tsc", "declaration": true, "types": ["node"], - "target": "es6" + "target": "es6", + "typeRoots": ["../../../node_modules/@types", "src/customTypes"] }, "exclude": ["**/*.spec.ts"], "include": ["**/*.ts"] diff --git a/package.json b/package.json index c22b725f7..ff024c5dd 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "moment": "^2.29.1", "mongoose": "5.10.0", "mongoose-autopopulate": "0.12.2", + "mongoose-fuzzy-searching": "^2.0.2", "mongoose-paginate-v2": "1.3.9", "mongoose-sequence": "^5.3.1", "mongoose-unique-validator": "^2.0.3", diff --git a/yarn.lock b/yarn.lock index 0e694ff7d..a5a7ffb33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12984,6 +12984,11 @@ mongoose-autopopulate@0.12.2: resolved "https://registry.yarnpkg.com/mongoose-autopopulate/-/mongoose-autopopulate-0.12.2.tgz#5209c7f9934b2642d2eac1afc84d289316138a84" integrity sha512-ZpjylV6ty0iqNjSWPgQWDMaBExnXrvCNhci9EnX49O0fOIVES1Qc7r1d+nxYBKvQutYW5UWs+fauahOTTlpjww== +mongoose-fuzzy-searching@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mongoose-fuzzy-searching/-/mongoose-fuzzy-searching-2.0.2.tgz#16a25b198591ab41ac00f3a9d15d3328aa7a126c" + integrity sha512-Hbmx59VWJjQJDNdoyQ8bdECaqpis0pKoliWzf5eILzFs2KO7laUJpaQWJySN9OvdQS/gM9fURMSzQVQoLeZ3DA== + mongoose-legacy-pluralize@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz#3ba9f91fa507b5186d399fb40854bff18fb563e4"