Compare commits

..

No commits in common. "master" and "v1.1.7" have entirely different histories.

13 changed files with 143 additions and 798 deletions

1
.gitignore vendored
View File

@ -17,4 +17,3 @@ dist/
dist_*/ dist_*/
#------# custom #------# custom
**/.claude/settings.local.json

View File

@ -1,28 +1,5 @@
# Changelog # Changelog
## 2025-05-13 - 1.1.10 - fix(documentation)
Update documentation and migration guide with standardized method names and deprecation notices.
- Replaced deprecated getClosestMatchForString with findClosestMatch in code examples.
- Replaced deprecated getChangeScoreForString with calculateScores in documentation.
- Updated readme plan to mark method naming standardization as completed.
## 2025-05-12 - 1.1.9 - fix(core)
Update build scripts, refine testing assertions, and enhance documentation
- Updated .gitignore to exclude local settings files
- Modified build script in package.json to use 'tsbuild tsfolders --allowimplicitany'
- Revised readme.plan.md with comprehensive Fuse.js optimization and API improvement strategies
- Enhanced input validation, error handling, and JSDoc comments across core classes
- Standardized test syntax and improved test coverage for fuzzy matching features
## 2025-05-12 - 1.1.8 - fix(tests)
Standardize test syntax and update testing dependencies
- Added @git.zone/tsrun dependency to package.json for improved test runner support
- Refactored test export in test/test.articlesearch.ts to use default export instead of tap.start()
- Updated readme.plan.md to describe testing improvements and syntax standardization
## 2025-05-12 - 1.1.7 - fix(build) ## 2025-05-12 - 1.1.7 - fix(build)
Fix import paths, update CI workflows and upgrade dependencies for ESM compliance Fix import paths, update CI workflows and upgrade dependencies for ESM compliance

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartfuzzy", "name": "@push.rocks/smartfuzzy",
"version": "1.1.10", "version": "1.1.7",
"private": false, "private": false,
"description": "A library for fuzzy matching strings against word dictionaries or arrays, with support for object and article searching.", "description": "A library for fuzzy matching strings against word dictionaries or arrays, with support for object and article searching.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@ -10,12 +10,11 @@
"scripts": { "scripts": {
"test": "(tstest test/)", "test": "(tstest test/)",
"format": "(gitzone format)", "format": "(gitzone format)",
"build": "(tsbuild tsfolders --allowimplicitany)", "build": "(tsbuild)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.1.27", "@git.zone/tsbuild": "^2.1.27",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^1.0.57", "@git.zone/tstest": "^1.0.57",
"@push.rocks/tapbundle": "^6.0.3", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.15.17" "@types/node": "^22.15.17"

3
pnpm-lock.yaml generated
View File

@ -24,9 +24,6 @@ importers:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^2.1.27 specifier: ^2.1.27
version: 2.3.2 version: 2.3.2
'@git.zone/tsrun':
specifier: ^1.3.3
version: 1.3.3
'@git.zone/tstest': '@git.zone/tstest':
specifier: ^1.0.57 specifier: ^1.0.57
version: 1.0.96(@aws-sdk/credential-providers@3.806.0)(socks@2.8.4)(typescript@5.8.3) version: 1.0.96(@aws-sdk/credential-providers@3.806.0)(socks@2.8.4)(typescript@5.8.3)

View File

@ -34,20 +34,12 @@ const mySmartFuzzy = new Smartfuzzy(myDictionary);
mySmartFuzzy.addToDictionary('Microsoft'); mySmartFuzzy.addToDictionary('Microsoft');
mySmartFuzzy.addToDictionary(['Google', 'Facebook']); mySmartFuzzy.addToDictionary(['Google', 'Facebook']);
// Finding the closest match // Getting the closest match
const searchResult = mySmartFuzzy.findClosestMatch('Appl'); const searchResult = mySmartFuzzy.getClosestMatchForString('Appl');
console.log(searchResult); // Output: "Apple Inc." console.log(searchResult); // Output: "Apple Inc."
// Calculate similarity scores for all dictionary entries
const scores = mySmartFuzzy.calculateScores('Appl');
console.log(scores);
// Output: { 'Sony': 4, 'Deutsche Bahn': 11, 'Apple Inc.': 5, ... }
// Lower scores indicate better matches
``` ```
This example demonstrates how to instantiate the `Smartfuzzy` class with a list of strings (dictionary) and add more entries to it. You can then use it to find the closest match or calculate similarity scores for a given search string. This example demonstrates how to instantiate the `Smartfuzzy` class with a list of strings (dictionary) and add more entries to it. You can then use it to get the closest match for a given search string.
> **Note:** The older method names `getClosestMatchForString` and `getChangeScoreForString` are still available for backward compatibility but are deprecated. It's recommended to use the new method names `findClosestMatch` and `calculateScores` instead.
### Advanced Object Sorting ### Advanced Object Sorting

View File

@ -1,172 +1,85 @@
# SmartFuzzy Improvement Plan - Fuse.js Optimization Focus # SmartFuzzy Improvement Plan
## Current Status ## Current Status
- ESM imports/exports fixed with .js extensions - ESM imports/exports fixed with .js extensions
- Basic fuzzy matching functionality works - Basic fuzzy matching functionality works
- Testing infrastructure fixed with @git.zone/tsrun dependency - Tests run individually with tsx but fail with pnpm test
- Test syntax standardized using SmartExpect syntax - Using older versions of dependencies
- Tests improved with proper assertions and error handling
- Input validation added to all public methods
- Code documented with comprehensive TypeScript JSDoc comments
- Method names standardized for better API consistency
- Backward compatibility maintained through deprecated method aliases
## Improvement Plan - Fuse.js Optimization Focus ## Improvement Plan
### 1. Fully Leverage Fuse.js Capabilities ### 1. Fix Testing Infrastructure
#### 1.1 Enhance Configurability #### 1.1 Fix Test Runner Configuration
- [ ] Create a comprehensive `FuzzyOptions` interface exposing Fuse.js options - [ ] Investigate why `pnpm test` fails while individual tests run with `tsx` succeed
- **Implementation approach**: - [ ] Check if `tsrun` command is properly installed and available (current error shows "tsrun: command not found")
- Expose all relevant Fuse.js options (threshold, distance, location, etc.) - [ ] Examine the `tstest` configuration in package.json and update if needed
- Group options logically (matching control, performance control, output control) - [ ] Verify that `@git.zone/tstest` dependency is properly installed and configured
- Add proper TypeScript types and documentation for each option - [ ] Consider updating the test script to use `tsx` directly if `tstest` continues to be problematic
- Create sensible defaults for different use cases (loose matching, exact matching, etc.)
- Add option validation with clear error messages
- Implement runtime option updates via setOptions() method
#### 1.2 Improve Weighted Field Support #### 1.2 Update Test Syntax and Standards
- [ ] Enhance ObjectSorter to support field weights like ArticleSearch - [ ] Convert all tests from chai-style syntax (`expect().to.be`) to SmartExpect syntax (`expect().toBeInstanceOf()`)
- **Implementation approach**: - [ ] Implement consistent test structure across all test files
- Add ability to specify weight per field in ObjectSorter - [ ] Add proper setup and teardown patterns where needed
- Maintain backward compatibility with current simple array of fields - [ ] Replace console.log statements with proper assertions to validate results
- Create examples of different weighting strategies - [ ] Add descriptive error messages to assertions to improve test debugging
- Add tests demonstrating the effect of different field weights
- Include weight settings in all relevant documentation
#### 1.3 Add Extended Search Capabilities #### 1.3 Expand Test Coverage
- [ ] Implement Fuse.js extended search syntax support - [ ] Add tests for empty dictionaries and edge cases
- **Implementation approach**: - [ ] Test with extremely large dictionaries to verify performance
- Add support for Fuse.js extended search syntax (AND, OR, exact matching) - [ ] Add tests for unicode/special character handling
- Create helper methods to build complex search queries - [ ] Test with very similar strings to validate fuzzy matching accuracy
- Add examples of extended search usage in documentation - [ ] Add tests for error conditions and input validation
- Create tests for complex search patterns - [ ] Implement tests for all public APIs and features
- Implement query validation for extended search syntax
### 2. Performance Optimization #### 1.4 Test Automation and CI
- [ ] Add test coverage reporting
- [ ] Set up continuous integration for automated testing
- [ ] Add performance regression tests
- [ ] Create test fixtures for consistent test data
- [ ] Add browser-based tests for web compatibility
#### 2.1 Optimize Index Creation ### 2. Code Quality Improvements
- [ ] Implement proper Fuse.js index management - [ ] Add proper TypeScript documentation comments to all public methods
- **Implementation approach**: - [ ] Implement consistent error handling
- Create persistent indices instead of rebuilding for each search - [ ] Add input validation for all public methods
- Add incremental index updates when items are added/removed - [ ] Standardize method naming conventions (e.g., get* vs find*)
- Implement proper index serialization and deserialization
- Add option to lazily rebuild indices
- Create tests measuring index creation performance
#### 2.2 Implement Basic Caching ### 3. Feature Enhancements
- [ ] Add results caching for repeated queries - [ ] Add configurable threshold options for matching
- **Implementation approach**: - [ ] Implement stemming/lemmatization support for better text matching
- Implement simple Map-based cache for query results - [ ] Add language-specific matching options
- Add cache invalidation on dictionary/object changes - [ ] Support for weighted matching across multiple fields
- Create configurable cache size limits - [ ] Add batch processing capabilities for large datasets
- Add cache hit/miss tracking for debugging
- Implement optional cache persistence
#### 2.3 Add Async Processing for Large Datasets ### 4. Performance Optimizations
- [ ] Implement non-blocking search operations for large datasets - [ ] Implement caching for repeated searches
- **Implementation approach**: - [ ] Optimize indexing for large dictionaries
- Create async versions of search methods that don't block main thread - [ ] Add benchmarking tests to measure performance improvements
- Implement chunked processing for large dictionaries
- Add progress tracking for long operations
- Create cancellable search operations
- Add proper promise handling and error propagation
- Measure performance difference between sync and async methods
### 3. API Improvements ### 5. Dependencies and Build System
- [ ] Update to latest versions of dependencies
- [ ] Ensure proper tree-shaking for browser bundle
- [ ] Add browser-specific build configuration
- [ ] Implement proper ES module / CommonJS dual package setup
#### 3.1 Standardize Method Naming ### 6. Documentation
- [x] Standardize all method names for consistency - [ ] Create comprehensive API documentation
- **Implementation completed**: - [ ] Add usage examples for common scenarios
- Renamed `getClosestMatchForString` to `findClosestMatch` - [ ] Create benchmarks comparing to other fuzzy matching libraries
- Renamed `getChangeScoreForString` to `calculateScores` - [ ] Document performance characteristics and optimization strategies
- Created backward compatibility aliases with @deprecated tags
- Updated all tests with new method names
- ✓ Tests pass and build succeeds
#### 3.2 Add Chainable API ### 7. Developer Experience
- [ ] Create a more fluent API for complex searches - [ ] Add VS Code debugging configuration
- **Implementation approach**: - [ ] Implement changelog generation
- Implement chainable methods for setting options - [ ] Set up automated release process
- Add result transformation methods (map, filter, sort) - [ ] Add contribution guidelines
- Create fluent search building interface
- Implement method chaining for filters and transformations
- Add proper TypeScript type inference for chainable methods
- Create examples demonstrating the chainable API
#### 3.3 Enhance Return Types ## Priority Order
- [ ] Improve result objects with more useful information 1. Fix testing infrastructure (critical)
- **Implementation approach**: 2. Code quality improvements (high)
- Standardize return types across all search methods 3. Documentation (high)
- Add richer match information (character positions, context) 4. Feature enhancements (medium)
- Implement highlighting helpers for match visualization 5. Performance optimizations (medium)
- Add metadata to search results (time taken, options used) 6. Dependencies and build system (medium)
- Create proper TypeScript interfaces for all result types 7. Developer experience (low)
### 4. Documentation and Examples
#### 4.1 Create Comprehensive Documentation
- [ ] Improve documentation with Fuse.js-specific information
- **Implementation approach**:
- Generate TypeDoc documentation from JSDoc comments
- Create specific sections for Fuse.js integration details
- Add visual diagrams showing how Fuse.js is utilized
- Document all configuration options with examples
- Add performance guidelines based on Fuse.js recommendations
#### 4.2 Create Usage Examples
- [ ] Add specialized examples for common search patterns
- **Implementation approach**:
- Create examples for typical search scenarios (autocomplete, filtering, etc.)
- Add examples of weighted searching for different use cases
- Demonstrate extended search syntax with examples
- Create comparative examples showing different configuration effects
- Add performance optimization examples
### 5. Testing Enhancements
#### 5.1 Add Fuse.js-specific Tests
- [ ] Create tests focused on Fuse.js features
- **Implementation approach**:
- Add tests for all Fuse.js configuration options
- Create performance comparison tests for different settings
- Implement tests for extended search syntax
- Add tests for very large datasets
- Create index persistence and rebuilding tests
#### 5.2 Add Edge Case Tests
- [ ] Improve test coverage for Fuse.js edge cases
- **Implementation approach**:
- Test with unusual strings (very long, special characters, etc.)
- Add tests for multilingual content
- Create tests for zero-match and all-match cases
- Implement tests for threshold boundary conditions
- Add tests for unusual scoring scenarios
## Implementation Priority
### Phase 1: Core Improvements (1-2 weeks)
- [x] API Improvements (3.1 Standardize Method Naming) ✓ COMPLETED
- [ ] Configurability Enhancements (1.1 Enhance Configurability)
- [ ] Documentation Updates (4.1 Create Comprehensive Documentation)
### Phase 2: Performance Optimizations (1-2 weeks)
- [ ] Optimize Index Creation (2.1)
- [ ] Implement Basic Caching (2.2)
- [ ] Add Fuse.js-specific Tests (5.1)
### Phase 3: Advanced Features (2-3 weeks)
- [ ] Improve Weighted Field Support (1.2)
- [ ] Add Extended Search Capabilities (1.3)
- [ ] Add Chainable API (3.2)
- [ ] Enhance Return Types (3.3)
- [ ] Add Async Processing for Large Datasets (2.3)
- [ ] Create Usage Examples (4.2)
- [ ] Add Edge Case Tests (5.2)
## Expected Outcomes
- Significantly improved performance for large datasets
- More flexible and powerful search capabilities
- Better developer experience with improved API design
- Clearer understanding of the library through better documentation
- Higher test coverage, particularly for edge cases and performance scenarios

View File

@ -2,18 +2,14 @@ import { expect, tap } from '@push.rocks/tapbundle';
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
import * as smartfuzzy from '../ts/index.js'; import * as smartfuzzy from '../ts/index.js';
// Create fixed timestamps for consistent test results tap.test('should sort objects', async () => {
const timestamp1 = 1620000000000; // May 2021 const articleArray: tsclass.content.IArticle[] = [
const timestamp2 = 1620086400000; // May 2021 + 1 day
// Test articles with known content
const testArticles: tsclass.content.IArticle[] = [
{ {
title: 'Berlin has a ambivalent history', title: 'Berlin has a ambivalent history',
content: 'it is known that Berlin has an interesting history', content: 'it is known that Berlin has an interesting history',
author: null, author: null,
tags: ['city', 'Europe', 'history', 'travel'], tags: ['city', 'Europe', 'hello'],
timestamp: timestamp1, timestamp: Date.now(),
featuredImageUrl: null, featuredImageUrl: null,
url: null, url: null,
}, },
@ -21,111 +17,18 @@ const testArticles: tsclass.content.IArticle[] = [
title: 'Washington is a great city', title: 'Washington is a great city',
content: 'it is known that Washington is one of the greatest cities in the world', content: 'it is known that Washington is one of the greatest cities in the world',
author: null, author: null,
tags: ['city', 'USA', 'travel', 'politics'], tags: ['city', 'USA', 'hello'],
timestamp: timestamp2, timestamp: Date.now(),
featuredImageUrl: null, featuredImageUrl: null,
url: null, url: null,
}, },
{
title: 'Travel tips for European cities',
content: 'Here are some travel tips for European cities including Berlin and Paris',
author: null,
tags: ['travel', 'Europe', 'tips'],
timestamp: timestamp2,
featuredImageUrl: null,
url: null,
}
]; ];
let articleSearch: smartfuzzy.ArticleSearch; const testArticleSearch = new smartfuzzy.ArticleSearch(articleArray);
tap.test('should create an ArticleSearch instance', async () => { const result = await testArticleSearch.search('USA');
// Test creation with constructor console.log(result);
articleSearch = new smartfuzzy.ArticleSearch(testArticles); console.log(result[0].matches);
expect(articleSearch).toBeInstanceOf(smartfuzzy.ArticleSearch);
expect(articleSearch.articles.length).toEqual(testArticles.length);
// Test empty constructor
const emptySearch = new smartfuzzy.ArticleSearch();
expect(emptySearch.articles).toBeArray();
expect(emptySearch.articles.length).toEqual(0);
}); });
tap.test('should search by exact tag match', async () => { tap.start();
const result = await articleSearch.search('USA');
// Should have results
expect(result).toBeArray();
expect(result.length).toBeGreaterThan(0);
// First result should be the Washington article (contains USA tag)
expect(result[0].item.title).toInclude('Washington');
// Should include match information
expect(result[0].matches).toBeDefined();
expect(result[0].matches.length).toBeGreaterThan(0);
// At least one match should be for the 'USA' tag
const tagMatch = result[0].matches.find(m => m.key === 'tags' && m.value === 'USA');
expect(tagMatch).toBeDefined();
});
tap.test('should search by title and content', async () => {
// Search for term in the title and content of one article
const result = await articleSearch.search('Berlin');
expect(result.length).toBeGreaterThan(0);
expect(result[0].item.title).toInclude('Berlin');
// The Travel article mentions Berlin in content, so it should be included
// but ranked lower
const berlinArticleIndex = result.findIndex(r => r.item.title.includes('Berlin'));
const travelArticleIndex = result.findIndex(r => r.item.title.includes('Travel'));
expect(berlinArticleIndex).toBeLessThan(travelArticleIndex);
});
tap.test('should add articles incrementally', async () => {
const newSearch = new smartfuzzy.ArticleSearch();
expect(newSearch.articles.length).toEqual(0);
// Add one article
const newArticle: tsclass.content.IArticle = {
title: 'New Article',
content: 'This is a new article about technology',
author: null,
tags: ['technology', 'new'],
timestamp: Date.now(),
featuredImageUrl: null,
url: null,
};
newSearch.addArticle(newArticle);
expect(newSearch.articles.length).toEqual(1);
expect(newSearch.needsUpdate).toBeTrue();
// Search should update the index
const result = await newSearch.search('technology');
expect(result.length).toEqual(1);
expect(newSearch.needsUpdate).toBeFalse();
// Add another article and check if updates work
const anotherArticle: tsclass.content.IArticle = {
title: 'Another Tech Article',
content: 'Another article about technology innovations',
author: null,
tags: ['technology', 'innovation'],
timestamp: Date.now(),
featuredImageUrl: null,
url: null,
};
newSearch.addArticle(anotherArticle);
expect(newSearch.needsUpdate).toBeTrue();
// Search again should now return both articles
const newResult = await newSearch.search('technology');
expect(newResult.length).toEqual(2);
});
export default tap.start();

