cry-search 1.0.5 → 1.0.7
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/CLAUDE.md +26 -0
- package/README.md +65 -0
- package/UNIVERSE.md +53 -0
- package/dist/common/SearchUniverse.d.ts +44 -1
- package/dist/common/SearchUniverse.d.ts.map +1 -1
- package/dist/common/numeric/searchAndRank.d.ts +90 -0
- package/dist/common/numeric/searchAndRank.d.ts.map +1 -0
- package/dist/index.cjs +197 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +197 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -315,6 +315,31 @@ Override match behavior per token at search time. Priority: query prefix > field
|
|
|
315
315
|
5. only processes items whose foreign key matches primaryId
|
|
316
316
|
6. returns { added: number, updated: number, removed: number }
|
|
317
317
|
|
|
318
|
+
13. searchAndRank(query, data, metadata, options): ScoredResult<T>[]
|
|
319
|
+
1. searchAndRank = searchAndRankNumeric (alias for the numeric implementation)
|
|
320
|
+
2. wraps searchInDataReturnIds + positional scoring over the candidate set
|
|
321
|
+
3. options: { primaryTextFn, exactKeysFn?, limit?, opts? }
|
|
322
|
+
- primaryTextFn: (item) => string (e.g. item.naziv) — used for positional scoring
|
|
323
|
+
- exactKeysFn: (item) => readonly string[] | undefined (e.g. crtnaKoda)
|
|
324
|
+
exact equality with raw query returns score 100_000
|
|
325
|
+
4. results sorted by score desc, locale-aware primary-text asc as tiebreaker
|
|
326
|
+
5. scoring components (additive):
|
|
327
|
+
- +100_000 exact key match
|
|
328
|
+
- +50 base for any subsequence-prefix match in primary text
|
|
329
|
+
- +100 all query tokens matched (in order)
|
|
330
|
+
- +50 first matched position is 0
|
|
331
|
+
- +80 perfect prefix (positions === [0..k-1])
|
|
332
|
+
- +30 * (matchedCount / span) density
|
|
333
|
+
- -0.1 * primaryText.length length penalty
|
|
334
|
+
6. returns 1 if candidate matched only via secondary field (no primary token match)
|
|
335
|
+
14. orderedTokenize(input: string): string[]
|
|
336
|
+
1. prepareStringForSearch pipeline minus the trailing .sort()
|
|
337
|
+
2. required for any positional / order-aware scoring
|
|
338
|
+
3. do NOT use these tokens for candidate retrieval (sorted lists required)
|
|
339
|
+
15. scoreOne(item, query, qTokens, primaryTextFn, exactKeys?): number
|
|
340
|
+
1. single-item scoring primitive — use to compose custom rankers
|
|
341
|
+
2. callers must pre-tokenize the query with orderedTokenize for correct positional semantics
|
|
342
|
+
|
|
318
343
|
## Token Registry Functions
|
|
319
344
|
|
|
320
345
|
1. tokens: string[] - global array (read-only)
|
|
@@ -373,6 +398,7 @@ src/
|
|
|
373
398
|
│ │ ├── updateSearchMetadata.ts
|
|
374
399
|
│ │ ├── updateSearchLinkedMetadata.ts
|
|
375
400
|
│ │ ├── searchInData.ts
|
|
401
|
+
│ │ ├── searchAndRank.ts # Ranked search with positional scoring
|
|
376
402
|
│ │ ├── syncSearchArrayMetadata.ts
|
|
377
403
|
│ │ └── AugmentedMetadata.ts
|
|
378
404
|
│ ├── string/ # Legacy string implementation
|
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A fast, memory-efficient search library for large datasets with support for toke
|
|
|
11
11
|
- **Flexible matching** - Per-field match modes and query prefixes for prefix, suffix, anywhere, exact, and negation
|
|
12
12
|
- **Search prefixes** - use `-word`, `--word`, or `~word` to exclude, `..word` for suffix, `=word` for exact match
|
|
13
13
|
- **Automatic normalization** - Handles diacritics, case, dates, and number formatting
|
|
14
|
+
- **Ranked search** - `searchAndRank` returns results scored by positional relevance (token order, contiguity, perfect-prefix, length)
|
|
14
15
|
- **[Universal search](./UNIVERSE.md)** - Search and update across linked collections (e.g., customers with their pets)
|
|
15
16
|
|
|
16
17
|
## Table of Contents
|
|
@@ -24,6 +25,7 @@ A fast, memory-efficient search library for large datasets with support for toke
|
|
|
24
25
|
- [SearchUniverse with Linked Collections](#searchuniverse-with-linked-collections)
|
|
25
26
|
- [Updating Existing Data](#updating-existing-data)
|
|
26
27
|
- [Augmented Metadata - Local Search Context](#augmented-metadata---local-search-context)
|
|
28
|
+
- [Ranked Search with Positional Scoring](#ranked-search-with-positional-scoring)
|
|
27
29
|
- [Architecture](#architecture)
|
|
28
30
|
- [Text Processing Pipeline](#text-processing-pipeline)
|
|
29
31
|
- [Metadata Building](#metadata-building)
|
|
@@ -278,6 +280,69 @@ augmented.clearAllAugmented(); // Reset to global state
|
|
|
278
280
|
- Documents with review status or approval state
|
|
279
281
|
- Items with computed scores or temporary categories
|
|
280
282
|
|
|
283
|
+
### Ranked Search with Positional Scoring
|
|
284
|
+
|
|
285
|
+
`searchInDataReturnIds` returns matching candidates but no ordering signal — useful when the caller needs raw IDs (e.g., for further filtering) but not when the UI needs the *most relevant* match first.
|
|
286
|
+
|
|
287
|
+
`searchAndRank` wraps candidate retrieval with positional relevance scoring. Each item gets a score that rewards in-order token matches, first-word matches, perfect-prefix matches, contiguous matches, and shorter primary text.
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import {
|
|
291
|
+
arrayToSearchableData,
|
|
292
|
+
createSearchArrayMetadata,
|
|
293
|
+
searchAndRank,
|
|
294
|
+
type SearchableObject,
|
|
295
|
+
} from "cry-search";
|
|
296
|
+
|
|
297
|
+
interface Product extends SearchableObject {
|
|
298
|
+
_id: string;
|
|
299
|
+
name: string;
|
|
300
|
+
barcodes?: string[];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const products: Product[] = [
|
|
304
|
+
{ _id: "1", name: "POTROŠNI MATERIAL" },
|
|
305
|
+
{ _id: "2", name: "POTROŠNI MATERIAL-SUCRALAN 1ML" },
|
|
306
|
+
{ _id: "3", name: "KONTROLNI PREGLED" },
|
|
307
|
+
{ _id: "4", name: "DERMATOLOŠKI KONTROLNI PREGLED" },
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const data = arrayToSearchableData(products);
|
|
311
|
+
const meta = createSearchArrayMetadata(data);
|
|
312
|
+
|
|
313
|
+
// Returns items sorted by relevance, NOT discovery order:
|
|
314
|
+
const results = searchAndRank("pot mat", data, meta, {
|
|
315
|
+
primaryTextFn: (p) => p.name,
|
|
316
|
+
exactKeysFn: (p) => p.barcodes, // optional: exact-key boost (e.g. barcode)
|
|
317
|
+
limit: 10,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// results[0].item.name === "POTROŠNI MATERIAL" (perfect prefix → +80)
|
|
321
|
+
// results[1].item.name === "POTROŠNI MATERIAL-SUCRALAN 1ML" (longer naziv)
|
|
322
|
+
|
|
323
|
+
// Each result: { id: Id, item: T, score: number }
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Scoring formula** (`scoreOne` is exported as a primitive — useful if you want to compose your own scoring):
|
|
327
|
+
|
|
328
|
+
| Component | Value | When |
|
|
329
|
+
|---|---|---|
|
|
330
|
+
| Exact-key match | **+100 000** | `query === any(exactKeys)` (e.g. barcode equality) |
|
|
331
|
+
| Base | +50 | Any in-order subsequence-prefix match of query tokens in `primaryText` |
|
|
332
|
+
| All tokens matched | +100 | Every query token had a target |
|
|
333
|
+
| First-word match | +50 | First match at position 0 |
|
|
334
|
+
| Perfect prefix | +80 | Matches at positions `[0,1,…,k-1]` (contiguous from start) |
|
|
335
|
+
| Density | +30 × (matched / span) | Rewards contiguous matches over gapped |
|
|
336
|
+
| Length penalty | -0.1 × `primaryText.length` | Weak tiebreaker — shorter wins |
|
|
337
|
+
| Secondary-field-only | 1 | Item matched (via search) but `primaryText` contains no query token |
|
|
338
|
+
|
|
339
|
+
**Lower-level primitives** are also exported, for building custom rankers:
|
|
340
|
+
|
|
341
|
+
- `orderedTokenize(input: string): string[]` — tokenize *without* alphabetic sort. `prepareStringForSearch` sorts tokens alphabetically (required for binary-search retrieval), which loses positional info. Use this for any order-aware scoring.
|
|
342
|
+
- `scoreOne(item, query, qTokens, primaryTextFn, exactKeys?): number` — score one item against a pre-tokenized query. Compose with your own retrieval or filtering.
|
|
343
|
+
|
|
344
|
+
**Important**: `orderedTokenize` output is NOT compatible with `searchInData...` retrieval functions — those require the sorted lists from `prepareStringForSearch`.
|
|
345
|
+
|
|
281
346
|
## Architecture
|
|
282
347
|
|
|
283
348
|
cry-search uses a two-phase approach: **build** metadata once, then **search** instantly.
|
package/UNIVERSE.md
CHANGED
|
@@ -620,6 +620,59 @@ SearchUniverse provides methods to update data and metadata after initial load.
|
|
|
620
620
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
621
621
|
```
|
|
622
622
|
|
|
623
|
+
### Sync Collection (Snapshot Diff)
|
|
624
|
+
|
|
625
|
+
```
|
|
626
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
627
|
+
│ universe.syncCollection(collectionName, newItems, options?) │
|
|
628
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
629
|
+
│ │
|
|
630
|
+
│ ⚠️ newItems MUST be the FULL snapshot, not a partial delta. │
|
|
631
|
+
│ Anything currently in the collection whose _id is missing from │
|
|
632
|
+
│ newItems will be REMOVED (data + metadata + linked index). │
|
|
633
|
+
│ Passing a partial list will wipe out everything else. │
|
|
634
|
+
│ │
|
|
635
|
+
│ newItems = [ │
|
|
636
|
+
│ { _id: "s1", name: "Krajnik", address: "Ljubljana" }, ← unchanged │
|
|
637
|
+
│ { _id: "s2", name: "Novak", address: "Celje" }, ← updated │
|
|
638
|
+
│ { _id: "s3", name: "Horvat", address: "Koper" }, ← added │
|
|
639
|
+
│ // s4 from previous state is missing → removed │
|
|
640
|
+
│ ] │
|
|
641
|
+
│ │
|
|
642
|
+
│ Behavior: │
|
|
643
|
+
│ 1. Items in collection but absent from newItems → removed │
|
|
644
|
+
│ (data + metadata + primaryToLinked all cleaned) │
|
|
645
|
+
│ 2. Items in both → tokens compared; metadata.set only if changed │
|
|
646
|
+
│ 3. New items → tokenized, added to metadata + primaryToLinked │
|
|
647
|
+
│ 4. _deleted/_blocked items in newItems → removed from metadata │
|
|
648
|
+
│ (kept in data, same convention as updateSearchMetadata) │
|
|
649
|
+
│ │
|
|
650
|
+
│ Options: │
|
|
651
|
+
│ skipUpdateCheck: true → don't re-tokenize existing items (faster) │
|
|
652
|
+
│ data refs still get updated │
|
|
653
|
+
│ │
|
|
654
|
+
│ Returns: SyncResult │
|
|
655
|
+
│ { added, updated, removed, addedIds[], updatedIds[], removedIds[] } │
|
|
656
|
+
│ │
|
|
657
|
+
│ Single shared tokensMap is used across the whole snapshot for efficiency. │
|
|
658
|
+
│ │
|
|
659
|
+
└─────────────────────────────────────────────────────────────────────────────┘
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
#### When to use which update method
|
|
663
|
+
|
|
664
|
+
| Scenario | Method |
|
|
665
|
+
|---------------------------------------------------|--------------------------------------------------------------|
|
|
666
|
+
| Full snapshot from server (periodic refresh) | `syncCollection(name, allItems)` |
|
|
667
|
+
| Known delta — add/update some items | `updateSearchMetadataBatch(name, changedItems)` |
|
|
668
|
+
| Known IDs to remove | `removeFromSearchMetadataBatch(name, ids)` |
|
|
669
|
+
| Mark item as soft-deleted but keep in data | `updateSearchMetadata(name, id, { ...item, _deleted: ... })` |
|
|
670
|
+
| Initial load / full reset | `loadCollection(name, items)` |
|
|
671
|
+
|
|
672
|
+
**Rule of thumb:** if you don't have the complete current state of the
|
|
673
|
+
collection, do NOT use `syncCollection` — use the batch methods instead.
|
|
674
|
+
They only touch the items you pass in and leave the rest alone.
|
|
675
|
+
|
|
623
676
|
### Clear Universe
|
|
624
677
|
|
|
625
678
|
```
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult } from '../types';
|
|
1
|
+
import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult, SyncResult, SyncOptions } from '../types';
|
|
2
2
|
export type { UniversalSearchOpts, CollectionConfig, UpdateResult, BatchUpdateResult } from '../types';
|
|
3
3
|
/**
|
|
4
4
|
* SearchUniverse manages multiple collections with their metadata and relationships.
|
|
@@ -154,6 +154,49 @@ export declare class SearchUniverse {
|
|
|
154
154
|
* @returns Number of items actually removed
|
|
155
155
|
*/
|
|
156
156
|
removeFromSearchMetadataBatch(collectionName: string, ids: Id[]): number;
|
|
157
|
+
/**
|
|
158
|
+
* Syncs a collection against a **full snapshot** of items.
|
|
159
|
+
*
|
|
160
|
+
* **IMPORTANT:** `newItems` MUST contain the entire desired state of the
|
|
161
|
+
* collection, not a partial delta. Any item currently in the collection
|
|
162
|
+
* whose `_id` is missing from `newItems` will be **completely removed**
|
|
163
|
+
* (data + metadata + linked indexes). Passing a partial list will wipe
|
|
164
|
+
* out everything else.
|
|
165
|
+
*
|
|
166
|
+
* Compares `newItems` to the current collection state and applies the diff:
|
|
167
|
+
* - items absent from `newItems` → removed
|
|
168
|
+
* - new IDs in `newItems` → added
|
|
169
|
+
* - existing IDs with changed tokens → updated
|
|
170
|
+
* - items marked `_deleted` or `_blocked` → removed from metadata
|
|
171
|
+
* (kept in `collection.data` for reference — same convention as
|
|
172
|
+
* {@link updateSearchMetadata})
|
|
173
|
+
*
|
|
174
|
+
* **When to use which method:**
|
|
175
|
+
* - Full snapshot from server (periodic refresh): `syncCollection`
|
|
176
|
+
* - Known delta of changes: {@link updateSearchMetadataBatch}
|
|
177
|
+
* - Known IDs to delete: {@link removeFromSearchMetadataBatch}
|
|
178
|
+
*
|
|
179
|
+
* For partial delta updates, prefer the batch methods — they leave untouched
|
|
180
|
+
* items unaffected.
|
|
181
|
+
*
|
|
182
|
+
* @typeParam T - Type of items in the collection
|
|
183
|
+
* @param collectionName - Name of the collection
|
|
184
|
+
* @param newItems - **Full** new dataset (complete snapshot, not a delta)
|
|
185
|
+
* @param options - Optional sync options (e.g. `skipUpdateCheck` to avoid re-tokenizing existing items)
|
|
186
|
+
* @returns SyncResult with counts and IDs of added/updated/removed items
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```typescript
|
|
190
|
+
* // Full refresh from server — pass ALL current products
|
|
191
|
+
* const fresh = await fetchAllProducts();
|
|
192
|
+
* const result = universe.syncCollection('products', fresh);
|
|
193
|
+
* // { added: 3, updated: 12, removed: 1, addedIds: [...], ... }
|
|
194
|
+
*
|
|
195
|
+
* // ⚠️ DO NOT do this — would delete all other products:
|
|
196
|
+
* // universe.syncCollection('products', [oneChangedProduct]);
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
syncCollection<T extends SearchableObject>(collectionName: string, newItems: T[], options?: SyncOptions): SyncResult;
|
|
157
200
|
/**
|
|
158
201
|
* Gets collection or throws an error if not found.
|
|
159
202
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SearchUniverse.d.ts","sourceRoot":"","sources":["../../src/common/SearchUniverse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,EAAE,EACF,qBAAqB,
|
|
1
|
+
{"version":3,"file":"SearchUniverse.d.ts","sourceRoot":"","sources":["../../src/common/SearchUniverse.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Id, NumericSearchMetadata, SearchableData, SearchableObject, SearchOpts } from "../../types";
|
|
2
|
+
/**
|
|
3
|
+
* Tokenizes a string preserving source order (no alphabetic sort).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors {@link prepareStringForSearch} pipeline minus the trailing `.sort()`,
|
|
6
|
+
* which is required for any positional / order-aware scoring.
|
|
7
|
+
*
|
|
8
|
+
* NOTE: do NOT use these tokens for cry-search candidate retrieval; those APIs
|
|
9
|
+
* assume sorted lists for efficient binary search.
|
|
10
|
+
*/
|
|
11
|
+
export declare function orderedTokenize(input: string): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Result of a scored / ranked search.
|
|
14
|
+
*/
|
|
15
|
+
export interface ScoredResult<T extends SearchableObject> {
|
|
16
|
+
/** Item id. */
|
|
17
|
+
id: Id;
|
|
18
|
+
/** The matched item. */
|
|
19
|
+
item: T;
|
|
20
|
+
/** Relevance score (higher = better). 100_000 for exact-key match. */
|
|
21
|
+
score: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Options controlling {@link searchAndRank} scoring and retrieval.
|
|
25
|
+
*/
|
|
26
|
+
export interface SearchAndRankOptions<T extends SearchableObject> {
|
|
27
|
+
/**
|
|
28
|
+
* Extracts the primary display text used for positional scoring.
|
|
29
|
+
* Typically the human-readable label (`item => item.naziv`).
|
|
30
|
+
*/
|
|
31
|
+
primaryTextFn: (item: T) => string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Optional: extracts values that must match the raw query string exactly
|
|
34
|
+
* for a top-priority boost (e.g. crtnaKoda for invoice items).
|
|
35
|
+
* Returns score 100_000 on exact equality.
|
|
36
|
+
*/
|
|
37
|
+
exactKeysFn?: (item: T) => readonly string[] | undefined;
|
|
38
|
+
/** Maximum number of ranked results to return. Default: Infinity. */
|
|
39
|
+
limit?: number;
|
|
40
|
+
/** Underlying search options passed to candidate retrieval. */
|
|
41
|
+
opts?: SearchOpts;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Computes a relevance score for one item against a query.
|
|
45
|
+
*
|
|
46
|
+
* Scoring components (additive):
|
|
47
|
+
* - **+100_000** if the raw `query` equals any `exactKeys` value (e.g. crtnaKoda).
|
|
48
|
+
* - **+50** base for any subsequence-prefix match of query tokens in primary text.
|
|
49
|
+
* - **+100** if ALL query tokens match (in order) in primary text.
|
|
50
|
+
* - **+50** if first matched position is 0 (primary text starts with first query token).
|
|
51
|
+
* - **+80** if matched positions form a contiguous prefix `[0..k-1]` (perfect prefix).
|
|
52
|
+
* - **+30 * (matchedCount / span)** density bonus (rewards contiguous matches).
|
|
53
|
+
* - **-0.1 * primaryText.length** length penalty (weak tiebreaker — shorter wins).
|
|
54
|
+
*
|
|
55
|
+
* Returns **1** if the item was retrieved as a candidate but its primary text
|
|
56
|
+
* contains no query token (match came from a secondary field).
|
|
57
|
+
*
|
|
58
|
+
* Returns **0** if the query is empty.
|
|
59
|
+
*/
|
|
60
|
+
export declare function scoreOne<T extends SearchableObject>(item: T, query: string, qTokens: string[], primaryTextFn: (item: T) => string | undefined, exactKeys?: readonly string[] | undefined): number;
|
|
61
|
+
/**
|
|
62
|
+
* Searches with positional relevance scoring and returns ranked results.
|
|
63
|
+
*
|
|
64
|
+
* Combines candidate retrieval ({@link searchInDataReturnIdsNumeric}, sorted-token
|
|
65
|
+
* AND-match) with a positional scoring layer that rewards in-order matches,
|
|
66
|
+
* first-word matches, perfect prefixes, dense (contiguous) matches, and shorter
|
|
67
|
+
* primary text.
|
|
68
|
+
*
|
|
69
|
+
* Results are sorted by score descending, with a stable tiebreaker on
|
|
70
|
+
* locale-aware primary-text comparison.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam T - Item type (must extend SearchableObject)
|
|
73
|
+
* @param query - Raw query string (tokenized internally)
|
|
74
|
+
* @param data - SearchableData built via {@link arrayToSearchableData}
|
|
75
|
+
* @param metadata - NumericSearchMetadata built via {@link createSearchArrayMetadata}
|
|
76
|
+
* @param options - Scoring + retrieval options
|
|
77
|
+
* @returns Array of `{ id, item, score }` ordered by score descending
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const results = searchAndRank("pot mat", data, meta, {
|
|
82
|
+
* primaryTextFn: (a) => a.naziv,
|
|
83
|
+
* exactKeysFn: (a) => a.crtneKode,
|
|
84
|
+
* limit: 20,
|
|
85
|
+
* });
|
|
86
|
+
* // results[0].item.naziv === "POTROŠNI MATERIAL"
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export declare function searchAndRankNumeric<T extends SearchableObject>(query: string, data: SearchableData<T>, metadata: NumericSearchMetadata, options: SearchAndRankOptions<T>): ScoredResult<T>[];
|
|
90
|
+
//# sourceMappingURL=searchAndRank.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchAndRank.d.ts","sourceRoot":"","sources":["../../../src/common/numeric/searchAndRank.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,EAAE,EACF,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACX,MAAM,aAAa,CAAC;AAOrB;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAKvD;AACD;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,gBAAgB;IACtD,eAAe;IACf,EAAE,EAAE,EAAE,CAAC;IACP,wBAAwB;IACxB,IAAI,EAAE,CAAC,CAAC;IACR,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,gBAAgB;IAC9D;;;OAGG;IACH,aAAa,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAAG,SAAS,CAAC;IAC/C;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC;IACzD,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,IAAI,CAAC,EAAE,UAAU,CAAC;CACnB;AACD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,gBAAgB,EACjD,IAAI,EAAE,CAAC,EACP,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,EACjB,aAAa,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,MAAM,GAAG,SAAS,EAC9C,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GACxC,MAAM,CAyCR;AACD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,gBAAgB,EAC7D,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,QAAQ,EAAE,qBAAqB,EAC/B,OAAO,EAAE,oBAAoB,CAAC,CAAC,CAAC,GAC/B,YAAY,CAAC,CAAC,CAAC,EAAE,CA0BnB"}
|
package/dist/index.cjs
CHANGED
|
@@ -43,6 +43,9 @@ __export(exports_src, {
|
|
|
43
43
|
searchInDataReturnObjects: () => searchInDataReturnObjectsNumeric,
|
|
44
44
|
searchInDataReturnIds: () => searchInDataReturnIdsNumeric,
|
|
45
45
|
searchInData: () => searchInDataReturnIdsNumeric,
|
|
46
|
+
searchAndRankNumeric: () => searchAndRankNumeric,
|
|
47
|
+
searchAndRank: () => searchAndRankNumeric,
|
|
48
|
+
scoreOne: () => scoreOne,
|
|
46
49
|
sanitiseString: () => sanitiseString,
|
|
47
50
|
resetTokenRegistry: () => resetTokenRegistry,
|
|
48
51
|
removePrimaryItem: () => removePrimaryItemNumeric,
|
|
@@ -54,6 +57,7 @@ __export(exports_src, {
|
|
|
54
57
|
prepareStringForSearch: () => prepareStringForSearch,
|
|
55
58
|
prepareQueryTokens: () => prepareQueryTokens,
|
|
56
59
|
prepareObjectForSearch: () => prepareObjectForSearchNumeric,
|
|
60
|
+
orderedTokenize: () => orderedTokenize,
|
|
57
61
|
normalizeDates: () => normalizeDates,
|
|
58
62
|
matchTokenInSortedListNumeric: () => matchTokenInSortedListNumeric,
|
|
59
63
|
matchPreparedTokenInSortedList: () => matchPreparedTokenInSortedList,
|
|
@@ -1331,7 +1335,93 @@ function syncLinkedItemsForPrimaryNumeric(linked, primaryId, newLinkedItems, for
|
|
|
1331
1335
|
clearTokensMap(tokensMap);
|
|
1332
1336
|
return result;
|
|
1333
1337
|
}
|
|
1338
|
+
// src/common/numeric/searchAndRank.ts
|
|
1339
|
+
function orderedTokenize(input) {
|
|
1340
|
+
return tokenize(sanitiseString(preprocessString(normalizeDates(input))), DEFAULT_TOKENIZE_CONFIG);
|
|
1341
|
+
}
|
|
1342
|
+
function scoreOne(item, query, qTokens, primaryTextFn, exactKeys) {
|
|
1343
|
+
if (exactKeys && exactKeys.length > 0) {
|
|
1344
|
+
for (let i = 0;i < exactKeys.length; i++) {
|
|
1345
|
+
if (exactKeys[i] === query)
|
|
1346
|
+
return 1e5;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (qTokens.length === 0)
|
|
1350
|
+
return 0;
|
|
1351
|
+
const primaryText = primaryTextFn(item) || "";
|
|
1352
|
+
const itemTokens = orderedTokenize(primaryText);
|
|
1353
|
+
if (itemTokens.length === 0)
|
|
1354
|
+
return 1;
|
|
1355
|
+
let qi = 0;
|
|
1356
|
+
const positions = [];
|
|
1357
|
+
for (let i = 0;i < itemTokens.length; i++) {
|
|
1358
|
+
if (qi < qTokens.length && itemTokens[i].startsWith(qTokens[qi])) {
|
|
1359
|
+
positions.push(i);
|
|
1360
|
+
qi++;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (qi === 0)
|
|
1364
|
+
return 1;
|
|
1365
|
+
let score = 50;
|
|
1366
|
+
if (qi === qTokens.length)
|
|
1367
|
+
score += 100;
|
|
1368
|
+
if (positions[0] === 0)
|
|
1369
|
+
score += 50;
|
|
1370
|
+
let perfect = qi === qTokens.length;
|
|
1371
|
+
if (perfect) {
|
|
1372
|
+
for (let i = 0;i < positions.length; i++) {
|
|
1373
|
+
if (positions[i] !== i) {
|
|
1374
|
+
perfect = false;
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (perfect)
|
|
1380
|
+
score += 80;
|
|
1381
|
+
if (positions.length >= 2) {
|
|
1382
|
+
const span = positions[positions.length - 1] - positions[0] + 1;
|
|
1383
|
+
score += positions.length / span * 30;
|
|
1384
|
+
}
|
|
1385
|
+
score -= primaryText.length * 0.1;
|
|
1386
|
+
return score;
|
|
1387
|
+
}
|
|
1388
|
+
function searchAndRankNumeric(query, data, metadata, options) {
|
|
1389
|
+
const { primaryTextFn, exactKeysFn, limit, opts } = options;
|
|
1390
|
+
const ids = searchInDataReturnIdsNumeric(query, metadata, opts);
|
|
1391
|
+
if (ids.length === 0)
|
|
1392
|
+
return [];
|
|
1393
|
+
const qTokens = orderedTokenize(query);
|
|
1394
|
+
const scored = [];
|
|
1395
|
+
for (const id of ids) {
|
|
1396
|
+
const item = data.get(id);
|
|
1397
|
+
if (!item)
|
|
1398
|
+
continue;
|
|
1399
|
+
const exactKeys = exactKeysFn?.(item);
|
|
1400
|
+
const score = scoreOne(item, query, qTokens, primaryTextFn, exactKeys);
|
|
1401
|
+
scored.push({ id, item, score });
|
|
1402
|
+
}
|
|
1403
|
+
scored.sort((x, y) => {
|
|
1404
|
+
if (y.score !== x.score)
|
|
1405
|
+
return y.score - x.score;
|
|
1406
|
+
const xText = primaryTextFn(x.item) || "";
|
|
1407
|
+
const yText = primaryTextFn(y.item) || "";
|
|
1408
|
+
return xText.localeCompare(yText);
|
|
1409
|
+
});
|
|
1410
|
+
if (limit === undefined || limit === Infinity || limit >= scored.length)
|
|
1411
|
+
return scored;
|
|
1412
|
+
return scored.slice(0, limit);
|
|
1413
|
+
}
|
|
1334
1414
|
// src/common/SearchUniverse.ts
|
|
1415
|
+
function tokensEqual3(a, b) {
|
|
1416
|
+
if (a.length !== b.length)
|
|
1417
|
+
return false;
|
|
1418
|
+
for (let i = 0;i < a.length; i++) {
|
|
1419
|
+
if (a[i] !== b[i])
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1335
1425
|
class SearchUniverse {
|
|
1336
1426
|
collections = new Map;
|
|
1337
1427
|
registerCollection(name, config = {}) {
|
|
@@ -1535,6 +1625,113 @@ class SearchUniverse {
|
|
|
1535
1625
|
}
|
|
1536
1626
|
return removed;
|
|
1537
1627
|
}
|
|
1628
|
+
syncCollection(collectionName, newItems, options) {
|
|
1629
|
+
const collection = this.getCollectionOrThrow(collectionName);
|
|
1630
|
+
const result = {
|
|
1631
|
+
added: 0,
|
|
1632
|
+
updated: 0,
|
|
1633
|
+
removed: 0,
|
|
1634
|
+
addedIds: [],
|
|
1635
|
+
updatedIds: [],
|
|
1636
|
+
removedIds: []
|
|
1637
|
+
};
|
|
1638
|
+
const skipUpdateCheck = options?.skipUpdateCheck ?? false;
|
|
1639
|
+
const fkGetter = collection.config.linkedTo ? collection.config.linkedTo.foreignKeyGetter : undefined;
|
|
1640
|
+
const newIds = new Set;
|
|
1641
|
+
for (const item of newItems)
|
|
1642
|
+
newIds.add(item._id);
|
|
1643
|
+
const toRemove = [];
|
|
1644
|
+
for (const id of collection.data.keys()) {
|
|
1645
|
+
if (!newIds.has(id))
|
|
1646
|
+
toRemove.push(id);
|
|
1647
|
+
}
|
|
1648
|
+
for (const id of toRemove) {
|
|
1649
|
+
const existing = collection.data.get(id);
|
|
1650
|
+
if (fkGetter && collection.primaryToLinked) {
|
|
1651
|
+
const fk = fkGetter(existing);
|
|
1652
|
+
if (fk) {
|
|
1653
|
+
const list = collection.primaryToLinked.get(fk);
|
|
1654
|
+
if (list) {
|
|
1655
|
+
const idx = list.indexOf(id);
|
|
1656
|
+
if (idx !== -1) {
|
|
1657
|
+
list.splice(idx, 1);
|
|
1658
|
+
if (list.length === 0)
|
|
1659
|
+
collection.primaryToLinked.delete(fk);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
const hadMetadata = collection.metadata.has(id);
|
|
1665
|
+
collection.data.delete(id);
|
|
1666
|
+
collection.metadata.delete(id);
|
|
1667
|
+
if (hadMetadata) {
|
|
1668
|
+
result.removed++;
|
|
1669
|
+
result.removedIds.push(id);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
const tokensMap = createTokensMap();
|
|
1673
|
+
try {
|
|
1674
|
+
for (const item of newItems) {
|
|
1675
|
+
const id = item._id;
|
|
1676
|
+
const existing = collection.data.get(id);
|
|
1677
|
+
const hadMetadata = collection.metadata.has(id);
|
|
1678
|
+
const shouldTokenize = !existing || !skipUpdateCheck;
|
|
1679
|
+
let newTokens;
|
|
1680
|
+
if (shouldTokenize) {
|
|
1681
|
+
newTokens = prepareObjectForSearchNumeric(item, collection.config.spec, tokensMap);
|
|
1682
|
+
}
|
|
1683
|
+
if (fkGetter && collection.primaryToLinked) {
|
|
1684
|
+
const newFk = fkGetter(item);
|
|
1685
|
+
const oldFk = existing ? fkGetter(existing) : undefined;
|
|
1686
|
+
const wasInIndex = hadMetadata;
|
|
1687
|
+
const shouldBeInIndex = !!newFk && !item._deleted && !item._blocked;
|
|
1688
|
+
if (oldFk !== newFk || wasInIndex !== shouldBeInIndex) {
|
|
1689
|
+
if (oldFk && wasInIndex) {
|
|
1690
|
+
const list = collection.primaryToLinked.get(oldFk);
|
|
1691
|
+
if (list) {
|
|
1692
|
+
const idx = list.indexOf(id);
|
|
1693
|
+
if (idx !== -1) {
|
|
1694
|
+
list.splice(idx, 1);
|
|
1695
|
+
if (list.length === 0)
|
|
1696
|
+
collection.primaryToLinked.delete(oldFk);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
if (shouldBeInIndex && newFk) {
|
|
1701
|
+
const list = collection.primaryToLinked.get(newFk) || [];
|
|
1702
|
+
if (!list.includes(id))
|
|
1703
|
+
list.push(id);
|
|
1704
|
+
collection.primaryToLinked.set(newFk, list);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
collection.data.set(id, item);
|
|
1709
|
+
if (!shouldTokenize)
|
|
1710
|
+
continue;
|
|
1711
|
+
if (newTokens === undefined) {
|
|
1712
|
+
if (hadMetadata) {
|
|
1713
|
+
collection.metadata.delete(id);
|
|
1714
|
+
result.removed++;
|
|
1715
|
+
result.removedIds.push(id);
|
|
1716
|
+
}
|
|
1717
|
+
} else if (!hadMetadata) {
|
|
1718
|
+
collection.metadata.set(id, newTokens);
|
|
1719
|
+
result.added++;
|
|
1720
|
+
result.addedIds.push(id);
|
|
1721
|
+
} else {
|
|
1722
|
+
const existingTokens = collection.metadata.get(id);
|
|
1723
|
+
if (!tokensEqual3(existingTokens, newTokens)) {
|
|
1724
|
+
collection.metadata.set(id, newTokens);
|
|
1725
|
+
result.updated++;
|
|
1726
|
+
result.updatedIds.push(id);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
} finally {
|
|
1731
|
+
clearTokensMap(tokensMap);
|
|
1732
|
+
}
|
|
1733
|
+
return result;
|
|
1734
|
+
}
|
|
1538
1735
|
getCollectionOrThrow(name) {
|
|
1539
1736
|
const collection = this.collections.get(name);
|
|
1540
1737
|
if (!collection) {
|
package/dist/index.d.cts
CHANGED
|
@@ -22,5 +22,6 @@ export { syncSearchArrayMetadataNumeric as syncSearchArrayMetadata, syncSearchAr
|
|
|
22
22
|
export { upsertPrimaryItemNumeric as upsertPrimaryItem, removePrimaryItemNumeric as removePrimaryItem, upsertLinkedItemNumeric as upsertLinkedItem, removeLinkedItemNumeric as removeLinkedItem, syncLinkedItemsForPrimaryNumeric as syncLinkedItemsForPrimary, } from './common/numeric/updateSearchLinkedMetadata';
|
|
23
23
|
export { matchTokenInSortedListNumeric, allTokensMatchNumeric, binarySearchUint32, prepareQueryTokens, matchPreparedTokenInSortedList, allPreparedTokensMatch, type PreparedQueryToken, } from './utils/matchTokenNumeric';
|
|
24
24
|
export { isDeletedOrBlocked } from './common/string/createSearchArrayMetadata';
|
|
25
|
+
export { searchAndRankNumeric as searchAndRank, searchAndRankNumeric, scoreOne, orderedTokenize, type ScoredResult, type SearchAndRankOptions, } from './common/numeric/searchAndRank';
|
|
25
26
|
export { SearchUniverse } from './common/SearchUniverse';
|
|
26
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts
CHANGED
|
@@ -22,5 +22,6 @@ export { syncSearchArrayMetadataNumeric as syncSearchArrayMetadata, syncSearchAr
|
|
|
22
22
|
export { upsertPrimaryItemNumeric as upsertPrimaryItem, removePrimaryItemNumeric as removePrimaryItem, upsertLinkedItemNumeric as upsertLinkedItem, removeLinkedItemNumeric as removeLinkedItem, syncLinkedItemsForPrimaryNumeric as syncLinkedItemsForPrimary, } from './common/numeric/updateSearchLinkedMetadata';
|
|
23
23
|
export { matchTokenInSortedListNumeric, allTokensMatchNumeric, binarySearchUint32, prepareQueryTokens, matchPreparedTokenInSortedList, allPreparedTokensMatch, type PreparedQueryToken, } from './utils/matchTokenNumeric';
|
|
24
24
|
export { isDeletedOrBlocked } from './common/string/createSearchArrayMetadata';
|
|
25
|
+
export { searchAndRankNumeric as searchAndRank, searchAndRankNumeric, scoreOne, orderedTokenize, type ScoredResult, type SearchAndRankOptions, } from './common/numeric/searchAndRank';
|
|
25
26
|
export { SearchUniverse } from './common/SearchUniverse';
|
|
26
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,YAAY,EAEV,EAAE,EACF,WAAW,EACX,qBAAqB,EACrB,cAAc,EAEd,YAAY,EACZ,sBAAsB,EACtB,qBAAqB,EAErB,gBAAgB,EAChB,SAAS,EACT,oBAAoB,EAEpB,UAAU,EAEV,oBAAoB,EACpB,4BAA4B,EAC5B,2BAA2B,EAC3B,kBAAkB,EAClB,0BAA0B,EAE1B,oBAAoB,EAEpB,UAAU,EACV,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,6BAA6B,EAE7B,mBAAmB,EACnB,gBAAgB,EAChB,YAAY,EACZ,iBAAiB,EAEjB,cAAc,IAAI,oBAAoB,EACtC,mBAAmB,EACnB,cAAc,EACd,kBAAkB,GACnB,MAAM,SAAS,CAAC;AAGjB,YAAY,EAAE,qBAAqB,IAAI,cAAc,EAAE,MAAM,SAAS,CAAC;AAGvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAG9C,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAKvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,gCAAgC,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAKxD,OAAO,EACL,MAAM,EACN,aAAa,EACb,YAAY,EACZ,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,eAAe,EACf,cAAc,EACd,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAO/B,OAAO,EACL,gCAAgC,IAAI,yBAAyB,EAC7D,6BAA6B,IAAI,sBAAsB,EACvD,mBAAmB,EACnB,aAAa,EACb,eAAe,EACf,aAAa,EACb,eAAe,GAChB,MAAM,4CAA4C,CAAC;AAEpD,OAAO,EACL,iCAAiC,IAAI,0BAA0B,EAC/D,KAAK,mCAAmC,GACzC,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,2BAA2B,IAAI,oBAAoB,EACnD,gCAAgC,IAAI,yBAAyB,EAC7D,+BAA+B,IAAI,wBAAwB,EAC3D,oCAAoC,IAAI,6BAA6B,GACtE,MAAM,uCAAuC,CAAC;AAG/C,OAAO,EACL,4BAA4B,IAAI,YAAY,EAC5C,4BAA4B,IAAI,qBAAqB,EACrD,gCAAgC,IAAI,yBAAyB,EAC7D,qCAAqC,IAAI,qBAAqB,GAC/D,MAAM,+BAA+B,CAAC;AAGvC,OAAO,EACL,yBAAyB,EACzB,+BAA+B,GAChC,MAAM,qCAAqC,CAAC;AAG7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,oCAAoC,CAAC;AAGvE,OAAO,EACL,8BAA8B,IAAI,uBAAuB,EACzD,0CAA0C,IAAI,mCAAmC,GAClF,MAAM,0CAA0C,CAAC;AAGlD,OAAO,EACL,wBAAwB,IAAI,iBAAiB,EAC7C,wBAAwB,IAAI,iBAAiB,EAC7C,uBAAuB,IAAI,gBAAgB,EAC3C,uBAAuB,IAAI,gBAAgB,EAC3C,gCAAgC,IAAI,yBAAyB,GAC9D,MAAM,6CAA6C,CAAC;AAGrD,OAAO,EACL,6BAA6B,EAC7B,qBAAqB,EACrB,kBAAkB,EAElB,kBAAkB,EAClB,8BAA8B,EAC9B,sBAAsB,EACtB,KAAK,kBAAkB,GACxB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,kBAAkB,EAAE,MAAM,2CAA2C,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1246,7 +1246,93 @@ function syncLinkedItemsForPrimaryNumeric(linked, primaryId, newLinkedItems, for
|
|
|
1246
1246
|
clearTokensMap(tokensMap);
|
|
1247
1247
|
return result;
|
|
1248
1248
|
}
|
|
1249
|
+
// src/common/numeric/searchAndRank.ts
|
|
1250
|
+
function orderedTokenize(input) {
|
|
1251
|
+
return tokenize(sanitiseString(preprocessString(normalizeDates(input))), DEFAULT_TOKENIZE_CONFIG);
|
|
1252
|
+
}
|
|
1253
|
+
function scoreOne(item, query, qTokens, primaryTextFn, exactKeys) {
|
|
1254
|
+
if (exactKeys && exactKeys.length > 0) {
|
|
1255
|
+
for (let i = 0;i < exactKeys.length; i++) {
|
|
1256
|
+
if (exactKeys[i] === query)
|
|
1257
|
+
return 1e5;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
if (qTokens.length === 0)
|
|
1261
|
+
return 0;
|
|
1262
|
+
const primaryText = primaryTextFn(item) || "";
|
|
1263
|
+
const itemTokens = orderedTokenize(primaryText);
|
|
1264
|
+
if (itemTokens.length === 0)
|
|
1265
|
+
return 1;
|
|
1266
|
+
let qi = 0;
|
|
1267
|
+
const positions = [];
|
|
1268
|
+
for (let i = 0;i < itemTokens.length; i++) {
|
|
1269
|
+
if (qi < qTokens.length && itemTokens[i].startsWith(qTokens[qi])) {
|
|
1270
|
+
positions.push(i);
|
|
1271
|
+
qi++;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (qi === 0)
|
|
1275
|
+
return 1;
|
|
1276
|
+
let score = 50;
|
|
1277
|
+
if (qi === qTokens.length)
|
|
1278
|
+
score += 100;
|
|
1279
|
+
if (positions[0] === 0)
|
|
1280
|
+
score += 50;
|
|
1281
|
+
let perfect = qi === qTokens.length;
|
|
1282
|
+
if (perfect) {
|
|
1283
|
+
for (let i = 0;i < positions.length; i++) {
|
|
1284
|
+
if (positions[i] !== i) {
|
|
1285
|
+
perfect = false;
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (perfect)
|
|
1291
|
+
score += 80;
|
|
1292
|
+
if (positions.length >= 2) {
|
|
1293
|
+
const span = positions[positions.length - 1] - positions[0] + 1;
|
|
1294
|
+
score += positions.length / span * 30;
|
|
1295
|
+
}
|
|
1296
|
+
score -= primaryText.length * 0.1;
|
|
1297
|
+
return score;
|
|
1298
|
+
}
|
|
1299
|
+
function searchAndRankNumeric(query, data, metadata, options) {
|
|
1300
|
+
const { primaryTextFn, exactKeysFn, limit, opts } = options;
|
|
1301
|
+
const ids = searchInDataReturnIdsNumeric(query, metadata, opts);
|
|
1302
|
+
if (ids.length === 0)
|
|
1303
|
+
return [];
|
|
1304
|
+
const qTokens = orderedTokenize(query);
|
|
1305
|
+
const scored = [];
|
|
1306
|
+
for (const id of ids) {
|
|
1307
|
+
const item = data.get(id);
|
|
1308
|
+
if (!item)
|
|
1309
|
+
continue;
|
|
1310
|
+
const exactKeys = exactKeysFn?.(item);
|
|
1311
|
+
const score = scoreOne(item, query, qTokens, primaryTextFn, exactKeys);
|
|
1312
|
+
scored.push({ id, item, score });
|
|
1313
|
+
}
|
|
1314
|
+
scored.sort((x, y) => {
|
|
1315
|
+
if (y.score !== x.score)
|
|
1316
|
+
return y.score - x.score;
|
|
1317
|
+
const xText = primaryTextFn(x.item) || "";
|
|
1318
|
+
const yText = primaryTextFn(y.item) || "";
|
|
1319
|
+
return xText.localeCompare(yText);
|
|
1320
|
+
});
|
|
1321
|
+
if (limit === undefined || limit === Infinity || limit >= scored.length)
|
|
1322
|
+
return scored;
|
|
1323
|
+
return scored.slice(0, limit);
|
|
1324
|
+
}
|
|
1249
1325
|
// src/common/SearchUniverse.ts
|
|
1326
|
+
function tokensEqual3(a, b) {
|
|
1327
|
+
if (a.length !== b.length)
|
|
1328
|
+
return false;
|
|
1329
|
+
for (let i = 0;i < a.length; i++) {
|
|
1330
|
+
if (a[i] !== b[i])
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1250
1336
|
class SearchUniverse {
|
|
1251
1337
|
collections = new Map;
|
|
1252
1338
|
registerCollection(name, config = {}) {
|
|
@@ -1450,6 +1536,113 @@ class SearchUniverse {
|
|
|
1450
1536
|
}
|
|
1451
1537
|
return removed;
|
|
1452
1538
|
}
|
|
1539
|
+
syncCollection(collectionName, newItems, options) {
|
|
1540
|
+
const collection = this.getCollectionOrThrow(collectionName);
|
|
1541
|
+
const result = {
|
|
1542
|
+
added: 0,
|
|
1543
|
+
updated: 0,
|
|
1544
|
+
removed: 0,
|
|
1545
|
+
addedIds: [],
|
|
1546
|
+
updatedIds: [],
|
|
1547
|
+
removedIds: []
|
|
1548
|
+
};
|
|
1549
|
+
const skipUpdateCheck = options?.skipUpdateCheck ?? false;
|
|
1550
|
+
const fkGetter = collection.config.linkedTo ? collection.config.linkedTo.foreignKeyGetter : undefined;
|
|
1551
|
+
const newIds = new Set;
|
|
1552
|
+
for (const item of newItems)
|
|
1553
|
+
newIds.add(item._id);
|
|
1554
|
+
const toRemove = [];
|
|
1555
|
+
for (const id of collection.data.keys()) {
|
|
1556
|
+
if (!newIds.has(id))
|
|
1557
|
+
toRemove.push(id);
|
|
1558
|
+
}
|
|
1559
|
+
for (const id of toRemove) {
|
|
1560
|
+
const existing = collection.data.get(id);
|
|
1561
|
+
if (fkGetter && collection.primaryToLinked) {
|
|
1562
|
+
const fk = fkGetter(existing);
|
|
1563
|
+
if (fk) {
|
|
1564
|
+
const list = collection.primaryToLinked.get(fk);
|
|
1565
|
+
if (list) {
|
|
1566
|
+
const idx = list.indexOf(id);
|
|
1567
|
+
if (idx !== -1) {
|
|
1568
|
+
list.splice(idx, 1);
|
|
1569
|
+
if (list.length === 0)
|
|
1570
|
+
collection.primaryToLinked.delete(fk);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
const hadMetadata = collection.metadata.has(id);
|
|
1576
|
+
collection.data.delete(id);
|
|
1577
|
+
collection.metadata.delete(id);
|
|
1578
|
+
if (hadMetadata) {
|
|
1579
|
+
result.removed++;
|
|
1580
|
+
result.removedIds.push(id);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
const tokensMap = createTokensMap();
|
|
1584
|
+
try {
|
|
1585
|
+
for (const item of newItems) {
|
|
1586
|
+
const id = item._id;
|
|
1587
|
+
const existing = collection.data.get(id);
|
|
1588
|
+
const hadMetadata = collection.metadata.has(id);
|
|
1589
|
+
const shouldTokenize = !existing || !skipUpdateCheck;
|
|
1590
|
+
let newTokens;
|
|
1591
|
+
if (shouldTokenize) {
|
|
1592
|
+
newTokens = prepareObjectForSearchNumeric(item, collection.config.spec, tokensMap);
|
|
1593
|
+
}
|
|
1594
|
+
if (fkGetter && collection.primaryToLinked) {
|
|
1595
|
+
const newFk = fkGetter(item);
|
|
1596
|
+
const oldFk = existing ? fkGetter(existing) : undefined;
|
|
1597
|
+
const wasInIndex = hadMetadata;
|
|
1598
|
+
const shouldBeInIndex = !!newFk && !item._deleted && !item._blocked;
|
|
1599
|
+
if (oldFk !== newFk || wasInIndex !== shouldBeInIndex) {
|
|
1600
|
+
if (oldFk && wasInIndex) {
|
|
1601
|
+
const list = collection.primaryToLinked.get(oldFk);
|
|
1602
|
+
if (list) {
|
|
1603
|
+
const idx = list.indexOf(id);
|
|
1604
|
+
if (idx !== -1) {
|
|
1605
|
+
list.splice(idx, 1);
|
|
1606
|
+
if (list.length === 0)
|
|
1607
|
+
collection.primaryToLinked.delete(oldFk);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (shouldBeInIndex && newFk) {
|
|
1612
|
+
const list = collection.primaryToLinked.get(newFk) || [];
|
|
1613
|
+
if (!list.includes(id))
|
|
1614
|
+
list.push(id);
|
|
1615
|
+
collection.primaryToLinked.set(newFk, list);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
collection.data.set(id, item);
|
|
1620
|
+
if (!shouldTokenize)
|
|
1621
|
+
continue;
|
|
1622
|
+
if (newTokens === undefined) {
|
|
1623
|
+
if (hadMetadata) {
|
|
1624
|
+
collection.metadata.delete(id);
|
|
1625
|
+
result.removed++;
|
|
1626
|
+
result.removedIds.push(id);
|
|
1627
|
+
}
|
|
1628
|
+
} else if (!hadMetadata) {
|
|
1629
|
+
collection.metadata.set(id, newTokens);
|
|
1630
|
+
result.added++;
|
|
1631
|
+
result.addedIds.push(id);
|
|
1632
|
+
} else {
|
|
1633
|
+
const existingTokens = collection.metadata.get(id);
|
|
1634
|
+
if (!tokensEqual3(existingTokens, newTokens)) {
|
|
1635
|
+
collection.metadata.set(id, newTokens);
|
|
1636
|
+
result.updated++;
|
|
1637
|
+
result.updatedIds.push(id);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
} finally {
|
|
1642
|
+
clearTokensMap(tokensMap);
|
|
1643
|
+
}
|
|
1644
|
+
return result;
|
|
1645
|
+
}
|
|
1453
1646
|
getCollectionOrThrow(name) {
|
|
1454
1647
|
const collection = this.collections.get(name);
|
|
1455
1648
|
if (!collection) {
|
|
@@ -1541,6 +1734,9 @@ export {
|
|
|
1541
1734
|
searchInDataReturnObjectsNumeric as searchInDataReturnObjects,
|
|
1542
1735
|
searchInDataReturnIdsNumeric as searchInDataReturnIds,
|
|
1543
1736
|
searchInDataReturnIdsNumeric as searchInData,
|
|
1737
|
+
searchAndRankNumeric,
|
|
1738
|
+
searchAndRankNumeric as searchAndRank,
|
|
1739
|
+
scoreOne,
|
|
1544
1740
|
sanitiseString,
|
|
1545
1741
|
resetTokenRegistry,
|
|
1546
1742
|
removePrimaryItemNumeric as removePrimaryItem,
|
|
@@ -1552,6 +1748,7 @@ export {
|
|
|
1552
1748
|
prepareStringForSearch,
|
|
1553
1749
|
prepareQueryTokens,
|
|
1554
1750
|
prepareObjectForSearchNumeric as prepareObjectForSearch,
|
|
1751
|
+
orderedTokenize,
|
|
1555
1752
|
normalizeDates,
|
|
1556
1753
|
matchTokenInSortedListNumeric,
|
|
1557
1754
|
matchPreparedTokenInSortedList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cry-search",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "A fast, memory-efficient search library for large datasets with support for tokenized matching, linked collections, and per-field match modes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|