@danielsimonjr/memory-mcp 0.7.2 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/edge-cases/edge-cases.test.js +406 -0
- package/dist/__tests__/file-path.test.js +5 -5
- package/dist/__tests__/integration/workflows.test.js +449 -0
- package/dist/__tests__/knowledge-graph.test.js +8 -3
- package/dist/__tests__/performance/benchmarks.test.js +413 -0
- package/dist/__tests__/unit/core/EntityManager.test.js +334 -0
- package/dist/__tests__/unit/core/GraphStorage.test.js +205 -0
- package/dist/__tests__/unit/core/RelationManager.test.js +274 -0
- package/dist/__tests__/unit/features/CompressionManager.test.js +350 -0
- package/dist/__tests__/unit/search/BasicSearch.test.js +311 -0
- package/dist/__tests__/unit/search/BooleanSearch.test.js +432 -0
- package/dist/__tests__/unit/search/FuzzySearch.test.js +448 -0
- package/dist/__tests__/unit/search/RankedSearch.test.js +379 -0
- package/dist/__tests__/unit/utils/levenshtein.test.js +77 -0
- package/dist/core/EntityManager.js +554 -0
- package/dist/core/GraphStorage.js +172 -0
- package/dist/core/KnowledgeGraphManager.js +423 -0
- package/dist/core/ObservationManager.js +129 -0
- package/dist/core/RelationManager.js +186 -0
- package/dist/core/TransactionManager.js +389 -0
- package/dist/core/index.js +9 -0
- package/dist/features/AnalyticsManager.js +222 -0
- package/dist/features/ArchiveManager.js +74 -0
- package/dist/features/BackupManager.js +311 -0
- package/dist/features/CompressionManager.js +291 -0
- package/dist/features/ExportManager.js +305 -0
- package/dist/features/HierarchyManager.js +219 -0
- package/dist/features/ImportExportManager.js +50 -0
- package/dist/features/ImportManager.js +328 -0
- package/dist/features/TagManager.js +210 -0
- package/dist/features/index.js +12 -0
- package/dist/index.js +13 -996
- package/dist/memory.jsonl +18 -0
- package/dist/search/BasicSearch.js +131 -0
- package/dist/search/BooleanSearch.js +283 -0
- package/dist/search/FuzzySearch.js +96 -0
- package/dist/search/RankedSearch.js +190 -0
- package/dist/search/SavedSearchManager.js +145 -0
- package/dist/search/SearchFilterChain.js +187 -0
- package/dist/search/SearchManager.js +305 -0
- package/dist/search/SearchSuggestions.js +57 -0
- package/dist/search/TFIDFIndexManager.js +217 -0
- package/dist/search/index.js +14 -0
- package/dist/server/MCPServer.js +52 -0
- package/dist/server/toolDefinitions.js +732 -0
- package/dist/server/toolHandlers.js +117 -0
- package/dist/types/analytics.types.js +6 -0
- package/dist/types/entity.types.js +7 -0
- package/dist/types/import-export.types.js +7 -0
- package/dist/types/index.js +12 -0
- package/dist/types/search.types.js +7 -0
- package/dist/types/tag.types.js +6 -0
- package/dist/utils/constants.js +128 -0
- package/dist/utils/dateUtils.js +89 -0
- package/dist/utils/entityUtils.js +108 -0
- package/dist/utils/errors.js +121 -0
- package/dist/utils/filterUtils.js +155 -0
- package/dist/utils/index.js +39 -0
- package/dist/utils/levenshtein.js +62 -0
- package/dist/utils/logger.js +33 -0
- package/dist/utils/paginationUtils.js +81 -0
- package/dist/utils/pathUtils.js +115 -0
- package/dist/utils/responseFormatter.js +55 -0
- package/dist/utils/schemas.js +184 -0
- package/dist/utils/searchCache.js +209 -0
- package/dist/utils/tagUtils.js +107 -0
- package/dist/utils/tfidf.js +90 -0
- package/dist/utils/validationHelper.js +99 -0
- package/dist/utils/validationUtils.js +109 -0
- package/package.json +82 -48
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ranked Search
|
|
3
|
+
*
|
|
4
|
+
* TF-IDF relevance-based search with scoring and pre-calculated indexes.
|
|
5
|
+
*
|
|
6
|
+
* @module search/RankedSearch
|
|
7
|
+
*/
|
|
8
|
+
import { calculateTFIDF, tokenize } from '../utils/tfidf.js';
|
|
9
|
+
import { SEARCH_LIMITS } from '../utils/constants.js';
|
|
10
|
+
import { TFIDFIndexManager } from './TFIDFIndexManager.js';
|
|
11
|
+
import { SearchFilterChain } from './SearchFilterChain.js';
|
|
12
|
+
/**
|
|
13
|
+
* Performs TF-IDF ranked search with optional pre-calculated indexes.
|
|
14
|
+
*/
|
|
15
|
+
export class RankedSearch {
|
|
16
|
+
storage;
|
|
17
|
+
indexManager = null;
|
|
18
|
+
constructor(storage, storageDir) {
|
|
19
|
+
this.storage = storage;
|
|
20
|
+
// Initialize index manager if storage directory is provided
|
|
21
|
+
if (storageDir) {
|
|
22
|
+
this.indexManager = new TFIDFIndexManager(storageDir);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Initialize and build the TF-IDF index for fast searches.
|
|
27
|
+
*
|
|
28
|
+
* Should be called after graph changes to keep index up-to-date.
|
|
29
|
+
*/
|
|
30
|
+
async buildIndex() {
|
|
31
|
+
if (!this.indexManager) {
|
|
32
|
+
throw new Error('Index manager not initialized. Provide storageDir to constructor.');
|
|
33
|
+
}
|
|
34
|
+
const graph = await this.storage.loadGraph();
|
|
35
|
+
await this.indexManager.buildIndex(graph);
|
|
36
|
+
await this.indexManager.saveIndex();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Update the index incrementally after entity changes.
|
|
40
|
+
*
|
|
41
|
+
* @param changedEntityNames - Names of entities that were created, updated, or deleted
|
|
42
|
+
*/
|
|
43
|
+
async updateIndex(changedEntityNames) {
|
|
44
|
+
if (!this.indexManager) {
|
|
45
|
+
return; // No index manager, skip
|
|
46
|
+
}
|
|
47
|
+
const graph = await this.storage.loadGraph();
|
|
48
|
+
await this.indexManager.updateIndex(graph, changedEntityNames);
|
|
49
|
+
await this.indexManager.saveIndex();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load the TF-IDF index from disk if available.
|
|
53
|
+
*/
|
|
54
|
+
async ensureIndexLoaded() {
|
|
55
|
+
if (!this.indexManager) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// Return cached index if already loaded
|
|
59
|
+
const cached = this.indexManager.getIndex();
|
|
60
|
+
if (cached) {
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
// Try to load from disk
|
|
64
|
+
return await this.indexManager.loadIndex();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Search with TF-IDF relevance ranking.
|
|
68
|
+
*
|
|
69
|
+
* Uses pre-calculated index if available, falls back to on-the-fly calculation.
|
|
70
|
+
*
|
|
71
|
+
* @param query - Search query
|
|
72
|
+
* @param tags - Optional tags filter
|
|
73
|
+
* @param minImportance - Optional minimum importance
|
|
74
|
+
* @param maxImportance - Optional maximum importance
|
|
75
|
+
* @param limit - Maximum results to return (default 50, max 200)
|
|
76
|
+
* @returns Array of search results sorted by relevance
|
|
77
|
+
*/
|
|
78
|
+
async searchNodesRanked(query, tags, minImportance, maxImportance, limit = SEARCH_LIMITS.DEFAULT) {
|
|
79
|
+
// Enforce maximum search limit
|
|
80
|
+
const effectiveLimit = Math.min(limit, SEARCH_LIMITS.MAX);
|
|
81
|
+
const graph = await this.storage.loadGraph();
|
|
82
|
+
// Apply tag and importance filters using SearchFilterChain
|
|
83
|
+
const filters = { tags, minImportance, maxImportance };
|
|
84
|
+
const filteredEntities = SearchFilterChain.applyFilters(graph.entities, filters);
|
|
85
|
+
// Try to use pre-calculated index
|
|
86
|
+
const index = await this.ensureIndexLoaded();
|
|
87
|
+
const queryTerms = tokenize(query);
|
|
88
|
+
if (index) {
|
|
89
|
+
// Use pre-calculated index for fast search
|
|
90
|
+
return this.searchWithIndex(filteredEntities, queryTerms, index, effectiveLimit);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Fall back to on-the-fly calculation
|
|
94
|
+
return this.searchWithoutIndex(filteredEntities, queryTerms, effectiveLimit);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Search using pre-calculated TF-IDF index (fast path).
|
|
99
|
+
*/
|
|
100
|
+
searchWithIndex(entities, queryTerms, index, limit) {
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const entity of entities) {
|
|
103
|
+
const docVector = index.documents.get(entity.name);
|
|
104
|
+
if (!docVector) {
|
|
105
|
+
continue; // Entity not in index
|
|
106
|
+
}
|
|
107
|
+
// Calculate total terms in document (sum of all term frequencies)
|
|
108
|
+
const totalTerms = Object.values(docVector.terms).reduce((sum, count) => sum + count, 0);
|
|
109
|
+
if (totalTerms === 0)
|
|
110
|
+
continue;
|
|
111
|
+
// Calculate score using pre-calculated term frequencies and IDF
|
|
112
|
+
let totalScore = 0;
|
|
113
|
+
const matchedFields = {};
|
|
114
|
+
for (const term of queryTerms) {
|
|
115
|
+
const termCount = docVector.terms[term] || 0;
|
|
116
|
+
const idf = index.idf.get(term) || 0;
|
|
117
|
+
// Calculate TF-IDF: (termCount / totalTerms) * IDF
|
|
118
|
+
const tf = termCount / totalTerms;
|
|
119
|
+
const tfidf = tf * idf;
|
|
120
|
+
totalScore += tfidf;
|
|
121
|
+
// Track which fields matched
|
|
122
|
+
if (termCount > 0) {
|
|
123
|
+
if (entity.name.toLowerCase().includes(term)) {
|
|
124
|
+
matchedFields.name = true;
|
|
125
|
+
}
|
|
126
|
+
if (entity.entityType.toLowerCase().includes(term)) {
|
|
127
|
+
matchedFields.entityType = true;
|
|
128
|
+
}
|
|
129
|
+
const matchedObs = entity.observations.filter(o => o.toLowerCase().includes(term));
|
|
130
|
+
if (matchedObs.length > 0) {
|
|
131
|
+
matchedFields.observations = matchedObs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Only include entities with non-zero scores
|
|
136
|
+
if (totalScore > 0) {
|
|
137
|
+
results.push({
|
|
138
|
+
entity,
|
|
139
|
+
score: totalScore,
|
|
140
|
+
matchedFields,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Sort by score descending and apply limit
|
|
145
|
+
return results
|
|
146
|
+
.sort((a, b) => b.score - a.score)
|
|
147
|
+
.slice(0, limit);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Search without index (on-the-fly calculation, slow path).
|
|
151
|
+
*/
|
|
152
|
+
searchWithoutIndex(entities, queryTerms, limit) {
|
|
153
|
+
const results = [];
|
|
154
|
+
const documents = entities.map(e => [e.name, e.entityType, ...e.observations].join(' '));
|
|
155
|
+
for (let i = 0; i < entities.length; i++) {
|
|
156
|
+
const entity = entities[i];
|
|
157
|
+
const document = documents[i];
|
|
158
|
+
// Calculate score for each query term
|
|
159
|
+
let totalScore = 0;
|
|
160
|
+
const matchedFields = {};
|
|
161
|
+
for (const term of queryTerms) {
|
|
162
|
+
const score = calculateTFIDF(term, document, documents);
|
|
163
|
+
totalScore += score;
|
|
164
|
+
// Track which fields matched
|
|
165
|
+
if (entity.name.toLowerCase().includes(term)) {
|
|
166
|
+
matchedFields.name = true;
|
|
167
|
+
}
|
|
168
|
+
if (entity.entityType.toLowerCase().includes(term)) {
|
|
169
|
+
matchedFields.entityType = true;
|
|
170
|
+
}
|
|
171
|
+
const matchedObs = entity.observations.filter(o => o.toLowerCase().includes(term));
|
|
172
|
+
if (matchedObs.length > 0) {
|
|
173
|
+
matchedFields.observations = matchedObs;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Only include entities with non-zero scores
|
|
177
|
+
if (totalScore > 0) {
|
|
178
|
+
results.push({
|
|
179
|
+
entity,
|
|
180
|
+
score: totalScore,
|
|
181
|
+
matchedFields,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Sort by score descending and apply limit
|
|
186
|
+
return results
|
|
187
|
+
.sort((a, b) => b.score - a.score)
|
|
188
|
+
.slice(0, limit);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved Search Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages persistent saved searches with JSONL storage.
|
|
5
|
+
*
|
|
6
|
+
* @module search/SavedSearchManager
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'fs/promises';
|
|
9
|
+
/**
|
|
10
|
+
* Manages saved search queries with usage tracking.
|
|
11
|
+
*/
|
|
12
|
+
export class SavedSearchManager {
|
|
13
|
+
savedSearchesFilePath;
|
|
14
|
+
basicSearch;
|
|
15
|
+
constructor(savedSearchesFilePath, basicSearch) {
|
|
16
|
+
this.savedSearchesFilePath = savedSearchesFilePath;
|
|
17
|
+
this.basicSearch = basicSearch;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load all saved searches from JSONL file.
|
|
21
|
+
*
|
|
22
|
+
* @returns Array of saved searches
|
|
23
|
+
*/
|
|
24
|
+
async loadSavedSearches() {
|
|
25
|
+
try {
|
|
26
|
+
const data = await fs.readFile(this.savedSearchesFilePath, 'utf-8');
|
|
27
|
+
const lines = data.split('\n').filter((line) => line.trim() !== '');
|
|
28
|
+
return lines.map((line) => JSON.parse(line));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Save searches to JSONL file.
|
|
39
|
+
*
|
|
40
|
+
* @param searches - Array of saved searches
|
|
41
|
+
*/
|
|
42
|
+
async saveSavedSearches(searches) {
|
|
43
|
+
const lines = searches.map(s => JSON.stringify(s));
|
|
44
|
+
await fs.writeFile(this.savedSearchesFilePath, lines.join('\n'));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Save a search query for later reuse.
|
|
48
|
+
*
|
|
49
|
+
* @param search - Search parameters (without createdAt, useCount, lastUsed)
|
|
50
|
+
* @returns The newly created saved search
|
|
51
|
+
* @throws Error if search name already exists
|
|
52
|
+
*/
|
|
53
|
+
async saveSearch(search) {
|
|
54
|
+
const searches = await this.loadSavedSearches();
|
|
55
|
+
// Check if name already exists
|
|
56
|
+
if (searches.some(s => s.name === search.name)) {
|
|
57
|
+
throw new Error(`Saved search with name "${search.name}" already exists`);
|
|
58
|
+
}
|
|
59
|
+
const newSearch = {
|
|
60
|
+
...search,
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
useCount: 0,
|
|
63
|
+
};
|
|
64
|
+
searches.push(newSearch);
|
|
65
|
+
await this.saveSavedSearches(searches);
|
|
66
|
+
return newSearch;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* List all saved searches.
|
|
70
|
+
*
|
|
71
|
+
* @returns Array of all saved searches
|
|
72
|
+
*/
|
|
73
|
+
async listSavedSearches() {
|
|
74
|
+
return await this.loadSavedSearches();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get a specific saved search by name.
|
|
78
|
+
*
|
|
79
|
+
* @param name - Search name
|
|
80
|
+
* @returns Saved search or null if not found
|
|
81
|
+
*/
|
|
82
|
+
async getSavedSearch(name) {
|
|
83
|
+
const searches = await this.loadSavedSearches();
|
|
84
|
+
return searches.find(s => s.name === name) || null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Execute a saved search by name.
|
|
88
|
+
*
|
|
89
|
+
* Updates usage statistics (lastUsed, useCount) before executing.
|
|
90
|
+
*
|
|
91
|
+
* @param name - Search name
|
|
92
|
+
* @returns Search results as knowledge graph
|
|
93
|
+
* @throws Error if search not found
|
|
94
|
+
*/
|
|
95
|
+
async executeSavedSearch(name) {
|
|
96
|
+
const searches = await this.loadSavedSearches();
|
|
97
|
+
const search = searches.find(s => s.name === name);
|
|
98
|
+
if (!search) {
|
|
99
|
+
throw new Error(`Saved search "${name}" not found`);
|
|
100
|
+
}
|
|
101
|
+
// Update usage statistics
|
|
102
|
+
search.lastUsed = new Date().toISOString();
|
|
103
|
+
search.useCount++;
|
|
104
|
+
await this.saveSavedSearches(searches);
|
|
105
|
+
// Execute the search using BasicSearch
|
|
106
|
+
return await this.basicSearch.searchNodes(search.query, search.tags, search.minImportance, search.maxImportance);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Delete a saved search.
|
|
110
|
+
*
|
|
111
|
+
* @param name - Search name
|
|
112
|
+
* @returns True if deleted, false if not found
|
|
113
|
+
*/
|
|
114
|
+
async deleteSavedSearch(name) {
|
|
115
|
+
const searches = await this.loadSavedSearches();
|
|
116
|
+
const initialLength = searches.length;
|
|
117
|
+
const filtered = searches.filter(s => s.name !== name);
|
|
118
|
+
if (filtered.length === initialLength) {
|
|
119
|
+
return false; // Search not found
|
|
120
|
+
}
|
|
121
|
+
await this.saveSavedSearches(filtered);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Update a saved search.
|
|
126
|
+
*
|
|
127
|
+
* Cannot update name, createdAt, useCount, or lastUsed fields.
|
|
128
|
+
*
|
|
129
|
+
* @param name - Search name
|
|
130
|
+
* @param updates - Partial search with fields to update
|
|
131
|
+
* @returns Updated saved search
|
|
132
|
+
* @throws Error if search not found
|
|
133
|
+
*/
|
|
134
|
+
async updateSavedSearch(name, updates) {
|
|
135
|
+
const searches = await this.loadSavedSearches();
|
|
136
|
+
const search = searches.find(s => s.name === name);
|
|
137
|
+
if (!search) {
|
|
138
|
+
throw new Error(`Saved search "${name}" not found`);
|
|
139
|
+
}
|
|
140
|
+
// Apply updates
|
|
141
|
+
Object.assign(search, updates);
|
|
142
|
+
await this.saveSavedSearches(searches);
|
|
143
|
+
return search;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Filter Chain
|
|
3
|
+
*
|
|
4
|
+
* Centralizes filter logic for all search implementations to eliminate
|
|
5
|
+
* duplicate filtering code across BasicSearch, BooleanSearch, FuzzySearch,
|
|
6
|
+
* and RankedSearch.
|
|
7
|
+
*
|
|
8
|
+
* @module search/SearchFilterChain
|
|
9
|
+
*/
|
|
10
|
+
import { normalizeTags, hasMatchingTag } from '../utils/tagUtils.js';
|
|
11
|
+
import { isWithinImportanceRange } from '../utils/filterUtils.js';
|
|
12
|
+
import { validatePagination, applyPagination } from '../utils/paginationUtils.js';
|
|
13
|
+
/**
|
|
14
|
+
* Centralized filter chain for all search implementations.
|
|
15
|
+
* Ensures consistent filtering behavior across search types.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const filters: SearchFilters = { tags: ['important'], minImportance: 5 };
|
|
20
|
+
* const filtered = SearchFilterChain.applyFilters(entities, filters);
|
|
21
|
+
* const pagination = SearchFilterChain.validatePagination(0, 50);
|
|
22
|
+
* const result = SearchFilterChain.paginate(filtered, pagination);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class SearchFilterChain {
|
|
26
|
+
/**
|
|
27
|
+
* Applies all filters to an array of entities.
|
|
28
|
+
* Entities must pass ALL specified filters to be included.
|
|
29
|
+
*
|
|
30
|
+
* @param entities - Entities to filter
|
|
31
|
+
* @param filters - Filter criteria to apply
|
|
32
|
+
* @returns Filtered entities array
|
|
33
|
+
*/
|
|
34
|
+
static applyFilters(entities, filters) {
|
|
35
|
+
// Early return if no filters are active
|
|
36
|
+
if (!this.hasActiveFilters(filters)) {
|
|
37
|
+
return entities;
|
|
38
|
+
}
|
|
39
|
+
// Pre-normalize tags once for efficiency
|
|
40
|
+
const normalizedSearchTags = filters.tags?.length
|
|
41
|
+
? normalizeTags(filters.tags)
|
|
42
|
+
: undefined;
|
|
43
|
+
return entities.filter(entity => this.entityPassesFilters(entity, filters, normalizedSearchTags));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Checks if an entity passes all active filters.
|
|
47
|
+
* Short-circuits on first failing filter for performance.
|
|
48
|
+
*
|
|
49
|
+
* @param entity - Entity to check
|
|
50
|
+
* @param filters - Filter criteria
|
|
51
|
+
* @param normalizedSearchTags - Pre-normalized search tags (for efficiency)
|
|
52
|
+
* @returns true if entity passes all filters
|
|
53
|
+
*/
|
|
54
|
+
static entityPassesFilters(entity, filters, normalizedSearchTags) {
|
|
55
|
+
// Tag filter - check if entity has any matching tag
|
|
56
|
+
if (normalizedSearchTags && normalizedSearchTags.length > 0) {
|
|
57
|
+
if (!entity.tags || entity.tags.length === 0) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
const entityTags = normalizeTags(entity.tags);
|
|
61
|
+
const hasMatch = normalizedSearchTags.some(tag => entityTags.includes(tag));
|
|
62
|
+
if (!hasMatch) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Importance filter
|
|
67
|
+
if (!isWithinImportanceRange(entity.importance, filters.minImportance, filters.maxImportance)) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
// Entity type filter
|
|
71
|
+
if (filters.entityType && entity.entityType !== filters.entityType) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// Created date filter
|
|
75
|
+
if (filters.createdAfter || filters.createdBefore) {
|
|
76
|
+
if (!entity.createdAt) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const createdAt = new Date(entity.createdAt);
|
|
80
|
+
if (filters.createdAfter && createdAt < new Date(filters.createdAfter)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (filters.createdBefore && createdAt > new Date(filters.createdBefore)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Modified date filter
|
|
88
|
+
if (filters.modifiedAfter || filters.modifiedBefore) {
|
|
89
|
+
if (!entity.lastModified) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const modifiedAt = new Date(entity.lastModified);
|
|
93
|
+
if (filters.modifiedAfter && modifiedAt < new Date(filters.modifiedAfter)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (filters.modifiedBefore && modifiedAt > new Date(filters.modifiedBefore)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Checks if any filters are actually specified.
|
|
104
|
+
* Used for early return optimization.
|
|
105
|
+
*
|
|
106
|
+
* @param filters - Filter criteria to check
|
|
107
|
+
* @returns true if at least one filter is active
|
|
108
|
+
*/
|
|
109
|
+
static hasActiveFilters(filters) {
|
|
110
|
+
return !!((filters.tags && filters.tags.length > 0) ||
|
|
111
|
+
filters.minImportance !== undefined ||
|
|
112
|
+
filters.maxImportance !== undefined ||
|
|
113
|
+
filters.entityType ||
|
|
114
|
+
filters.createdAfter ||
|
|
115
|
+
filters.createdBefore ||
|
|
116
|
+
filters.modifiedAfter ||
|
|
117
|
+
filters.modifiedBefore);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Validates and returns pagination parameters.
|
|
121
|
+
* Delegates to paginationUtils.validatePagination.
|
|
122
|
+
*
|
|
123
|
+
* @param offset - Starting position
|
|
124
|
+
* @param limit - Maximum results
|
|
125
|
+
* @returns Validated pagination object
|
|
126
|
+
*/
|
|
127
|
+
static validatePagination(offset = 0, limit) {
|
|
128
|
+
return validatePagination(offset, limit);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Applies pagination to a filtered result set.
|
|
132
|
+
*
|
|
133
|
+
* @param entities - Entities to paginate
|
|
134
|
+
* @param pagination - Validated pagination parameters
|
|
135
|
+
* @returns Paginated slice of entities
|
|
136
|
+
*/
|
|
137
|
+
static paginate(entities, pagination) {
|
|
138
|
+
return applyPagination(entities, pagination);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Convenience method to apply both filters and pagination in one call.
|
|
142
|
+
*
|
|
143
|
+
* @param entities - Entities to process
|
|
144
|
+
* @param filters - Filter criteria
|
|
145
|
+
* @param offset - Pagination offset
|
|
146
|
+
* @param limit - Pagination limit
|
|
147
|
+
* @returns Filtered and paginated entities
|
|
148
|
+
*/
|
|
149
|
+
static filterAndPaginate(entities, filters, offset = 0, limit) {
|
|
150
|
+
const filtered = this.applyFilters(entities, filters);
|
|
151
|
+
const pagination = this.validatePagination(offset, limit);
|
|
152
|
+
return this.paginate(filtered, pagination);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Applies tag filter only. Useful when other filters are handled separately.
|
|
156
|
+
*
|
|
157
|
+
* @param entities - Entities to filter
|
|
158
|
+
* @param tags - Tags to filter by
|
|
159
|
+
* @returns Filtered entities
|
|
160
|
+
*/
|
|
161
|
+
static filterByTags(entities, tags) {
|
|
162
|
+
if (!tags || tags.length === 0) {
|
|
163
|
+
return entities;
|
|
164
|
+
}
|
|
165
|
+
const normalizedTags = normalizeTags(tags);
|
|
166
|
+
return entities.filter(entity => {
|
|
167
|
+
if (!entity.tags || entity.tags.length === 0) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return hasMatchingTag(entity.tags, normalizedTags);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Applies importance filter only. Useful when other filters are handled separately.
|
|
175
|
+
*
|
|
176
|
+
* @param entities - Entities to filter
|
|
177
|
+
* @param minImportance - Minimum importance
|
|
178
|
+
* @param maxImportance - Maximum importance
|
|
179
|
+
* @returns Filtered entities
|
|
180
|
+
*/
|
|
181
|
+
static filterByImportance(entities, minImportance, maxImportance) {
|
|
182
|
+
if (minImportance === undefined && maxImportance === undefined) {
|
|
183
|
+
return entities;
|
|
184
|
+
}
|
|
185
|
+
return entities.filter(entity => isWithinImportanceRange(entity.importance, minImportance, maxImportance));
|
|
186
|
+
}
|
|
187
|
+
}
|