@tuturuuu/utils 0.0.2 → 0.6.0

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 +305 -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 +122 -3
  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
@@ -0,0 +1,537 @@
1
+ import type { JSONContent } from '@tiptap/core';
2
+ import type { Json } from '@tuturuuu/types';
3
+
4
+ export const removeAccents = (str: string) =>
5
+ str
6
+ .normalize('NFD')
7
+ .replace(/[\u0300-\u036f]/g, '')
8
+ // specifically replace "đ" with "d" (both lowercase and uppercase)
9
+ // to support Vietnamese characters
10
+ .replace(/đ/g, 'd')
11
+ .replace(/Đ/g, 'D');
12
+
13
+ export const getDescriptionText = (description?: string | Json): string => {
14
+ if (!description) return '';
15
+
16
+ try {
17
+ // If description is already a Json object, use it directly
18
+ // Otherwise, parse the string
19
+ const parsed =
20
+ typeof description === 'string' ? JSON.parse(description) : description;
21
+
22
+ // Extract text with proper spacing and line breaks from TipTap JSONContent
23
+ // This function handles the standard TipTap node structure from the text editor
24
+ const extractText = (
25
+ node: JSONContent,
26
+ depth = 0,
27
+ listCounter?: number
28
+ ): string => {
29
+ // Handle text nodes - the leaf nodes containing actual text
30
+ if (node.type === 'text') {
31
+ return node.text || '';
32
+ }
33
+
34
+ // Handle hard breaks (Shift+Enter in editor)
35
+ if (node.type === 'hardBreak') {
36
+ return '\n';
37
+ }
38
+
39
+ // Handle horizontal rules
40
+ if (node.type === 'horizontalRule') {
41
+ return '\n---\n';
42
+ }
43
+
44
+ // Handle block-level nodes that should have spacing
45
+
46
+ // Paragraphs - basic text blocks
47
+ if (node.type === 'paragraph') {
48
+ const text =
49
+ node.content?.map((child) => extractText(child, depth)).join('') ||
50
+ '';
51
+ // Empty paragraphs still create visual spacing
52
+ return `${text}\n`;
53
+ }
54
+
55
+ // Headings - important sections
56
+ if (node.type === 'heading') {
57
+ const text =
58
+ node.content?.map((child) => extractText(child, depth)).join('') ||
59
+ '';
60
+ const level = node.attrs?.level || 1;
61
+ // Add visual hierarchy with # symbols
62
+ const prefix = '#'.repeat(level);
63
+ return `${prefix} ${text}\n`;
64
+ }
65
+
66
+ // Blockquotes - indented quoted text
67
+ if (node.type === 'blockquote') {
68
+ const text =
69
+ node.content
70
+ ?.map((child) => extractText(child, depth + 1))
71
+ .join('') || '';
72
+ // Indent quoted text with > symbol
73
+ return `${text
74
+ .split('\n')
75
+ .filter((line) => line.trim())
76
+ .map((line) => `> ${line}`)
77
+ .join('\n')}\n`;
78
+ }
79
+
80
+ // Code blocks - preserve formatting
81
+ if (node.type === 'codeBlock') {
82
+ const text =
83
+ node.content?.map((child) => extractText(child, depth)).join('') ||
84
+ '';
85
+ const language = node.attrs?.language || '';
86
+ return `\`\`\`${language}\n${text}\`\`\`\n`;
87
+ }
88
+
89
+ // Regular lists (bullet and numbered)
90
+ if (node.type === 'bulletList') {
91
+ const items =
92
+ node.content?.map((child) => extractText(child, depth)).join('') ||
93
+ '';
94
+ return `${items}\n`;
95
+ }
96
+
97
+ if (node.type === 'orderedList') {
98
+ let counter = node.attrs?.start || 1;
99
+ const items =
100
+ node.content
101
+ ?.map((child) => {
102
+ const text = extractText(child, depth, counter);
103
+ counter++;
104
+ return text;
105
+ })
106
+ .join('') || '';
107
+ return `${items}\n`;
108
+ }
109
+
110
+ if (node.type === 'listItem') {
111
+ const text =
112
+ node.content
113
+ ?.map((child) => extractText(child, depth + 1))
114
+ .join('')
115
+ .trim() || '';
116
+ const indent = ' '.repeat(depth);
117
+ // Check if we have a counter (ordered list)
118
+ const prefix =
119
+ typeof listCounter === 'number' ? `${listCounter}.` : '•';
120
+ return `${indent}${prefix} ${text}\n`;
121
+ }
122
+
123
+ // Task lists (checkboxes)
124
+ if (node.type === 'taskList') {
125
+ const items =
126
+ node.content?.map((child) => extractText(child, depth)).join('') ||
127
+ '';
128
+ return `${items}\n`;
129
+ }
130
+
131
+ if (node.type === 'taskItem') {
132
+ const text =
133
+ node.content
134
+ ?.map((child) => extractText(child, depth + 1))
135
+ .join('')
136
+ .trim() || '';
137
+ const indent = ' '.repeat(depth);
138
+ const checkbox = node.attrs?.checked ? '[x]' : '[ ]';
139
+ return `${indent}${checkbox} ${text}\n`;
140
+ }
141
+
142
+ // Table structures
143
+ if (node.type === 'table') {
144
+ const rows =
145
+ node.content?.map((child) => extractText(child, depth)).join('') ||
146
+ '';
147
+ return `\n${rows}\n`;
148
+ }
149
+
150
+ if (node.type === 'tableRow') {
151
+ const cells =
152
+ node.content?.map((child) => extractText(child, depth)).join(' | ') ||
153
+ '';
154
+ return `| ${cells} |\n`;
155
+ }
156
+
157
+ if (node.type === 'tableCell' || node.type === 'tableHeader') {
158
+ const text =
159
+ node.content
160
+ ?.map((child) => extractText(child, depth))
161
+ .join('')
162
+ .trim() || '';
163
+ return text;
164
+ }
165
+
166
+ // Media nodes - just indicate their presence
167
+ if (node.type === 'image' || node.type === 'imageResize') {
168
+ const alt = node.attrs?.alt || 'Image';
169
+ return `[${alt}]`;
170
+ }
171
+
172
+ if (node.type === 'video') {
173
+ return '[Video]';
174
+ }
175
+
176
+ if (node.type === 'youtube') {
177
+ return '[YouTube Video]';
178
+ }
179
+
180
+ // Mentions - extract display text
181
+ if (node.type === 'mention') {
182
+ const label = node.attrs?.label || node.attrs?.id || 'mention';
183
+ return `@${label}`;
184
+ }
185
+
186
+ // Doc node (root) and other container nodes
187
+ if (node.content) {
188
+ return node.content.map((child) => extractText(child, depth)).join('');
189
+ }
190
+
191
+ return '';
192
+ };
193
+
194
+ const result = extractText(parsed).trim();
195
+ // Clean up excessive newlines while preserving intentional double spacing
196
+ return result.replace(/\n{3,}/g, '\n\n');
197
+ } catch {
198
+ // If it's not valid JSON, return as plain text
199
+ return typeof description === 'string' ? description : String(description);
200
+ }
201
+ };
202
+
203
+ export interface DescriptionMetadata {
204
+ hasText: boolean;
205
+ hasImages: boolean;
206
+ hasVideos: boolean;
207
+ hasLinks: boolean;
208
+ imageCount: number;
209
+ videoCount: number;
210
+ linkCount: number;
211
+ totalCheckboxes: number;
212
+ checkedCheckboxes: number;
213
+ indeterminateCheckboxes: number;
214
+ }
215
+
216
+ export const getDescriptionMetadata = (
217
+ description?: string | Json
218
+ ): DescriptionMetadata => {
219
+ const metadata: DescriptionMetadata = {
220
+ hasText: false,
221
+ hasImages: false,
222
+ hasVideos: false,
223
+ hasLinks: false,
224
+ imageCount: 0,
225
+ videoCount: 0,
226
+ linkCount: 0,
227
+ totalCheckboxes: 0,
228
+ checkedCheckboxes: 0,
229
+ indeterminateCheckboxes: 0,
230
+ };
231
+
232
+ if (!description) return metadata;
233
+
234
+ try {
235
+ // If description is already a Json object, use it directly
236
+ // Otherwise, parse the string
237
+ const parsed =
238
+ typeof description === 'string' ? JSON.parse(description) : description;
239
+
240
+ const analyzeContent = (content: JSONContent): void => {
241
+ // Check for text content
242
+ if (content.type === 'text' && content.text?.trim()) {
243
+ metadata.hasText = true;
244
+ }
245
+
246
+ // Check for images (both 'image' and 'imageResize' node types)
247
+ if (content.type === 'image' || content.type === 'imageResize') {
248
+ metadata.hasImages = true;
249
+ metadata.imageCount++;
250
+ }
251
+
252
+ // Check for videos
253
+ if (content.type === 'video') {
254
+ metadata.hasVideos = true;
255
+ metadata.videoCount++;
256
+ }
257
+
258
+ // Check for YouTube embeds
259
+ if (content.type === 'youtube') {
260
+ metadata.hasVideos = true;
261
+ metadata.videoCount++;
262
+ }
263
+
264
+ // Check for links (marks within text nodes)
265
+ if (content.marks?.some((mark) => mark.type === 'link')) {
266
+ metadata.hasLinks = true;
267
+ metadata.linkCount++;
268
+ }
269
+
270
+ // Check for task items (checkboxes)
271
+ // Indeterminate items are counted separately and excluded from totalCheckboxes
272
+ // since they represent abandoned/not-proceeded tasks
273
+ if (content.type === 'taskItem') {
274
+ if (content.attrs?.checked === 'indeterminate') {
275
+ metadata.indeterminateCheckboxes++;
276
+ } else {
277
+ metadata.totalCheckboxes++;
278
+ if (content.attrs?.checked === true) {
279
+ metadata.checkedCheckboxes++;
280
+ }
281
+ }
282
+ }
283
+
284
+ // Recursively check child content
285
+ if (content.content) {
286
+ content.content.forEach(analyzeContent);
287
+ }
288
+ };
289
+
290
+ analyzeContent(parsed);
291
+ } catch {
292
+ // If it's not valid JSON, treat as plain text
293
+ const descText =
294
+ typeof description === 'string' ? description : String(description);
295
+ if (descText.trim()) {
296
+ metadata.hasText = true;
297
+ }
298
+ }
299
+
300
+ return metadata;
301
+ };
302
+
303
+ export interface ExtractTextOptions {
304
+ /** Include unique identifiers in placeholders for media nodes */
305
+ includeIdentifiers?: boolean;
306
+ /** Maximum length for identifiers (truncate with ...) */
307
+ maxIdentifierLength?: number;
308
+ }
309
+
310
+ /**
311
+ * Extract a filename from a URL or path
312
+ */
313
+ function extractFilename(urlOrPath: string, maxLength = 30): string {
314
+ try {
315
+ // Try to parse as URL first
316
+ const url = new URL(urlOrPath);
317
+ const pathname = url.pathname;
318
+ const filename = pathname.split('/').pop() || pathname;
319
+ const decoded = decodeURIComponent(filename);
320
+ return decoded.length > maxLength
321
+ ? `${decoded.slice(0, maxLength - 3)}...`
322
+ : decoded;
323
+ } catch {
324
+ // Not a valid URL, treat as path
325
+ const filename = urlOrPath.split('/').pop() || urlOrPath;
326
+ return filename.length > maxLength
327
+ ? `${filename.slice(0, maxLength - 3)}...`
328
+ : filename;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Enhanced version of getDescriptionText that includes unique identifiers
334
+ * for media nodes, enabling text-based diff to detect image/video changes.
335
+ *
336
+ * Example outputs:
337
+ * - Image: "[Image: cat-photo.png]" instead of "[Image]"
338
+ * - Video: "[Video: intro.mp4]" instead of "[Video]"
339
+ * - YouTube: "[YouTube: abc123]" instead of "[YouTube Video]"
340
+ */
341
+ export const getDescriptionTextWithIdentifiers = (
342
+ description?: string | Json,
343
+ options?: ExtractTextOptions
344
+ ): string => {
345
+ if (!description) return '';
346
+
347
+ const maxLen = options?.maxIdentifierLength ?? 30;
348
+
349
+ try {
350
+ const parsed =
351
+ typeof description === 'string' ? JSON.parse(description) : description;
352
+
353
+ const extractText = (
354
+ node: JSONContent,
355
+ depth = 0,
356
+ listCounter?: number
357
+ ): string => {
358
+ if (node.type === 'text') {
359
+ return node.text || '';
360
+ }
361
+
362
+ if (node.type === 'hardBreak') {
363
+ return '\n';
364
+ }
365
+
366
+ if (node.type === 'horizontalRule') {
367
+ return '\n---\n';
368
+ }
369
+
370
+ if (node.type === 'paragraph') {
371
+ const text =
372
+ node.content?.map((child) => extractText(child, depth)).join('') ||
373
+ '';
374
+ return `${text}\n`;
375
+ }
376
+
377
+ if (node.type === 'heading') {
378
+ const text =
379
+ node.content?.map((child) => extractText(child, depth)).join('') ||
380
+ '';
381
+ const level = node.attrs?.level || 1;
382
+ const prefix = '#'.repeat(level);
383
+ return `${prefix} ${text}\n`;
384
+ }
385
+
386
+ if (node.type === 'blockquote') {
387
+ const text =
388
+ node.content
389
+ ?.map((child) => extractText(child, depth + 1))
390
+ .join('') || '';
391
+ return `${text
392
+ .split('\n')
393
+ .filter((line) => line.trim())
394
+ .map((line) => `> ${line}`)
395
+ .join('\n')}\n`;
396
+ }
397
+
398
+ if (node.type === 'codeBlock') {
399
+ const text =
400
+ node.content?.map((child) => extractText(child, depth)).join('') ||
401
+ '';
402
+ const language = node.attrs?.language || '';
403
+ return `\`\`\`${language}\n${text}\`\`\`\n`;
404
+ }
405
+
406
+ if (node.type === 'bulletList') {
407
+ const items =
408
+ node.content?.map((child) => extractText(child, depth)).join('') ||
409
+ '';
410
+ return `${items}\n`;
411
+ }
412
+
413
+ if (node.type === 'orderedList') {
414
+ let counter = node.attrs?.start || 1;
415
+ const items =
416
+ node.content
417
+ ?.map((child) => {
418
+ const text = extractText(child, depth, counter);
419
+ counter++;
420
+ return text;
421
+ })
422
+ .join('') || '';
423
+ return `${items}\n`;
424
+ }
425
+
426
+ if (node.type === 'listItem') {
427
+ const text =
428
+ node.content
429
+ ?.map((child) => extractText(child, depth + 1))
430
+ .join('')
431
+ .trim() || '';
432
+ const indent = ' '.repeat(depth);
433
+ const prefix =
434
+ typeof listCounter === 'number' ? `${listCounter}.` : '•';
435
+ return `${indent}${prefix} ${text}\n`;
436
+ }
437
+
438
+ if (node.type === 'taskList') {
439
+ const items =
440
+ node.content?.map((child) => extractText(child, depth)).join('') ||
441
+ '';
442
+ return `${items}\n`;
443
+ }
444
+
445
+ if (node.type === 'taskItem') {
446
+ const text =
447
+ node.content
448
+ ?.map((child) => extractText(child, depth + 1))
449
+ .join('')
450
+ .trim() || '';
451
+ const indent = ' '.repeat(depth);
452
+ const checkbox = node.attrs?.checked ? '[x]' : '[ ]';
453
+ return `${indent}${checkbox} ${text}\n`;
454
+ }
455
+
456
+ if (node.type === 'table') {
457
+ const rows =
458
+ node.content?.map((child) => extractText(child, depth)).join('') ||
459
+ '';
460
+ return `\n${rows}\n`;
461
+ }
462
+
463
+ if (node.type === 'tableRow') {
464
+ const cells =
465
+ node.content?.map((child) => extractText(child, depth)).join(' | ') ||
466
+ '';
467
+ return `| ${cells} |\n`;
468
+ }
469
+
470
+ if (node.type === 'tableCell' || node.type === 'tableHeader') {
471
+ const text =
472
+ node.content
473
+ ?.map((child) => extractText(child, depth))
474
+ .join('')
475
+ .trim() || '';
476
+ return text;
477
+ }
478
+
479
+ // Media nodes with unique identifiers
480
+ if (node.type === 'image' || node.type === 'imageResize') {
481
+ const src = node.attrs?.src;
482
+ const alt = node.attrs?.alt;
483
+ if (src) {
484
+ const filename = extractFilename(src, maxLen);
485
+ return alt ? `[Image: ${alt} (${filename})]` : `[Image: ${filename}]`;
486
+ }
487
+ return alt ? `[Image: ${alt}]` : '[Image]';
488
+ }
489
+
490
+ if (node.type === 'video') {
491
+ const src = node.attrs?.src;
492
+ if (src) {
493
+ const filename = extractFilename(src, maxLen);
494
+ return `[Video: ${filename}]`;
495
+ }
496
+ return '[Video]';
497
+ }
498
+
499
+ if (node.type === 'youtube') {
500
+ const src = node.attrs?.src || node.attrs?.videoId;
501
+ if (src) {
502
+ try {
503
+ const url = new URL(src);
504
+ const videoId =
505
+ url.searchParams.get('v') || url.pathname.split('/').pop();
506
+ return videoId ? `[YouTube: ${videoId}]` : '[YouTube Video]';
507
+ } catch {
508
+ const id = src.length > 20 ? `${src.slice(0, 17)}...` : src;
509
+ return `[YouTube: ${id}]`;
510
+ }
511
+ }
512
+ return '[YouTube Video]';
513
+ }
514
+
515
+ if (node.type === 'mention') {
516
+ const label = node.attrs?.label || node.attrs?.id || 'mention';
517
+ const id = node.attrs?.id;
518
+ // Include ID in identifier for unique tracking
519
+ if (id && id !== label) {
520
+ return `@${label}#${id.slice(0, 8)}`;
521
+ }
522
+ return `@${label}`;
523
+ }
524
+
525
+ if (node.content) {
526
+ return node.content.map((child) => extractText(child, depth)).join('');
527
+ }
528
+
529
+ return '';
530
+ };
531
+
532
+ const result = extractText(parsed).trim();
533
+ return result.replace(/\n{3,}/g, '\n\n');
534
+ } catch {
535
+ return typeof description === 'string' ? description : String(description);
536
+ }
537
+ };
@@ -0,0 +1,63 @@
1
+ import { format as dateFnsFormat } from 'date-fns';
2
+
3
+ export type TimeFormat = '12h' | '24h';
4
+
5
+ /**
6
+ * Format a Date object's time according to the user's time format preference
7
+ * @param date - The date to format
8
+ * @param format - '12h' for AM/PM format (1:30 PM) or '24h' for 24-hour format (13:30)
9
+ * @returns Formatted time string
10
+ */
11
+ export function formatTimeByPreference(
12
+ date: Date,
13
+ format: TimeFormat = '12h'
14
+ ): string {
15
+ return format === '24h'
16
+ ? dateFnsFormat(date, 'HH:mm')
17
+ : dateFnsFormat(date, 'h:mm a');
18
+ }
19
+
20
+ /**
21
+ * Get the date-fns format pattern for the given time format preference
22
+ * @param format - '12h' for AM/PM format or '24h' for 24-hour format
23
+ * @returns The date-fns format pattern string
24
+ */
25
+ export function getTimeFormatPattern(format: TimeFormat = '12h'): string {
26
+ return format === '24h' ? 'HH:mm' : 'h:mm a';
27
+ }
28
+
29
+ /**
30
+ * Get the date-fns format pattern for date with time according to preference
31
+ * @param format - '12h' for AM/PM format or '24h' for 24-hour format
32
+ * @param datePattern - The date portion pattern (default: 'MMM d, yyyy')
33
+ * @returns The combined date-fns format pattern string
34
+ */
35
+ export function getDateTimeFormatPattern(
36
+ format: TimeFormat = '12h',
37
+ datePattern: string = 'MMM d, yyyy'
38
+ ): string {
39
+ const timePattern = getTimeFormatPattern(format);
40
+ return `${datePattern} ${timePattern}`;
41
+ }
42
+
43
+ // Utility function to parse time from timetz format (e.g., "09:00:00+00")
44
+ // Converts hour 0 to 24 to support 1-24 hour format used in the application
45
+ export const parseTimeFromTimetz = (
46
+ timetz: string | undefined
47
+ ): number | undefined => {
48
+ if (!timetz) return undefined;
49
+
50
+ // Validate basic format before splitting
51
+ if (!timetz.includes(':')) return undefined;
52
+
53
+ const timePart = timetz.split(':')[0];
54
+ if (!timePart) return undefined;
55
+
56
+ const hour = parseInt(timePart, 10);
57
+
58
+ // Validate hour is a valid number and in expected range
59
+ if (Number.isNaN(hour) || hour < 0 || hour > 23) return undefined;
60
+
61
+ // Convert 0 to 24 for comparison (which uses 1-24 format)
62
+ return hour === 0 ? 24 : hour;
63
+ };
@@ -0,0 +1,73 @@
1
+ import dayjs from 'dayjs';
2
+ import isoWeek from 'dayjs/plugin/isoWeek';
3
+ import timezone from 'dayjs/plugin/timezone';
4
+ import utc from 'dayjs/plugin/utc';
5
+
6
+ dayjs.extend(utc);
7
+ dayjs.extend(timezone);
8
+ dayjs.extend(isoWeek);
9
+
10
+ export type TimeTrackerViewMode = 'day' | 'week' | 'month';
11
+
12
+ type TimeTrackerPeriodBounds = {
13
+ startOfPeriod: Date;
14
+ endOfPeriod: Date;
15
+ };
16
+
17
+ type FormatTimeTrackerDateRangeOptions = {
18
+ locale?: string;
19
+ referenceDate?: Date;
20
+ };
21
+
22
+ const resolveViewMode = (viewMode: TimeTrackerViewMode) =>
23
+ viewMode === 'week' ? 'isoWeek' : viewMode;
24
+
25
+ export const getTimeTrackerPeriodBounds = (
26
+ currentDate: Date,
27
+ viewMode: TimeTrackerViewMode,
28
+ userTimezone: string
29
+ ): TimeTrackerPeriodBounds => {
30
+ const view = resolveViewMode(viewMode);
31
+ const start = dayjs(currentDate).tz(userTimezone).startOf(view);
32
+ const end = dayjs(currentDate).tz(userTimezone).endOf(view);
33
+
34
+ return {
35
+ startOfPeriod: start.toDate(),
36
+ endOfPeriod: end.toDate(),
37
+ };
38
+ };
39
+
40
+ export const formatTimeTrackerDateRange = (
41
+ start: Date,
42
+ end: Date,
43
+ viewMode: TimeTrackerViewMode,
44
+ options: FormatTimeTrackerDateRangeOptions = {}
45
+ ): string => {
46
+ const { locale, referenceDate } = options;
47
+ const now = referenceDate ?? new Date();
48
+
49
+ if (viewMode === 'day') {
50
+ return start.toLocaleDateString(locale, {
51
+ weekday: 'long',
52
+ month: 'long',
53
+ day: 'numeric',
54
+ year: start.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
55
+ });
56
+ }
57
+
58
+ if (viewMode === 'month') {
59
+ return start.toLocaleDateString(locale, {
60
+ month: 'long',
61
+ year: 'numeric',
62
+ });
63
+ }
64
+
65
+ return `${start.toLocaleDateString(locale, {
66
+ month: 'short',
67
+ day: 'numeric',
68
+ })} - ${end.toLocaleDateString(locale, {
69
+ month: 'short',
70
+ day: 'numeric',
71
+ year: end.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
72
+ })}`;
73
+ };