@drakkar.software/octospaces-sdk 0.4.1 → 0.4.3
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/index.d.ts +144 -1
- package/dist/index.js +164 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/types.ts +3 -2
- package/src/index.ts +20 -0
- package/src/utils/invite-preview.test.ts +169 -0
- package/src/utils/invite-preview.ts +101 -0
- package/src/utils/live-sync-bus.test.ts +116 -0
- package/src/utils/live-sync-bus.ts +71 -0
- package/src/utils/search-match.test.ts +149 -0
- package/src/utils/search-match.ts +145 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure title matcher + ranker for Quick Find / Search. No React, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* Relevance is tiered the way a human reads a match, strongest first:
|
|
5
|
+
*
|
|
6
|
+
* 1. PREFIX — the title starts with the query ("not" → "Notes").
|
|
7
|
+
* 2. WORD boundary — some word starts with the query ("pa" → "New page").
|
|
8
|
+
* 3. SUBSTRING — the query appears mid-word ("page" → "Homepage").
|
|
9
|
+
* 4. FUZZY — the query is a subsequence ("rdm" → "Roadmap").
|
|
10
|
+
*
|
|
11
|
+
* Within a tier, earlier and tighter matches in shorter titles score higher;
|
|
12
|
+
* tier gaps are wider than any intra-tier penalty, so a fuzzy hit can never
|
|
13
|
+
* outrank a real substring. Ties (same score) fall back to `updatedAt` DESC in
|
|
14
|
+
* {@link rankResults} — between two objects named "Notes", the one touched last
|
|
15
|
+
* is almost always the one wanted.
|
|
16
|
+
*
|
|
17
|
+
* Matching is case- and diacritic-insensitive via a per-UTF-16-unit fold that
|
|
18
|
+
* PRESERVES STRING LENGTH, so the returned ranges index straight into the
|
|
19
|
+
* ORIGINAL title for highlight rendering.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Half-open [start, end) span into the original title. */
|
|
23
|
+
export interface MatchRange {
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TitleMatch {
|
|
29
|
+
score: number;
|
|
30
|
+
ranges: MatchRange[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tier bases. Gaps (1000) exceed the max intra-tier penalty (≤900), keeping
|
|
34
|
+
// tiers strictly ordered no matter the inputs.
|
|
35
|
+
const TIER_PREFIX = 4000;
|
|
36
|
+
const TIER_WORD = 3000;
|
|
37
|
+
const TIER_SUBSTRING = 2000;
|
|
38
|
+
const TIER_FUZZY = 1000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lowercase + strip diacritics WITHOUT changing length: each UTF-16 unit maps
|
|
42
|
+
* to exactly one folded unit (NFD base char, first lowercase unit). Surrogate
|
|
43
|
+
* halves pass through unchanged — they can't match an ASCII query, which is
|
|
44
|
+
* exactly right for emoji-bearing titles.
|
|
45
|
+
*/
|
|
46
|
+
export function fold(s: string): string {
|
|
47
|
+
let out = '';
|
|
48
|
+
for (let i = 0; i < s.length; i++) {
|
|
49
|
+
const base = s[i]!.normalize('NFD')[0]!;
|
|
50
|
+
const lower = base.toLowerCase();
|
|
51
|
+
// Some locale-specific lowercasings expand (e.g. 'İ' → 'i̇'); keep unit 0.
|
|
52
|
+
out += lower.length === 1 ? lower : lower[0]!;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A word starts where the previous folded char is not alphanumeric. */
|
|
58
|
+
export function isWordStart(folded: string, i: number): boolean {
|
|
59
|
+
if (i === 0) return true;
|
|
60
|
+
return !/[a-z0-9]/.test(folded[i - 1]!);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const startPenalty = (i: number) => Math.min(i * 8, 600);
|
|
64
|
+
const lengthPenalty = (titleLen: number, queryLen: number) => Math.min(Math.max(titleLen - queryLen, 0), 100);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Match one title against a query. Returns `null` for an empty query or a miss.
|
|
68
|
+
* Ranges cover every highlighted span (one for contiguous tiers, several merged
|
|
69
|
+
* runs for fuzzy).
|
|
70
|
+
*/
|
|
71
|
+
export function matchTitle(query: string, title: string): TitleMatch | null {
|
|
72
|
+
const q = fold(query.trim());
|
|
73
|
+
if (!q) return null;
|
|
74
|
+
const t = fold(title);
|
|
75
|
+
|
|
76
|
+
let first = -1;
|
|
77
|
+
let wordAt = -1;
|
|
78
|
+
for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
|
|
79
|
+
if (first === -1) first = i;
|
|
80
|
+
if (isWordStart(t, i)) {
|
|
81
|
+
wordAt = i;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (first === 0) {
|
|
87
|
+
return { score: TIER_PREFIX - lengthPenalty(t.length, q.length), ranges: [{ start: 0, end: q.length }] };
|
|
88
|
+
}
|
|
89
|
+
if (wordAt !== -1) {
|
|
90
|
+
return {
|
|
91
|
+
score: TIER_WORD - startPenalty(wordAt) - lengthPenalty(t.length, q.length),
|
|
92
|
+
ranges: [{ start: wordAt, end: wordAt + q.length }],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (first !== -1) {
|
|
96
|
+
return {
|
|
97
|
+
score: TIER_SUBSTRING - startPenalty(first) - lengthPenalty(t.length, q.length),
|
|
98
|
+
ranges: [{ start: first, end: first + q.length }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Fuzzy subsequence (greedy left-to-right). Whitespace in the query is
|
|
103
|
+
// skipped so "new pg" can still reach "New page". Adjacent hits merge into
|
|
104
|
+
// one range so the highlight reads as runs, not confetti.
|
|
105
|
+
const chars = q.replace(/\s+/g, '');
|
|
106
|
+
if (!chars) return null;
|
|
107
|
+
const ranges: MatchRange[] = [];
|
|
108
|
+
let from = 0;
|
|
109
|
+
for (let ci = 0; ci < chars.length; ci++) {
|
|
110
|
+
const at = t.indexOf(chars[ci]!, from);
|
|
111
|
+
if (at === -1) return null;
|
|
112
|
+
const last = ranges[ranges.length - 1];
|
|
113
|
+
if (last && last.end === at) last.end = at + 1;
|
|
114
|
+
else ranges.push({ start: at, end: at + 1 });
|
|
115
|
+
from = at + 1;
|
|
116
|
+
}
|
|
117
|
+
const firstHit = ranges[0]!.start;
|
|
118
|
+
const spread = ranges[ranges.length - 1]!.end - firstHit - chars.length;
|
|
119
|
+
const score = TIER_FUZZY - Math.min(spread * 8, 600) - Math.min(firstHit * 2, 200) - lengthPenalty(t.length, chars.length);
|
|
120
|
+
return { score, ranges };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RankedResult<T> {
|
|
124
|
+
item: T;
|
|
125
|
+
score: number;
|
|
126
|
+
ranges: MatchRange[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rank a candidate list against a query: score every title, drop misses, sort
|
|
131
|
+
* by score DESC then `updatedAt` DESC (recency breaks ties), cap at `limit`.
|
|
132
|
+
*/
|
|
133
|
+
export function rankResults<T extends { title: string; updatedAt: number }>(
|
|
134
|
+
query: string,
|
|
135
|
+
items: readonly T[],
|
|
136
|
+
limit = 50,
|
|
137
|
+
): RankedResult<T>[] {
|
|
138
|
+
const out: RankedResult<T>[] = [];
|
|
139
|
+
for (const item of items) {
|
|
140
|
+
const m = matchTitle(query, item.title);
|
|
141
|
+
if (m) out.push({ item, score: m.score, ranges: m.ranges });
|
|
142
|
+
}
|
|
143
|
+
out.sort((a, b) => b.score - a.score || b.item.updatedAt - a.item.updatedAt);
|
|
144
|
+
return out.slice(0, limit);
|
|
145
|
+
}
|