@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,374 @@
1
+ import type { JSONContent } from '@tiptap/react';
2
+
3
+ /** Priority type matching the codebase convention */
4
+ export type TaskPriorityType = 'critical' | 'high' | 'normal' | 'low';
5
+
6
+ /** Icon component type (generic to avoid lucide-react dependency) */
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ export type IconComponent = React.ComponentType<any>;
9
+
10
+ /** Snapshot of a task at a point in history */
11
+ export interface TaskSnapshot {
12
+ id: string;
13
+ name: string;
14
+ description: string | JSONContent | null;
15
+ priority: TaskPriorityType | null;
16
+ start_date: string | null;
17
+ end_date: string | null;
18
+ estimation_points: number | null;
19
+ list_id: string;
20
+ list_name?: string | null;
21
+ completed: boolean;
22
+ assignees: SnapshotAssignee[];
23
+ labels: SnapshotLabel[];
24
+ projects: SnapshotProject[];
25
+ }
26
+
27
+ export interface SnapshotAssignee {
28
+ id: string;
29
+ user_id?: string;
30
+ display_name: string | null;
31
+ avatar_url?: string | null;
32
+ }
33
+
34
+ export interface SnapshotLabel {
35
+ id: string;
36
+ name: string;
37
+ color?: string | null;
38
+ }
39
+
40
+ export interface SnapshotProject {
41
+ id: string;
42
+ name: string;
43
+ }
44
+
45
+ /** Current task state for comparison */
46
+ export interface CurrentTaskState {
47
+ id: string;
48
+ name: string;
49
+ description: string | JSONContent | null;
50
+ priority: TaskPriorityType | null;
51
+ start_date: string | null;
52
+ end_date: string | null;
53
+ estimation_points: number | null;
54
+ list_id: string;
55
+ list_name?: string | null;
56
+ completed: boolean;
57
+ assignees?: Array<{ id?: string; user_id?: string }>;
58
+ labels?: Array<{ id: string }>;
59
+ projects?: Array<{ id: string }>;
60
+ }
61
+
62
+ /** Field that can be compared and reverted */
63
+ export type ComparableField =
64
+ | 'name'
65
+ | 'description'
66
+ | 'priority'
67
+ | 'start_date'
68
+ | 'end_date'
69
+ | 'estimation_points'
70
+ | 'list_id'
71
+ | 'completed'
72
+ | 'assignees'
73
+ | 'labels'
74
+ | 'projects';
75
+
76
+ export const ALL_COMPARABLE_FIELDS: ComparableField[] = [
77
+ 'name',
78
+ 'description',
79
+ 'priority',
80
+ 'start_date',
81
+ 'end_date',
82
+ 'estimation_points',
83
+ 'list_id',
84
+ 'completed',
85
+ 'assignees',
86
+ 'labels',
87
+ 'projects',
88
+ ];
89
+
90
+ /** Field display configuration */
91
+ export interface FieldDisplayInfo {
92
+ label: string;
93
+ icon: IconComponent;
94
+ category: 'core' | 'dates' | 'relationships';
95
+ }
96
+
97
+ /**
98
+ * Deep compare two JSON content objects for equality
99
+ */
100
+ export function isDescriptionEqual(
101
+ a: string | JSONContent | null | undefined,
102
+ b: string | JSONContent | null | undefined
103
+ ): boolean {
104
+ // Both null/undefined
105
+ if (!a && !b) return true;
106
+ // One is null/undefined
107
+ if (!a || !b) return false;
108
+
109
+ // Convert strings to objects if needed
110
+ const objA = typeof a === 'string' ? tryParseJson(a) : a;
111
+ const objB = typeof b === 'string' ? tryParseJson(b) : b;
112
+
113
+ // If parsing failed, compare as strings
114
+ if (objA === null || objB === null) {
115
+ return String(a) === String(b);
116
+ }
117
+
118
+ // Deep comparison
119
+ return JSON.stringify(objA) === JSON.stringify(objB);
120
+ }
121
+
122
+ /**
123
+ * Try to parse a JSON string, return null if invalid
124
+ */
125
+ function tryParseJson(str: string): JSONContent | null {
126
+ try {
127
+ return JSON.parse(str);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Compare two arrays by extracting IDs
135
+ */
136
+ function arraysEqualById(
137
+ a: Array<{ id: string }> | undefined,
138
+ b: Array<{ id: string }> | undefined
139
+ ): boolean {
140
+ const aIds = new Set((a || []).map((item) => item.id));
141
+ const bIds = new Set((b || []).map((item) => item.id));
142
+
143
+ if (aIds.size !== bIds.size) return false;
144
+
145
+ for (const id of aIds) {
146
+ if (!bIds.has(id)) return false;
147
+ }
148
+
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Compare snapshot with current task state and return changed fields
154
+ */
155
+ export function getChangedFields(
156
+ snapshot: TaskSnapshot,
157
+ current: CurrentTaskState
158
+ ): ComparableField[] {
159
+ const changedFields: ComparableField[] = [];
160
+
161
+ // Core text field
162
+ if (snapshot.name !== current.name) {
163
+ changedFields.push('name');
164
+ }
165
+
166
+ // Description (deep comparison)
167
+ if (!isDescriptionEqual(snapshot.description, current.description)) {
168
+ changedFields.push('description');
169
+ }
170
+
171
+ // Priority
172
+ if (snapshot.priority !== current.priority) {
173
+ changedFields.push('priority');
174
+ }
175
+
176
+ // Dates
177
+ if (
178
+ normalizeDate(snapshot.start_date) !== normalizeDate(current.start_date)
179
+ ) {
180
+ changedFields.push('start_date');
181
+ }
182
+ if (normalizeDate(snapshot.end_date) !== normalizeDate(current.end_date)) {
183
+ changedFields.push('end_date');
184
+ }
185
+
186
+ // Estimation points
187
+ if (snapshot.estimation_points !== current.estimation_points) {
188
+ changedFields.push('estimation_points');
189
+ }
190
+
191
+ // List/status
192
+ if (snapshot.list_id !== current.list_id) {
193
+ changedFields.push('list_id');
194
+ }
195
+
196
+ // Completed status
197
+ if (snapshot.completed !== current.completed) {
198
+ changedFields.push('completed');
199
+ }
200
+
201
+ // Relationships
202
+ const snapshotAssigneeIds =
203
+ snapshot.assignees?.map((a) => ({ id: a.user_id || a.id })) || [];
204
+ const currentAssigneeIds =
205
+ current.assignees?.map((a) => ({ id: a.user_id || a.id || '' })) || [];
206
+ if (!arraysEqualById(snapshotAssigneeIds, currentAssigneeIds)) {
207
+ changedFields.push('assignees');
208
+ }
209
+
210
+ if (!arraysEqualById(snapshot.labels, current.labels)) {
211
+ changedFields.push('labels');
212
+ }
213
+
214
+ if (!arraysEqualById(snapshot.projects, current.projects)) {
215
+ changedFields.push('projects');
216
+ }
217
+
218
+ return changedFields;
219
+ }
220
+
221
+ /**
222
+ * Normalize date string for comparison (to ISO date string or null)
223
+ */
224
+ function normalizeDate(date: string | null | undefined): string | null {
225
+ if (!date) return null;
226
+ try {
227
+ return new Date(date).toISOString();
228
+ } catch {
229
+ return date;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Get display information for a field
235
+ * Note: Icons are passed as a parameter to avoid importing lucide-react in utils package
236
+ */
237
+ export function getFieldDisplayInfo(
238
+ fieldName: ComparableField,
239
+ t: (key: string, options?: { defaultValue?: string }) => string,
240
+ icons: {
241
+ FileText: IconComponent;
242
+ Flag: IconComponent;
243
+ Clock: IconComponent;
244
+ Calendar: IconComponent;
245
+ Target: IconComponent;
246
+ Layers: IconComponent;
247
+ CheckCircle2: IconComponent;
248
+ Users: IconComponent;
249
+ Tag: IconComponent;
250
+ FolderKanban: IconComponent;
251
+ }
252
+ ): FieldDisplayInfo {
253
+ const fieldConfig: Record<ComparableField, FieldDisplayInfo> = {
254
+ name: {
255
+ label: t('field.name', { defaultValue: 'Name' }),
256
+ icon: icons.FileText,
257
+ category: 'core',
258
+ },
259
+ description: {
260
+ label: t('field.description', { defaultValue: 'Description' }),
261
+ icon: icons.FileText,
262
+ category: 'core',
263
+ },
264
+ priority: {
265
+ label: t('field.priority', { defaultValue: 'Priority' }),
266
+ icon: icons.Flag,
267
+ category: 'core',
268
+ },
269
+ start_date: {
270
+ label: t('field.start_date', { defaultValue: 'Start Date' }),
271
+ icon: icons.Clock,
272
+ category: 'dates',
273
+ },
274
+ end_date: {
275
+ label: t('field.end_date', { defaultValue: 'Due Date' }),
276
+ icon: icons.Calendar,
277
+ category: 'dates',
278
+ },
279
+ estimation_points: {
280
+ label: t('field.estimation', { defaultValue: 'Estimation' }),
281
+ icon: icons.Target,
282
+ category: 'core',
283
+ },
284
+ list_id: {
285
+ label: t('field.list', { defaultValue: 'List' }),
286
+ icon: icons.Layers,
287
+ category: 'core',
288
+ },
289
+ completed: {
290
+ label: t('field.completed', { defaultValue: 'Completed' }),
291
+ icon: icons.CheckCircle2,
292
+ category: 'core',
293
+ },
294
+ assignees: {
295
+ label: t('field.assignees', { defaultValue: 'Assignees' }),
296
+ icon: icons.Users,
297
+ category: 'relationships',
298
+ },
299
+ labels: {
300
+ label: t('field.labels', { defaultValue: 'Labels' }),
301
+ icon: icons.Tag,
302
+ category: 'relationships',
303
+ },
304
+ projects: {
305
+ label: t('field.projects', { defaultValue: 'Projects' }),
306
+ icon: icons.FolderKanban,
307
+ category: 'relationships',
308
+ },
309
+ };
310
+
311
+ return fieldConfig[fieldName];
312
+ }
313
+
314
+ /**
315
+ * Get priority label for a priority value
316
+ */
317
+ export function getPriorityLabel(
318
+ priority: TaskPriorityType | null,
319
+ t: (key: string, options?: { defaultValue?: string }) => string
320
+ ): string {
321
+ if (priority === null) return t('priority.none', { defaultValue: 'None' });
322
+
323
+ const labels: Record<TaskPriorityType, string> = {
324
+ low: t('priority.low', { defaultValue: 'Low' }),
325
+ normal: t('priority.normal', { defaultValue: 'Normal' }),
326
+ high: t('priority.high', { defaultValue: 'High' }),
327
+ critical: t('priority.critical', { defaultValue: 'Critical' }),
328
+ };
329
+
330
+ return labels[priority] || String(priority);
331
+ }
332
+
333
+ /**
334
+ * Format a date for display
335
+ */
336
+ export function formatDateForDisplay(
337
+ date: string | null | undefined,
338
+ locale: string = 'en'
339
+ ): string {
340
+ if (!date) return '-';
341
+
342
+ try {
343
+ return new Date(date).toLocaleDateString(locale, {
344
+ year: 'numeric',
345
+ month: 'short',
346
+ day: 'numeric',
347
+ hour: '2-digit',
348
+ minute: '2-digit',
349
+ });
350
+ } catch {
351
+ return date;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Get a summary of relationship changes
357
+ */
358
+ export function getRelationshipDiff<T extends { id: string; name?: string }>(
359
+ snapshotItems: T[],
360
+ currentItems: T[]
361
+ ): {
362
+ added: T[];
363
+ removed: T[];
364
+ unchanged: T[];
365
+ } {
366
+ const snapshotIds = new Set(snapshotItems.map((i) => i.id));
367
+ const currentIds = new Set(currentItems.map((i) => i.id));
368
+
369
+ const added = currentItems.filter((i) => !snapshotIds.has(i.id));
370
+ const removed = snapshotItems.filter((i) => !currentIds.has(i.id));
371
+ const unchanged = snapshotItems.filter((i) => currentIds.has(i.id));
372
+
373
+ return { added, removed, unchanged };
374
+ }
@@ -0,0 +1,81 @@
1
+ import * as Diff from 'diff';
2
+
3
+ export interface DiffChange {
4
+ type: 'added' | 'removed' | 'unchanged';
5
+ value: string;
6
+ }
7
+
8
+ export interface DiffStats {
9
+ added: number;
10
+ removed: number;
11
+ unchanged: number;
12
+ }
13
+
14
+ /**
15
+ * Computes a line-level diff between two text strings.
16
+ * Returns an array of changes with their type (added/removed/unchanged).
17
+ */
18
+ export function computeLineDiff(
19
+ oldText: string,
20
+ newText: string
21
+ ): DiffChange[] {
22
+ const changes = Diff.diffLines(oldText, newText);
23
+ return changes.map((change) => ({
24
+ type: change.added ? 'added' : change.removed ? 'removed' : 'unchanged',
25
+ value: change.value,
26
+ }));
27
+ }
28
+
29
+ /**
30
+ * Computes a word-level diff between two text strings.
31
+ * Useful for smaller changes within lines.
32
+ */
33
+ export function computeWordDiff(
34
+ oldText: string,
35
+ newText: string
36
+ ): DiffChange[] {
37
+ const changes = Diff.diffWords(oldText, newText);
38
+ return changes.map((change) => ({
39
+ type: change.added ? 'added' : change.removed ? 'removed' : 'unchanged',
40
+ value: change.value,
41
+ }));
42
+ }
43
+
44
+ /**
45
+ * Computes statistics from a diff result.
46
+ * Counts the number of lines added, removed, and unchanged.
47
+ */
48
+ export function getDiffStats(diff: DiffChange[]): DiffStats {
49
+ return diff.reduce(
50
+ (acc, d) => {
51
+ const lines = d.value.split('\n').filter(Boolean).length;
52
+ acc[d.type] += lines;
53
+ return acc;
54
+ },
55
+ { added: 0, removed: 0, unchanged: 0 }
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Checks if a diff has any meaningful changes.
61
+ */
62
+ export function hasDiffChanges(diff: DiffChange[]): boolean {
63
+ return diff.some((d) => d.type !== 'unchanged');
64
+ }
65
+
66
+ /**
67
+ * Formats a diff for display, adding line prefixes.
68
+ */
69
+ export function formatDiffForDisplay(diff: DiffChange[]): string {
70
+ return diff
71
+ .map((d) => {
72
+ const prefix =
73
+ d.type === 'added' ? '+ ' : d.type === 'removed' ? '- ' : ' ';
74
+ return d.value
75
+ .split('\n')
76
+ .filter(Boolean)
77
+ .map((line) => prefix + line)
78
+ .join('\n');
79
+ })
80
+ .join('\n');
81
+ }