@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.
Files changed (186) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. 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
+ }
@@ -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
+ }