View File

@ -1,86 +1,19 @@
import { expect, tap } from '@push.rocks/tapbundle'; import { expect, tap } from '@push.rocks/tapbundle';
import * as smartfuzzy from '../ts/index.js'; import * as smartfuzzy from '../ts/index.js';
tap.test('should sort objects', async () => {
class Car { class Car {
constructor(public brand: string, public model?: string, public year?: number) {} constructor(public brand: string) {}
} }
const testCars = [ const testObjectSorter = new smartfuzzy.ObjectSorter<Car>([
new Car('BMW', 'X5', 2022), new Car('BMW'),
new Car('Mercedes Benz', 'S-Class', 2021), new Car('Mercedes Benz'),
new Car('Volvo', 'XC90', 2023), new Car('Volvo'),
new Car('Volkswagen', 'Golf', 2020),
new Car('Audi', 'A4', 2022),
];
let objectSorter: smartfuzzy.ObjectSorter<Car>;
tap.test('should create an instance of ObjectSorter', async () => {
objectSorter = new smartfuzzy.ObjectSorter<Car>(testCars);
expect(objectSorter).toBeInstanceOf(smartfuzzy.ObjectSorter);
expect(objectSorter.objectDictionary).toEqual(testCars);
// Test empty constructor
const emptyObjectSorter = new smartfuzzy.ObjectSorter<Car>();
expect(emptyObjectSorter.objectDictionary).toEqual([]);
});
tap.test('should sort objects by exact brand match', async () => {
const result = objectSorter.sort('Volvo', ['brand']);
// Should return an array of results
expect(result).toBeArray();
expect(result.length).toBeGreaterThan(0);
// First result should be the Volvo
expect(result[0].item.brand).toEqual('Volvo');
// Should have expected result structure
expect(result[0]).toHaveProperty('item');
expect(result[0]).toHaveProperty('refIndex');
expect(result[0].refIndex).toBeTypeofNumber();
// Reference index should match the original array position
expect(result[0].refIndex).toEqual(2); // Volvo is at index 2
});
tap.test('should sort objects by fuzzy brand match', async () => {
// "Wolvo" should fuzzy match to "Volvo"
const result = objectSorter.sort('Wolvo', ['brand']);
expect(result.length).toBeGreaterThan(0);
expect(result[0].item.brand).toEqual('Volvo');
});
tap.test('should sort objects by multiple field search', async () => {
// Add a car with similar model name but different brand
objectSorter = new smartfuzzy.ObjectSorter<Car>([
...testCars,
new Car('Toyota', 'X5 Replica', 2020),
]); ]);
// Search across both brand and model const result = testObjectSorter.sort('Volvo', ['brand']);
const result = objectSorter.sort('BMW X5', ['brand', 'model']); console.log(result);
expect(result.length).toBeGreaterThan(0);
// BMW X5 should be first result
expect(result[0].item.brand).toEqual('BMW');
expect(result[0].item.model).toEqual('X5');
// Toyota X5 Replica may be in results depending on threshold
// But we shouldn't expect it specifically since results depend on the
// fuzzy matching algorithm's threshold setting
// BMW should be the first result
const bmwIndex = result.findIndex(r => r.item.brand === 'BMW');
expect(bmwIndex).toEqual(0);
// If Toyota is in results, it should be ranked lower than BMW
const toyotaIndex = result.findIndex(r => r.item.brand === 'Toyota');
if (toyotaIndex !== -1) {
expect(bmwIndex).toBeLessThan(toyotaIndex);
}
}); });
export default tap.start(); tap.start();

