206 lines
5.7 KiB
TypeScript
206 lines
5.7 KiB
TypeScript
import * as plugins from './smartfuzzy.plugins.js';
|
|
|
|
/**
|
|
* Type for the search result returned by ArticleSearch
|
|
*/
|
|
export type IArticleSearchResult = {
|
|
/** The matched article */
|
|
item: plugins.tsclass.content.IArticle;
|
|
|
|
/** The index of the article in the original array */
|
|
refIndex: number;
|
|
|
|
/** The match score (lower is better) */
|
|
score?: number;
|
|
|
|
/** Information about where matches were found in the article */
|
|
matches?: ReadonlyArray<{
|
|
indices: ReadonlyArray<readonly [number, number]>;
|
|
key?: string;
|
|
value?: string;
|
|
refIndex?: number;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Specialized search engine for articles with weighted field searching
|
|
*
|
|
* This class provides fuzzy searching against article content, with different weights
|
|
* assigned to different parts of the article (title, tags, content) to provide
|
|
* more relevant results.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const articles = [
|
|
* {
|
|
* title: 'Getting Started with TypeScript',
|
|
* content: 'TypeScript is a superset of JavaScript that adds static typing...',
|
|
* tags: ['typescript', 'javascript', 'programming'],
|
|
* author: 'John Doe',
|
|
* timestamp: Date.now(),
|
|
* featuredImageUrl: null,
|
|
* url: 'https://example.com/typescript-intro'
|
|
* }
|
|
* ];
|
|
*
|
|
* const articleSearch = new ArticleSearch(articles);
|
|
* const results = await articleSearch.search('typescript');
|
|
* ```
|
|
*/
|
|
export class ArticleSearch {
|
|
/**
|
|
* Collection of articles to search through
|
|
*/
|
|
public articles: plugins.tsclass.content.IArticle[] = [];
|
|
|
|
/**
|
|
* Flag indicating whether the search index needs to be updated
|
|
*/
|
|
public needsUpdate: boolean = false;
|
|
|
|
/**
|
|
* Promise manager for async operations
|
|
*/
|
|
private readyDeferred = plugins.smartpromise.defer();
|
|
|
|
/**
|
|
* Fuse.js instance for searching
|
|
*/
|
|
private fuse: plugins.fuseJs<plugins.tsclass.content.IArticle>;
|
|
|
|
/**
|
|
* Creates a new ArticleSearch instance
|
|
*
|
|
* @param articleArrayArg - Optional array of articles to initialize with
|
|
*/
|
|
constructor(articleArrayArg?: plugins.tsclass.content.IArticle[]) {
|
|
// Validate input if provided
|
|
if (articleArrayArg !== undefined && !Array.isArray(articleArrayArg)) {
|
|
throw new Error('Article array must be an array');
|
|
}
|
|
|
|
this.fuse = new plugins.fuseJs(this.articles);
|
|
this.readyDeferred.resolve();
|
|
|
|
if (articleArrayArg) {
|
|
for (const article of articleArrayArg) {
|
|
// Validate each article has required fields
|
|
if (!article || typeof article !== 'object') {
|
|
throw new Error('Each article must be a valid object');
|
|
}
|
|
|
|
// Require at least title field
|
|
if (!article.title || typeof article.title !== 'string') {
|
|
throw new Error('Each article must have a title string');
|
|
}
|
|
|
|
this.addArticle(article);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds an article to the collection and marks the index for updating
|
|
*
|
|
* @param articleArg - The article to add to the search collection
|
|
* @returns void
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* articleSearch.addArticle({
|
|
* title: 'Advanced TypeScript Features',
|
|
* content: 'This article covers advanced TypeScript concepts...',
|
|
* tags: ['typescript', 'advanced'],
|
|
* author: 'Jane Smith',
|
|
* timestamp: Date.now(),
|
|
* featuredImageUrl: null,
|
|
* url: 'https://example.com/advanced-typescript'
|
|
* });
|
|
* ```
|
|
*/
|
|
public addArticle(articleArg: plugins.tsclass.content.IArticle): void {
|
|
if (!articleArg || typeof articleArg !== 'object') {
|
|
throw new Error('Article must be a valid object');
|
|
}
|
|
|
|
// Require at least title field
|
|
if (!articleArg.title || typeof articleArg.title !== 'string') {
|
|
throw new Error('Article must have a title string');
|
|
}
|
|
|
|
// Validate tags if present
|
|
if (articleArg.tags !== undefined && !Array.isArray(articleArg.tags)) {
|
|
throw new Error('Article tags must be an array of strings');
|
|
}
|
|
|
|
this.articles.push(articleArg);
|
|
this.needsUpdate = true;
|
|
}
|
|
|
|
/**
|
|
* Performs a weighted fuzzy search across all articles
|
|
*
|
|
* The search uses the following weighting:
|
|
* - Title: 3x importance
|
|
* - Tags: 2x importance
|
|
* - Content: 1x importance
|
|
*
|
|
* @param searchStringArg - The search query string
|
|
* @returns Array of articles matched with their relevance score and match details
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* // Search for articles about TypeScript
|
|
* const results = await articleSearch.search('typescript');
|
|
*
|
|
* // Access the first (most relevant) result
|
|
* if (results.length > 0) {
|
|
* console.log(results[0].item.title);
|
|
*
|
|
* // See where the match was found
|
|
* console.log(results[0].matches);
|
|
* }
|
|
* ```
|
|
*/
|
|
public async search(searchStringArg: string): Promise<IArticleSearchResult[]> {
|
|
if (typeof searchStringArg !== 'string') {
|
|
throw new Error('Search string must be a string');
|
|
}
|
|
|
|
// Empty article collection should return empty results
|
|
if (this.articles.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (this.needsUpdate) {
|
|
const oldDeferred = this.readyDeferred;
|
|
this.readyDeferred = plugins.smartpromise.defer();
|
|
this.needsUpdate = false;
|
|
if (oldDeferred.status !== 'fulfilled') {
|
|
this.readyDeferred.promise.then(oldDeferred.resolve);
|
|
}
|
|
this.fuse = new plugins.fuseJs(this.articles, {
|
|
keys: [
|
|
{
|
|
name: 'title',
|
|
weight: 3,
|
|
},
|
|
{
|
|
name: 'tags',
|
|
weight: 2,
|
|
},
|
|
{
|
|
name: 'content',
|
|
weight: 1,
|
|
},
|
|
],
|
|
includeMatches: true,
|
|
});
|
|
this.readyDeferred.resolve();
|
|
} else {
|
|
await this.readyDeferred.promise;
|
|
}
|
|
return this.fuse.search(searchStringArg);
|
|
}
|
|
}
|