@tuturuuu/utils 0.0.3 → 0.6.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/CHANGELOG.md +313 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- package/src/index.ts +0 -1
package/src/search.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
export type IntentMatchReason =
|
|
2
|
+
| 'exact'
|
|
3
|
+
| 'prefix'
|
|
4
|
+
| 'compact'
|
|
5
|
+
| 'acronym'
|
|
6
|
+
| 'word-order'
|
|
7
|
+
| 'contains'
|
|
8
|
+
| 'fuzzy'
|
|
9
|
+
| 'typo';
|
|
10
|
+
|
|
11
|
+
export type IntentSearchCandidate = {
|
|
12
|
+
aliases?: readonly string[];
|
|
13
|
+
keywords?: readonly string[];
|
|
14
|
+
subtitle?: string | null;
|
|
15
|
+
title: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type IntentSearchResult<T extends IntentSearchCandidate> = {
|
|
19
|
+
item: T;
|
|
20
|
+
matchedText: string;
|
|
21
|
+
reason: IntentMatchReason;
|
|
22
|
+
score: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type NormalizedText = {
|
|
26
|
+
compact: string;
|
|
27
|
+
original: string;
|
|
28
|
+
text: string;
|
|
29
|
+
words: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type CandidateMatch = {
|
|
33
|
+
matchedText: string;
|
|
34
|
+
reason: IntentMatchReason;
|
|
35
|
+
score: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const SHORT_QUERY_MAX_LENGTH = 2;
|
|
39
|
+
const TYPO_DISTANCE_MAX_LENGTH = 32;
|
|
40
|
+
|
|
41
|
+
export function normalizeIntentText(value: string): string {
|
|
42
|
+
return value
|
|
43
|
+
.normalize('NFD')
|
|
44
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/đ/g, 'd')
|
|
47
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/\s+/g, ' ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function compactIntentText(value: string): string {
|
|
53
|
+
return normalizeIntentText(value).replace(/\s+/g, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getIntentAcronym(value: string): string {
|
|
57
|
+
return normalizeIntentText(value)
|
|
58
|
+
.split(' ')
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((word) => word[0])
|
|
61
|
+
.join('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalize(value: string): NormalizedText {
|
|
65
|
+
const text = normalizeIntentText(value);
|
|
66
|
+
const words = text ? text.split(' ') : [];
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
compact: words.join(''),
|
|
70
|
+
original: value,
|
|
71
|
+
text,
|
|
72
|
+
words,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getCandidateTexts(candidate: IntentSearchCandidate): string[] {
|
|
77
|
+
const values = [
|
|
78
|
+
candidate.title,
|
|
79
|
+
candidate.subtitle ?? '',
|
|
80
|
+
...(candidate.aliases ?? []),
|
|
81
|
+
...(candidate.keywords ?? []),
|
|
82
|
+
]
|
|
83
|
+
.map((value) => value.trim())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
|
|
86
|
+
return Array.from(new Set(values));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasOrderedCharacters(text: string, query: string): boolean {
|
|
90
|
+
let queryIndex = 0;
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < text.length && queryIndex < query.length; i += 1) {
|
|
93
|
+
if (text[i] === query[queryIndex]) {
|
|
94
|
+
queryIndex += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return queryIndex === query.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function orderedCharacterScore(text: string, query: string): number {
|
|
102
|
+
let queryIndex = 0;
|
|
103
|
+
let firstMatch = -1;
|
|
104
|
+
let lastMatch = -1;
|
|
105
|
+
let streak = 0;
|
|
106
|
+
let longestStreak = 0;
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < text.length && queryIndex < query.length; i += 1) {
|
|
109
|
+
if (text[i] === query[queryIndex]) {
|
|
110
|
+
if (firstMatch === -1) firstMatch = i;
|
|
111
|
+
lastMatch = i;
|
|
112
|
+
queryIndex += 1;
|
|
113
|
+
streak += 1;
|
|
114
|
+
longestStreak = Math.max(longestStreak, streak);
|
|
115
|
+
} else {
|
|
116
|
+
streak = 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (queryIndex !== query.length || firstMatch === -1 || lastMatch === -1) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const span = Math.max(1, lastMatch - firstMatch + 1);
|
|
125
|
+
const density = query.length / span;
|
|
126
|
+
const prefixBonus = firstMatch === 0 ? 28 : Math.max(0, 18 - firstMatch);
|
|
127
|
+
const streakBonus = Math.min(40, longestStreak * 8);
|
|
128
|
+
|
|
129
|
+
return Math.round(320 + density * 110 + prefixBonus + streakBonus);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function boundedLevenshtein(a: string, b: string, maxDistance: number): number {
|
|
133
|
+
if (Math.abs(a.length - b.length) > maxDistance) return maxDistance + 1;
|
|
134
|
+
if (a === b) return 0;
|
|
135
|
+
if (!a) return b.length;
|
|
136
|
+
if (!b) return a.length;
|
|
137
|
+
|
|
138
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
139
|
+
let current = new Array<number>(b.length + 1);
|
|
140
|
+
|
|
141
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
142
|
+
current[0] = i;
|
|
143
|
+
let rowMin = current[0];
|
|
144
|
+
|
|
145
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
146
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
147
|
+
const deletion = (previous[j] ?? Number.POSITIVE_INFINITY) + 1;
|
|
148
|
+
const insertion = (current[j - 1] ?? Number.POSITIVE_INFINITY) + 1;
|
|
149
|
+
const substitution = (previous[j - 1] ?? Number.POSITIVE_INFINITY) + cost;
|
|
150
|
+
current[j] = Math.min(deletion, insertion, substitution);
|
|
151
|
+
rowMin = Math.min(rowMin, current[j] ?? Number.POSITIVE_INFINITY);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (rowMin > maxDistance) return maxDistance + 1;
|
|
155
|
+
[previous, current] = [current, previous];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return previous[b.length] ?? maxDistance + 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getTypoLimit(queryLength: number): number {
|
|
162
|
+
if (queryLength < 4) return 0;
|
|
163
|
+
if (queryLength < 8) return 1;
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scoreText(text: string, query: NormalizedText): CandidateMatch | null {
|
|
168
|
+
const target = normalize(text);
|
|
169
|
+
|
|
170
|
+
if (!query.text || !query.compact || !target.text) return null;
|
|
171
|
+
|
|
172
|
+
const isShortQuery = query.compact.length <= SHORT_QUERY_MAX_LENGTH;
|
|
173
|
+
const acronym = getIntentAcronym(target.original);
|
|
174
|
+
|
|
175
|
+
if (target.text === query.text) {
|
|
176
|
+
return {
|
|
177
|
+
matchedText: text,
|
|
178
|
+
reason: 'exact',
|
|
179
|
+
score: 10_000,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (target.compact === query.compact) {
|
|
184
|
+
return {
|
|
185
|
+
matchedText: text,
|
|
186
|
+
reason: 'compact',
|
|
187
|
+
score: 9_700,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (target.text.startsWith(query.text)) {
|
|
192
|
+
return {
|
|
193
|
+
matchedText: text,
|
|
194
|
+
reason: 'prefix',
|
|
195
|
+
score: 9_200 - Math.min(300, target.text.length - query.text.length),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (target.compact.startsWith(query.compact)) {
|
|
200
|
+
return {
|
|
201
|
+
matchedText: text,
|
|
202
|
+
reason: 'compact',
|
|
203
|
+
score:
|
|
204
|
+
8_900 - Math.min(300, target.compact.length - query.compact.length),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (acronym?.startsWith(query.compact)) {
|
|
209
|
+
return {
|
|
210
|
+
matchedText: text,
|
|
211
|
+
reason: 'acronym',
|
|
212
|
+
score: 8_300 - Math.min(200, acronym.length - query.compact.length),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (isShortQuery) return null;
|
|
217
|
+
|
|
218
|
+
if (
|
|
219
|
+
target.text.includes(query.text) ||
|
|
220
|
+
target.compact.includes(query.compact)
|
|
221
|
+
) {
|
|
222
|
+
const compactIndex = target.compact.indexOf(query.compact);
|
|
223
|
+
const wordStart = target.words.some((word) => word.startsWith(query.text));
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
matchedText: text,
|
|
227
|
+
reason: 'contains',
|
|
228
|
+
score: (wordStart ? 7_400 : 6_400) - Math.max(0, compactIndex),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (
|
|
233
|
+
query.words.length > 1 &&
|
|
234
|
+
query.words.every((word) =>
|
|
235
|
+
target.words.some((targetWord) => targetWord.startsWith(word))
|
|
236
|
+
)
|
|
237
|
+
) {
|
|
238
|
+
return {
|
|
239
|
+
matchedText: text,
|
|
240
|
+
reason: 'word-order',
|
|
241
|
+
score: 7_700 - Math.min(500, target.words.length * 20),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (hasOrderedCharacters(target.compact, query.compact)) {
|
|
246
|
+
return {
|
|
247
|
+
matchedText: text,
|
|
248
|
+
reason: 'fuzzy',
|
|
249
|
+
score: orderedCharacterScore(target.compact, query.compact),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const typoLimit = getTypoLimit(query.compact.length);
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
typoLimit > 0 &&
|
|
257
|
+
target.compact.length <= TYPO_DISTANCE_MAX_LENGTH &&
|
|
258
|
+
query.compact.length <= TYPO_DISTANCE_MAX_LENGTH
|
|
259
|
+
) {
|
|
260
|
+
const candidates = [target.compact, ...target.words];
|
|
261
|
+
let bestDistance = typoLimit + 1;
|
|
262
|
+
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
if (Math.abs(candidate.length - query.compact.length) > typoLimit) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
bestDistance = Math.min(
|
|
269
|
+
bestDistance,
|
|
270
|
+
boundedLevenshtein(candidate, query.compact, typoLimit)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (bestDistance <= typoLimit) {
|
|
275
|
+
return {
|
|
276
|
+
matchedText: text,
|
|
277
|
+
reason: 'typo',
|
|
278
|
+
score: 6_900 - bestDistance * 350,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function scoreIntentCandidate<T extends IntentSearchCandidate>(
|
|
287
|
+
item: T,
|
|
288
|
+
query: string
|
|
289
|
+
): IntentSearchResult<T> | null {
|
|
290
|
+
const normalizedQuery = normalize(query);
|
|
291
|
+
|
|
292
|
+
if (!normalizedQuery.text) {
|
|
293
|
+
return {
|
|
294
|
+
item,
|
|
295
|
+
matchedText: item.title,
|
|
296
|
+
reason: 'exact',
|
|
297
|
+
score: 0,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let bestMatch: CandidateMatch | null = null;
|
|
302
|
+
|
|
303
|
+
for (const text of getCandidateTexts(item)) {
|
|
304
|
+
const match = scoreText(text, normalizedQuery);
|
|
305
|
+
|
|
306
|
+
if (!match) continue;
|
|
307
|
+
|
|
308
|
+
if (!bestMatch || match.score > bestMatch.score) {
|
|
309
|
+
bestMatch = match;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!bestMatch) return null;
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
item,
|
|
317
|
+
...bestMatch,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function searchIntent<T extends IntentSearchCandidate>(
|
|
322
|
+
items: readonly T[],
|
|
323
|
+
query: string,
|
|
324
|
+
{
|
|
325
|
+
limit = 10,
|
|
326
|
+
minScore = 1,
|
|
327
|
+
}: {
|
|
328
|
+
limit?: number;
|
|
329
|
+
minScore?: number;
|
|
330
|
+
} = {}
|
|
331
|
+
): IntentSearchResult<T>[] {
|
|
332
|
+
const trimmedQuery = query.trim();
|
|
333
|
+
|
|
334
|
+
if (!trimmedQuery) {
|
|
335
|
+
return items.slice(0, limit).map((item) => ({
|
|
336
|
+
item,
|
|
337
|
+
matchedText: item.title,
|
|
338
|
+
reason: 'exact',
|
|
339
|
+
score: 0,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return items
|
|
344
|
+
.map((item, index) => {
|
|
345
|
+
const result = scoreIntentCandidate(item, trimmedQuery);
|
|
346
|
+
|
|
347
|
+
return result && result.score >= minScore ? { ...result, index } : null;
|
|
348
|
+
})
|
|
349
|
+
.filter((result): result is IntentSearchResult<T> & { index: number } =>
|
|
350
|
+
Boolean(result)
|
|
351
|
+
)
|
|
352
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
353
|
+
.slice(0, limit)
|
|
354
|
+
.map(({ index: _index, ...result }) => result);
|
|
355
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const UUID_PATTERN =
|
|
2
|
+
'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
3
|
+
const GENERATED_STORAGE_PREFIX_PATTERN = new RegExp(
|
|
4
|
+
`^(?:${UUID_PATTERN}[-_])+`,
|
|
5
|
+
'i'
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export function stripGeneratedStorageNamePrefix(name: string) {
|
|
9
|
+
return name.replace(GENERATED_STORAGE_PREFIX_PATTERN, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getStoragePathSegmentDisplayName(segment: string) {
|
|
13
|
+
let decoded = segment;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
decoded = decodeURIComponent(segment);
|
|
17
|
+
} catch {}
|
|
18
|
+
|
|
19
|
+
return stripGeneratedStorageNamePrefix(decoded);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getStorageObjectDisplayName(
|
|
23
|
+
item: { name?: string | null } | null
|
|
24
|
+
) {
|
|
25
|
+
if (!item?.name) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return stripGeneratedStorageNamePrefix(item.name);
|
|
30
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Path Sanitization Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides secure path and filename sanitization for file storage operations.
|
|
5
|
+
* Prevents directory traversal attacks and ensures safe file operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sanitizes a path component to prevent directory traversal attacks.
|
|
10
|
+
*
|
|
11
|
+
* @param path - The path string to sanitize
|
|
12
|
+
* @returns Sanitized path string, or null if the path contains invalid characters
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* sanitizePath('folder/subfolder') // => 'folder/subfolder'
|
|
17
|
+
* sanitizePath('../../../etc/passwd') // => null
|
|
18
|
+
* sanitizePath('folder\\subfolder') // => 'folder/subfolder' (backslashes normalized)
|
|
19
|
+
* sanitizePath('') // => ''
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizePath(path: string): string | null {
|
|
23
|
+
if (!path) return '';
|
|
24
|
+
|
|
25
|
+
// Normalize backslashes to forward slashes (Windows compatibility)
|
|
26
|
+
let sanitized = path.replace(/\\/g, '/');
|
|
27
|
+
|
|
28
|
+
// Trim and remove leading/trailing slashes
|
|
29
|
+
sanitized = sanitized.trim().replace(/^\/+|\/+$/g, '');
|
|
30
|
+
|
|
31
|
+
// Split into segments and validate each
|
|
32
|
+
const segments = sanitized.split('/').filter(Boolean);
|
|
33
|
+
|
|
34
|
+
for (const segment of segments) {
|
|
35
|
+
// Reject any segment that is '..' or '.' or empty
|
|
36
|
+
if (segment === '..' || segment === '.' || segment === '') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// Reject segments with path traversal attempts
|
|
40
|
+
if (segment.includes('..')) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Rejoin with forward slashes
|
|
46
|
+
return segments.join('/');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sanitizes a folder name to prevent directory traversal and invalid characters.
|
|
51
|
+
*
|
|
52
|
+
* @param name - The folder name to sanitize
|
|
53
|
+
* @returns Sanitized folder name, or null if the name contains invalid characters
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* sanitizeFolderName('my-folder') // => 'my-folder'
|
|
58
|
+
* sanitizeFolderName('folder/subfolder') // => null (contains slashes)
|
|
59
|
+
* sanitizeFolderName('..') // => null (traversal attempt)
|
|
60
|
+
* sanitizeFolderName('folder\\name') // => null (normalized to 'folder/name' which contains slash)
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeFolderName(name: string): string | null {
|
|
64
|
+
if (!name) return null;
|
|
65
|
+
|
|
66
|
+
// Trim and remove leading/trailing slashes
|
|
67
|
+
const trimmed = name.trim().replace(/^\/+|\/+$/g, '');
|
|
68
|
+
|
|
69
|
+
// Replace backslashes with forward slashes
|
|
70
|
+
const normalized = trimmed.replace(/\\/g, '/');
|
|
71
|
+
|
|
72
|
+
// Reject if it contains slashes (should be a single name, not a path)
|
|
73
|
+
if (normalized.includes('/')) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Reject path traversal attempts
|
|
78
|
+
if (normalized === '..' || normalized === '.' || normalized.includes('..')) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sanitizes a filename to prevent directory traversal and invalid characters.
|
|
87
|
+
* Enforces strict validation rules including:
|
|
88
|
+
* - ASCII letters and digits only
|
|
89
|
+
* - Allowed special characters: space, underscore, hyphen, dot
|
|
90
|
+
* - No control characters or Unicode exploits
|
|
91
|
+
* - Maximum length of 255 characters
|
|
92
|
+
* - No leading/trailing spaces or dots
|
|
93
|
+
*
|
|
94
|
+
* @param filename - The filename to sanitize
|
|
95
|
+
* @returns Sanitized filename, or null if the filename contains invalid characters
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* sanitizeFilename('document.pdf') // => 'document.pdf'
|
|
100
|
+
* sanitizeFilename('../../../etc/passwd') // => null (traversal attempt)
|
|
101
|
+
* sanitizeFilename('file\x00name.txt') // => null (control character)
|
|
102
|
+
* sanitizeFilename('very-long-name...') // => null (if exceeds 255 chars)
|
|
103
|
+
* sanitizeFilename(' .hidden') // => null (leading space/dot)
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function sanitizeFilename(filename: string): string | null {
|
|
107
|
+
if (!filename) return null;
|
|
108
|
+
|
|
109
|
+
// Get the basename to remove any path components
|
|
110
|
+
// Using a simple approach without node:path for better portability
|
|
111
|
+
const lastSlash = Math.max(
|
|
112
|
+
filename.lastIndexOf('/'),
|
|
113
|
+
filename.lastIndexOf('\\')
|
|
114
|
+
);
|
|
115
|
+
const base = lastSlash >= 0 ? filename.substring(lastSlash + 1) : filename;
|
|
116
|
+
|
|
117
|
+
// Reject if basename differs from original (indicates path traversal attempt)
|
|
118
|
+
if (base !== filename) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Normalize to NFC (Canonical Decomposition, followed by Canonical Composition)
|
|
123
|
+
const normalized = base.normalize('NFC');
|
|
124
|
+
|
|
125
|
+
// Check length (255 characters max)
|
|
126
|
+
if (normalized.length === 0 || normalized.length > 255) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Reject leading/trailing spaces or dots
|
|
131
|
+
if (
|
|
132
|
+
normalized.startsWith(' ') ||
|
|
133
|
+
normalized.endsWith(' ') ||
|
|
134
|
+
normalized.startsWith('.') ||
|
|
135
|
+
normalized.endsWith('.')
|
|
136
|
+
) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Allows: Letters, Numbers, Spaces, Dots, Underscores, Hyphens, and Parentheses
|
|
141
|
+
const allowedPattern = /^[a-zA-Z0-9\s._()/-]+$/;
|
|
142
|
+
if (!allowedPattern.test(normalized)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
package/src/tag-utils.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Tag color utilities for consistent styling across components
|
|
2
|
+
|
|
3
|
+
// Dynamic color generation using HSL for better control and consistency
|
|
4
|
+
// Optimized for dark mode with excellent contrast ratios
|
|
5
|
+
const COLOR_PALETTE = [
|
|
6
|
+
{ hue: 210, saturation: 80, lightness: 50 }, // Blue
|
|
7
|
+
{ hue: 120, saturation: 80, lightness: 50 }, // Green
|
|
8
|
+
{ hue: 270, saturation: 80, lightness: 50 }, // Purple
|
|
9
|
+
{ hue: 30, saturation: 80, lightness: 50 }, // Orange
|
|
10
|
+
{ hue: 330, saturation: 80, lightness: 50 }, // Pink
|
|
11
|
+
{ hue: 180, saturation: 80, lightness: 50 }, // Cyan
|
|
12
|
+
{ hue: 60, saturation: 80, lightness: 50 }, // Yellow
|
|
13
|
+
{ hue: 0, saturation: 80, lightness: 50 }, // Red
|
|
14
|
+
{ hue: 150, saturation: 80, lightness: 50 }, // Teal
|
|
15
|
+
{ hue: 300, saturation: 80, lightness: 50 }, // Magenta
|
|
16
|
+
{ hue: 90, saturation: 80, lightness: 50 }, // Lime
|
|
17
|
+
{ hue: 240, saturation: 80, lightness: 50 }, // Indigo
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_TAG_COLOR =
|
|
21
|
+
'bg-gray-500/20 text-gray-300 border-gray-500/30';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate HSL color values for a tag
|
|
25
|
+
* @param tag - The tag string
|
|
26
|
+
* @returns HSL color object
|
|
27
|
+
*/
|
|
28
|
+
function generateTagHSL(tag: string) {
|
|
29
|
+
if (!tag || typeof tag !== 'string' || tag.trim().length === 0) {
|
|
30
|
+
return { hue: 0, saturation: 0, lightness: 50 }; // Gray
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Sanitize the tag for consistent hashing
|
|
34
|
+
const sanitizedTag = tag.trim().toLowerCase();
|
|
35
|
+
|
|
36
|
+
// Enhanced hash function for better distribution
|
|
37
|
+
let hash = 0;
|
|
38
|
+
for (let i = 0; i < sanitizedTag.length; i++) {
|
|
39
|
+
const charCode = sanitizedTag.charCodeAt(i);
|
|
40
|
+
if (charCode !== undefined) {
|
|
41
|
+
hash = (hash << 5) - hash + charCode;
|
|
42
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const index = Math.abs(hash) % COLOR_PALETTE.length;
|
|
47
|
+
const baseColor = COLOR_PALETTE[index]; // Safe since index is within bounds
|
|
48
|
+
|
|
49
|
+
if (!baseColor) {
|
|
50
|
+
return { hue: 0, saturation: 0, lightness: 50 }; // Fallback to gray
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Add some variation based on the hash to create more unique colors
|
|
54
|
+
// Reduced variation for more consistent and readable colors
|
|
55
|
+
const hueVariation = (hash % 20) - 10; // ±10 degrees (reduced from ±15)
|
|
56
|
+
const saturationVariation = (hash % 15) - 7; // ±7% (reduced from ±10%)
|
|
57
|
+
const lightnessVariation = (hash % 12) - 6; // ±6% (reduced from ±8%)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
hue: (baseColor.hue + hueVariation + 360) % 360,
|
|
61
|
+
saturation: Math.max(
|
|
62
|
+
60,
|
|
63
|
+
Math.min(85, baseColor.saturation + saturationVariation)
|
|
64
|
+
),
|
|
65
|
+
lightness: Math.max(
|
|
66
|
+
50,
|
|
67
|
+
Math.min(70, baseColor.lightness + lightnessVariation)
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate a consistent color for a tag based on its text
|
|
74
|
+
* Optimized for both light and dark modes with excellent contrast
|
|
75
|
+
* @param tag - The tag string
|
|
76
|
+
* @returns CSS variables object for the tag color styling
|
|
77
|
+
*/
|
|
78
|
+
export function getTagColor(tag: string): {
|
|
79
|
+
'--tag-bg-color': string;
|
|
80
|
+
'--tag-text-color': string;
|
|
81
|
+
'--tag-border-color': string;
|
|
82
|
+
} {
|
|
83
|
+
if (!tag || typeof tag !== 'string') {
|
|
84
|
+
return {
|
|
85
|
+
'--tag-bg-color': 'rgb(75 85 99 / 0.2)',
|
|
86
|
+
'--tag-text-color': 'rgb(209 213 219)',
|
|
87
|
+
'--tag-border-color': 'rgb(75 85 99 / 0.4)',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hsl = generateTagHSL(tag);
|
|
92
|
+
|
|
93
|
+
// Dark mode optimized color generation
|
|
94
|
+
// Background: Dark but visible (15-25% lightness)
|
|
95
|
+
const bgLightness = Math.max(12, Math.min(25, hsl.lightness - 40));
|
|
96
|
+
const bgSaturation = Math.max(40, Math.min(80, hsl.saturation - 10));
|
|
97
|
+
|
|
98
|
+
// Text: Bright and highly visible (85-95% lightness)
|
|
99
|
+
const textLightness = Math.max(80, Math.min(95, hsl.lightness + 30));
|
|
100
|
+
const textSaturation = Math.max(60, Math.min(90, hsl.saturation + 15));
|
|
101
|
+
|
|
102
|
+
// Border: Medium brightness for definition (30-50% lightness)
|
|
103
|
+
const borderLightness = Math.max(25, Math.min(50, hsl.lightness - 25));
|
|
104
|
+
const borderSaturation = Math.max(50, Math.min(85, hsl.saturation));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
'--tag-bg-color': `hsl(${hsl.hue} ${bgSaturation}% ${bgLightness}% / 0.9)`,
|
|
108
|
+
'--tag-text-color': `hsl(${hsl.hue} ${textSaturation}% ${textLightness}%)`,
|
|
109
|
+
'--tag-border-color': `hsl(${hsl.hue} ${borderSaturation}% ${borderLightness}% / 0.6)`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get a static Tailwind class for tag styling that works with CSS variables
|
|
115
|
+
* @returns A CSS class string that uses CSS variables for dynamic colors
|
|
116
|
+
*/
|
|
117
|
+
export function getTagColorClass(): string {
|
|
118
|
+
return 'bg-[var(--tag-bg-color)] text-[var(--tag-text-color)] border-[var(--tag-border-color)]';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get tag color styling that combines CSS variables with static Tailwind classes
|
|
123
|
+
* @param tag - The tag string
|
|
124
|
+
* @returns An object with style (CSS variables) and className (static Tailwind classes)
|
|
125
|
+
*/
|
|
126
|
+
export function getTagColorStyling(tag: string): {
|
|
127
|
+
style: React.CSSProperties;
|
|
128
|
+
className: string;
|
|
129
|
+
} {
|
|
130
|
+
const cssVars = getTagColor(tag);
|
|
131
|
+
return {
|
|
132
|
+
style: cssVars as React.CSSProperties,
|
|
133
|
+
className: getTagColorClass(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get a list of available tag colors
|
|
139
|
+
* Optimized for dark mode with better contrast
|
|
140
|
+
* @returns Array of tag color classes
|
|
141
|
+
*/
|
|
142
|
+
export function getAvailableTagColors(): readonly string[] {
|
|
143
|
+
// Use predefined Tailwind classes optimized for dark mode
|
|
144
|
+
// These provide better contrast ratios in dark themes
|
|
145
|
+
return [
|
|
146
|
+
'bg-blue-600/20 text-blue-300 border-blue-600/40',
|
|
147
|
+
'bg-green-600/20 text-green-300 border-green-600/40',
|
|
148
|
+
'bg-purple-600/20 text-purple-300 border-purple-600/40',
|
|
149
|
+
'bg-orange-600/20 text-orange-300 border-orange-600/40',
|
|
150
|
+
'bg-pink-600/20 text-pink-300 border-pink-600/40',
|
|
151
|
+
'bg-cyan-600/20 text-cyan-300 border-cyan-600/40',
|
|
152
|
+
'bg-yellow-600/20 text-yellow-300 border-yellow-600/40',
|
|
153
|
+
'bg-red-600/20 text-red-300 border-red-600/40',
|
|
154
|
+
'bg-teal-600/20 text-teal-300 border-teal-600/40',
|
|
155
|
+
'bg-fuchsia-600/20 text-fuchsia-300 border-fuchsia-600/40',
|
|
156
|
+
'bg-lime-600/20 text-lime-300 border-lime-600/40',
|
|
157
|
+
'bg-indigo-600/20 text-indigo-300 border-indigo-600/40',
|
|
158
|
+
] as const;
|
|
159
|
+
}
|