View File

@ -2,68 +2,25 @@ import { expect, tap } from '@push.rocks/tapbundle';
import * as smartfuzzy from '../ts/index.js'; import * as smartfuzzy from '../ts/index.js';
let testSmartfuzzy: smartfuzzy.Smartfuzzy; let testSmartfuzzy: smartfuzzy.Smartfuzzy;
const testDictionary = [
tap.test('should create an instance of Smartfuzzy', async () => {
testSmartfuzzy = new smartfuzzy.Smartfuzzy([
'Sony', 'Sony',
'Deutsche Bahn', 'Deutsche Bahn',
'Apple Inc.', 'Apple Inc.',
"Trader Joe's", "Trader Joe's",
]; ]);
tap.test('should create an instance of Smartfuzzy', async () => {
testSmartfuzzy = new smartfuzzy.Smartfuzzy(testDictionary);
expect(testSmartfuzzy).toBeInstanceOf(smartfuzzy.Smartfuzzy); expect(testSmartfuzzy).toBeInstanceOf(smartfuzzy.Smartfuzzy);
expect(testSmartfuzzy.dictionary).toEqual(testDictionary);
}); });
tap.test('should compute a score for a string against the dictionary', async () => { tap.test('should compute a score', async () => {
const result = testSmartfuzzy.calculateScores('Apple'); const result = testSmartfuzzy.getChangeScoreForString('Apple');
console.log(result);
// Check that we got a dictionary map back
expect(result).toBeTypeOf('object');
// Check that every dictionary entry has a score
for (const word of testDictionary) {
expect(result).toHaveProperty(word);
expect(result[word]).toBeTypeofNumber();
}
// Check that 'Apple Inc.' has a lower score (better match) for 'Apple' than other entries
// The leven distance for 'Apple Inc.' from 'Apple' should be less than that of other entries
// We can't predict exact values but we can compare them
expect(result['Apple Inc.']).toBeLessThanOrEqual(result['Sony']);
}); });
tap.test('should get closest match for a string', async () => { tap.test('should get closest match', async () => {
const result = testSmartfuzzy.findClosestMatch('Apple'); const result = testSmartfuzzy.getClosestMatchForString('Apple');
console.log(result);
// Should return closest match as string
expect(result).toBeTypeofString();
// Should match the expected closest entry
expect(result).toEqual('Apple Inc.');
}); });
tap.test('should add words to dictionary', async () => { tap.start();
const initialLength = testSmartfuzzy.dictionary.length;
// Add a single word
testSmartfuzzy.addToDictionary('Microsoft');
expect(testSmartfuzzy.dictionary.length).toEqual(initialLength + 1);
expect(testSmartfuzzy.dictionary).toContain('Microsoft');
// Add multiple words
const additionalWords = ['Google', 'Amazon', 'Facebook'];
testSmartfuzzy.addToDictionary(additionalWords);
expect(testSmartfuzzy.dictionary.length).toEqual(initialLength + 4);
for (const word of additionalWords) {
expect(testSmartfuzzy.dictionary).toContain(word);
}
});
tap.test('should handle empty query string', async () => {
const result = testSmartfuzzy.findClosestMatch('');
// For empty strings, behavior should be defined (either null or a specific result)
expect(result).toBeNullOrUndefined();
});
export default tap.start();

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartfuzzy', name: '@push.rocks/smartfuzzy',
version: '1.1.10', version: '1.1.7',
description: 'A library for fuzzy matching strings against word dictionaries or arrays, with support for object and article searching.' description: 'A library for fuzzy matching strings against word dictionaries or arrays, with support for object and article searching.'
} }

