@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
|
@@ -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
|
+
};
|