View File

@ -1,177 +1,37 @@
import * as plugins from './smartfuzzy.plugins.js'; import * as plugins from './smartfuzzy.plugins.js';
/** /**
* Type for the search result returned by ArticleSearch * an article search that searches articles in a weighted manner
*/
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 { export class ArticleSearch {
/**
* Collection of articles to search through
*/
public articles: plugins.tsclass.content.IArticle[] = []; public articles: plugins.tsclass.content.IArticle[] = [];
/**
* Flag indicating whether the search index needs to be updated
*/
public needsUpdate: boolean = false; public needsUpdate: boolean = false;
/**
* Promise manager for async operations
*/
private readyDeferred = plugins.smartpromise.defer(); private readyDeferred = plugins.smartpromise.defer();
/**
* Fuse.js instance for searching
*/
private fuse: plugins.fuseJs<plugins.tsclass.content.IArticle>; 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[]) { 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.fuse = new plugins.fuseJs(this.articles);
this.readyDeferred.resolve(); this.readyDeferred.resolve();
if (articleArrayArg) { if (articleArrayArg) {
for (const article of 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); this.addArticle(article);
} }
} }
} }
/** /**
* Adds an article to the collection and marks the index for updating * allows adding an article
*
* @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 { addArticle(articleArg: plugins.tsclass.content.IArticle) {
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.articles.push(articleArg);
this.needsUpdate = true; this.needsUpdate = true;
} }
/** /**
* Performs a weighted fuzzy search across all articles * allows searching an article
*
* 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[]> { public async search(searchStringArg: string) {
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) { if (this.needsUpdate) {
const oldDeferred = this.readyDeferred; const oldDeferred = this.readyDeferred;
this.readyDeferred = plugins.smartpromise.defer(); this.readyDeferred = plugins.smartpromise.defer();

View File

@ -1,107 +1,18 @@
import * as plugins from './smartfuzzy.plugins.js'; import * as plugins from './smartfuzzy.plugins.js';
/**
* Result of a fuzzy search on objects
*
* @typeParam T - The type of object being searched
*/
export interface IFuzzySearchResult<T> {
/** The matched object */
item: T;
/** The index of the object in the original array */
refIndex: number;
/** The match score (lower is better) */
score?: number;
}
/**
* Handles fuzzy searching and sorting of objects based on their properties
*
* @typeParam T - The type of objects to search through
*
* @example
* ```typescript
* interface User {
* name: string;
* email: string;
* }
*
* const users = [
* { name: 'John Smith', email: 'john@example.com' },
* { name: 'Jane Doe', email: 'jane@example.com' }
* ];
*
* const sorter = new ObjectSorter<User>(users);
* const results = sorter.sort('john', ['name', 'email']);
* ```
*/
export class ObjectSorter<T> { export class ObjectSorter<T> {
/**
* The collection of objects to search through
*/
public objectDictionary: T[]; public objectDictionary: T[];
/**
* Creates a new ObjectSorter instance
*
* @param objectDictionaryArg - Array of objects to search through
*/
constructor(objectDictionaryArg: T[] = []) { constructor(objectDictionaryArg: T[] = []) {
if (objectDictionaryArg !== undefined && !Array.isArray(objectDictionaryArg)) {
throw new Error('Object dictionary must be an array');
}
this.objectDictionary = objectDictionaryArg; this.objectDictionary = objectDictionaryArg;
} }
/** sort(stringArg: string, objectKeysArg: string[]): Array<{ item: T; refIndex: number; score?: number }> {
* Searches and sorts objects based on how well they match the search string
* in the specified object properties
*
* @param stringArg - The search query string
* @param objectKeysArg - Array of object property names to search within
* @returns Array of results sorted by relevance (best matches first)
*
* @example
* ```typescript
* // Search for 'john' in both name and email fields
* const results = sorter.sort('john', ['name', 'email']);
*
* // First result is the best match
* console.log(results[0].item.name); // 'John Smith'
* ```
*/
public sort(stringArg: string, objectKeysArg: string[]): Array<IFuzzySearchResult<T>> {
if (typeof stringArg !== 'string') {
throw new Error('Search string must be a string');
}
if (!Array.isArray(objectKeysArg)) {
throw new Error('Object keys must be an array');
}
if (objectKeysArg.length === 0) {
throw new Error('At least one object key must be provided for searching');
}
// Verify all keys are strings
for (const key of objectKeysArg) {
if (typeof key !== 'string') {
throw new Error('All object keys must be strings');
}
}
// Empty dictionary should return empty results instead of error
if (this.objectDictionary.length === 0) {
return [];
}
const fuseOptions = { const fuseOptions = {
shouldSort: true, shouldSort: true,
threshold: 0.6, // Lower values = more strict matching threshold: 0.6,
location: 0, // Where to start searching in the string location: 0,
distance: 100, // How far to search in the string distance: 100,
maxPatternLength: 32, maxPatternLength: 32,
minMatchCharLength: 1, minMatchCharLength: 1,
keys: objectKeysArg, keys: objectKeysArg,

View File

@ -2,93 +2,31 @@ import * as plugins from './smartfuzzy.plugins.js';
export let standardExport = 'Hi there! :) This is an exported string'; export let standardExport = 'Hi there! :) This is an exported string';
/**
* Type representing a dictionary of words mapped to their scores
* Lower scores typically indicate better matches
*/
export type TDictionaryMap = { [key: string]: number }; export type TDictionaryMap = { [key: string]: number };
/**
* Main class for fuzzy string matching against a dictionary
*
* @example
* ```typescript
* const fuzzy = new Smartfuzzy(['apple', 'banana', 'orange']);
* const result = fuzzy.findClosestMatch('aple'); // Returns 'apple'
* ```
*/
export class Smartfuzzy { export class Smartfuzzy {
/** dictionary: string[];
* Array of words used for fuzzy matching
*/
public dictionary: string[];
/**
* Creates a new Smartfuzzy instance
*
* @param dictionary - Initial array of words to use for matching
*/
constructor(dictionary: string[]) { constructor(dictionary: string[]) {
if (!Array.isArray(dictionary)) {
throw new Error('Dictionary must be an array of strings');
}
this.dictionary = dictionary; this.dictionary = dictionary;
} }
/** /**
* Adds one or more words to the dictionary * adds words to the dictionary
* * @param payloadArg
* @param payloadArg - A single word or an array of words to add to the dictionary
* @returns void
*
* @example
* ```typescript
* fuzzy.addToDictionary('pear');
* fuzzy.addToDictionary(['pear', 'grape', 'kiwi']);
* ```
*/ */
public addToDictionary(payloadArg: string | string[]): void { addToDictionary(payloadArg: string | string[]) {
if (payloadArg === undefined || payloadArg === null) {
throw new Error('Input cannot be null or undefined');
}
if (Array.isArray(payloadArg)) { if (Array.isArray(payloadArg)) {
// Validate all items in array are strings
for (const item of payloadArg) {
if (typeof item !== 'string') {
throw new Error('All items in array must be strings');
}
}
this.dictionary = this.dictionary.concat(payloadArg); this.dictionary = this.dictionary.concat(payloadArg);
} else if (typeof payloadArg === 'string') {
this.dictionary.push(payloadArg);
} else { } else {
throw new Error('Input must be a string or an array of strings'); this.dictionary.push(payloadArg);
} }
} }
/** /**
* Calculates the Levenshtein distance (edit distance) between the input string * returns the closest match for a given string
* and each word in the dictionary * @param stringArg
*
* @param stringArg - The string to compare against the dictionary
* @returns A dictionary map where keys are words and values are edit distances
*
* @example
* ```typescript
* const scores = fuzzy.calculateScores('aple');
* // Returns: { 'apple': 1, 'banana': 5, 'orange': 5 }
* ```
*/ */
public calculateScores(stringArg: string): TDictionaryMap { getChangeScoreForString(stringArg: string): TDictionaryMap {
if (typeof stringArg !== 'string') {
throw new Error('Input must be a string');
}
if (this.dictionary.length === 0) {
throw new Error('Dictionary is empty');
}
const dictionaryMap: TDictionaryMap = {}; const dictionaryMap: TDictionaryMap = {};
for (const wordArg of this.dictionary) { for (const wordArg of this.dictionary) {
dictionaryMap[wordArg] = plugins.leven(stringArg, wordArg); dictionaryMap[wordArg] = plugins.leven(stringArg, wordArg);
@ -96,34 +34,7 @@ export class Smartfuzzy {
return dictionaryMap; return dictionaryMap;
} }
/** getClosestMatchForString(stringArg: string): string {
* @deprecated Use calculateScores instead
*/
public getChangeScoreForString(stringArg: string): TDictionaryMap {
return this.calculateScores(stringArg);
}
/**
* Finds the closest matching word in the dictionary using fuzzy search
*
* @param stringArg - The string to find a match for
* @returns The closest matching word, or null if no match is found or dictionary is empty
*
* @example
* ```typescript
* const match = fuzzy.findClosestMatch('oragne');
* // Returns: 'orange'
* ```
*/
public findClosestMatch(stringArg: string): string {
if (typeof stringArg !== 'string') {
throw new Error('Input must be a string');
}
if (this.dictionary.length === 0) {
return null; // Return null for empty dictionary instead of throwing error
}
const fuseDictionary: { name: string }[] = []; const fuseDictionary: { name: string }[] = [];
for (const wordArg of this.dictionary) { for (const wordArg of this.dictionary) {
fuseDictionary.push({ fuseDictionary.push({
@ -147,11 +58,4 @@ export class Smartfuzzy {
} }
return closestMatch; return closestMatch;
} }
/**
* @deprecated Use findClosestMatch instead
*/
public getClosestMatchForString(stringArg: string): string {
return this.findClosestMatch(stringArg);
}
} }