@tuturuuu/utils 0.8.0 → 0.10.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.
@@ -0,0 +1,457 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import type { JSONContent } from '@tiptap/core';
3
+ import { Node as ProseMirrorNode } from 'prosemirror-model';
4
+ import {
5
+ hasMeaningfulTaskDescriptionContent,
6
+ parseTaskDescriptionContent,
7
+ taskDescriptionSchema,
8
+ } from './task-description-content';
9
+ import { getDescriptionText } from './text-helper';
10
+ import {
11
+ convertJsonContentToYjsState,
12
+ convertYjsStateToJsonContent,
13
+ } from './yjs-helper';
14
+
15
+ export type TaskDescriptionInputFormat = 'json' | 'markdown' | 'text';
16
+ export type TaskDescriptionYjsFormat = 'bytes-json' | 'yjs-base64';
17
+
18
+ export interface TaskDescriptionPayload {
19
+ content: JSONContent;
20
+ description: string | null;
21
+ description_yjs_state: number[] | null;
22
+ }
23
+
24
+ type InlineMark = NonNullable<JSONContent['marks']>[number];
25
+ type ListKind = 'bulletList' | 'orderedList' | 'taskList';
26
+
27
+ const MARK_PATTERNS = [
28
+ { mark: 'code', pattern: /`([^`]+)`/u },
29
+ { mark: 'bold', pattern: /\*\*([^*]+)\*\*/u },
30
+ { mark: 'strike', pattern: /~~(.+?)~~/u },
31
+ { mark: 'italic', pattern: /\*([^*\n]+)\*/u },
32
+ ] as const;
33
+
34
+ function textNode(text: string, marks?: JSONContent['marks']): JSONContent[] {
35
+ if (!text) return [];
36
+ return marks?.length
37
+ ? [{ marks, text, type: 'text' }]
38
+ : [{ text, type: 'text' }];
39
+ }
40
+
41
+ function withMark(type: InlineMark['type'], attrs?: InlineMark['attrs']) {
42
+ const mark: InlineMark = { type };
43
+ if (attrs && Object.keys(attrs).length > 0) {
44
+ mark.attrs = attrs;
45
+ }
46
+ return mark;
47
+ }
48
+
49
+ function findEarliestInlineToken(text: string):
50
+ | {
51
+ attrs?: Record<string, unknown>;
52
+ before: string;
53
+ inner: string;
54
+ mark: InlineMark['type'];
55
+ rest: string;
56
+ }
57
+ | undefined {
58
+ const linkMatch = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/u.exec(text);
59
+ let best:
60
+ | {
61
+ attrs?: Record<string, unknown>;
62
+ before: string;
63
+ inner: string;
64
+ index: number;
65
+ mark: InlineMark['type'];
66
+ rest: string;
67
+ }
68
+ | undefined;
69
+
70
+ if (linkMatch?.index !== undefined) {
71
+ best = {
72
+ attrs: {
73
+ href: linkMatch[2],
74
+ rel: null,
75
+ target: null,
76
+ title: linkMatch[3] ?? null,
77
+ },
78
+ before: text.slice(0, linkMatch.index),
79
+ index: linkMatch.index,
80
+ inner: linkMatch[1] ?? '',
81
+ mark: 'link',
82
+ rest: text.slice(linkMatch.index + linkMatch[0].length),
83
+ };
84
+ }
85
+
86
+ for (const { mark, pattern } of MARK_PATTERNS) {
87
+ const match = pattern.exec(text);
88
+ if (!match?.[0] || match.index === undefined) continue;
89
+ if (best && match.index >= best.index) continue;
90
+
91
+ best = {
92
+ before: text.slice(0, match.index),
93
+ index: match.index,
94
+ inner: match[1] ?? '',
95
+ mark,
96
+ rest: text.slice(match.index + match[0].length),
97
+ };
98
+ }
99
+
100
+ return best;
101
+ }
102
+
103
+ function parseInlineMarkdown(
104
+ text: string,
105
+ marks: JSONContent['marks'] = []
106
+ ): JSONContent[] {
107
+ const token = findEarliestInlineToken(text);
108
+ if (!token) return textNode(text, marks);
109
+
110
+ return [
111
+ ...textNode(token.before, marks),
112
+ ...parseInlineMarkdown(token.inner, [
113
+ ...marks,
114
+ withMark(token.mark, token.attrs),
115
+ ]),
116
+ ...parseInlineMarkdown(token.rest, marks),
117
+ ];
118
+ }
119
+
120
+ function paragraphFromText(text: string, markdown = false): JSONContent {
121
+ const lines = text.split('\n');
122
+ const content: JSONContent[] = [];
123
+
124
+ for (const [index, line] of lines.entries()) {
125
+ if (index > 0) {
126
+ content.push({ type: 'hardBreak' });
127
+ }
128
+ content.push(...(markdown ? parseInlineMarkdown(line) : textNode(line)));
129
+ }
130
+
131
+ return content.length > 0
132
+ ? { content, type: 'paragraph' }
133
+ : { type: 'paragraph' };
134
+ }
135
+
136
+ function codeBlockFromLines(lines: string[], language?: string): JSONContent {
137
+ return {
138
+ attrs: { language: language || null },
139
+ content:
140
+ lines.length > 0 ? [{ text: lines.join('\n'), type: 'text' }] : undefined,
141
+ type: 'codeBlock',
142
+ };
143
+ }
144
+
145
+ function isHorizontalRule(line: string) {
146
+ return /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(line);
147
+ }
148
+
149
+ function getListLine(line: string):
150
+ | {
151
+ checked?: boolean;
152
+ kind: ListKind;
153
+ start?: number;
154
+ text: string;
155
+ }
156
+ | undefined {
157
+ const taskMatch = /^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/u.exec(line);
158
+ if (taskMatch) {
159
+ return {
160
+ checked: taskMatch[1]?.toLowerCase() === 'x',
161
+ kind: 'taskList',
162
+ text: taskMatch[2] ?? '',
163
+ };
164
+ }
165
+
166
+ const bulletMatch = /^\s*[-*+]\s+(.+)$/u.exec(line);
167
+ if (bulletMatch) {
168
+ return { kind: 'bulletList', text: bulletMatch[1] ?? '' };
169
+ }
170
+
171
+ const orderedMatch = /^\s*(\d+)[.)]\s+(.+)$/u.exec(line);
172
+ if (orderedMatch) {
173
+ return {
174
+ kind: 'orderedList',
175
+ start: Number.parseInt(orderedMatch[1] ?? '1', 10),
176
+ text: orderedMatch[2] ?? '',
177
+ };
178
+ }
179
+ }
180
+
181
+ function isBlockStart(line: string) {
182
+ return (
183
+ /^```/u.test(line) ||
184
+ /^#{1,6}\s+/u.test(line) ||
185
+ /^>\s?/u.test(line) ||
186
+ isHorizontalRule(line) ||
187
+ Boolean(getListLine(line))
188
+ );
189
+ }
190
+
191
+ function createListBlock(
192
+ kind: ListKind,
193
+ entries: Array<{ checked?: boolean; text: string }>,
194
+ start?: number
195
+ ): JSONContent {
196
+ if (kind === 'taskList') {
197
+ return {
198
+ content: entries.map((entry) => ({
199
+ attrs: { checked: entry.checked === true },
200
+ content: [paragraphFromText(entry.text, true)],
201
+ type: 'taskItem',
202
+ })),
203
+ type: 'taskList',
204
+ };
205
+ }
206
+
207
+ return {
208
+ attrs: kind === 'orderedList' ? { start: start ?? 1 } : undefined,
209
+ content: entries.map((entry) => ({
210
+ content: [paragraphFromText(entry.text, true)],
211
+ type: 'listItem',
212
+ })),
213
+ type: kind,
214
+ };
215
+ }
216
+
217
+ function parseMarkdownBlocks(markdown: string): JSONContent[] {
218
+ const lines = markdown.replace(/\r\n?/gu, '\n').split('\n');
219
+ const blocks: JSONContent[] = [];
220
+ let index = 0;
221
+
222
+ while (index < lines.length) {
223
+ const line = lines[index] ?? '';
224
+
225
+ if (!line.trim()) {
226
+ index += 1;
227
+ continue;
228
+ }
229
+
230
+ const fence = /^```([^\s`]*)\s*$/u.exec(line);
231
+ if (fence) {
232
+ const codeLines: string[] = [];
233
+ index += 1;
234
+ while (index < lines.length && !/^```\s*$/u.test(lines[index] ?? '')) {
235
+ codeLines.push(lines[index] ?? '');
236
+ index += 1;
237
+ }
238
+ if (index < lines.length) index += 1;
239
+ blocks.push(codeBlockFromLines(codeLines, fence[1]));
240
+ continue;
241
+ }
242
+
243
+ const heading = /^(#{1,6})\s+(.+)$/u.exec(line);
244
+ if (heading) {
245
+ blocks.push({
246
+ attrs: { level: heading[1]?.length ?? 1, textAlign: null },
247
+ content: parseInlineMarkdown(heading[2] ?? ''),
248
+ type: 'heading',
249
+ });
250
+ index += 1;
251
+ continue;
252
+ }
253
+
254
+ if (isHorizontalRule(line)) {
255
+ blocks.push({ type: 'horizontalRule' });
256
+ index += 1;
257
+ continue;
258
+ }
259
+
260
+ if (/^>\s?/u.test(line)) {
261
+ const quoted: string[] = [];
262
+ while (index < lines.length && /^>\s?/u.test(lines[index] ?? '')) {
263
+ quoted.push((lines[index] ?? '').replace(/^>\s?/u, ''));
264
+ index += 1;
265
+ }
266
+ const content = parseMarkdownBlocks(quoted.join('\n'));
267
+ blocks.push({
268
+ content: content.length > 0 ? content : [{ type: 'paragraph' }],
269
+ type: 'blockquote',
270
+ });
271
+ continue;
272
+ }
273
+
274
+ const listLine = getListLine(line);
275
+ if (listLine) {
276
+ const entries: Array<{ checked?: boolean; text: string }> = [];
277
+ const listKind = listLine.kind;
278
+ const start = listLine.start;
279
+
280
+ while (index < lines.length) {
281
+ const item = getListLine(lines[index] ?? '');
282
+ if (!item || item.kind !== listKind) break;
283
+ entries.push({ checked: item.checked, text: item.text });
284
+ index += 1;
285
+ }
286
+
287
+ blocks.push(createListBlock(listKind, entries, start));
288
+ continue;
289
+ }
290
+
291
+ const paragraphLines = [line];
292
+ index += 1;
293
+ while (
294
+ index < lines.length &&
295
+ (lines[index] ?? '').trim() &&
296
+ !isBlockStart(lines[index] ?? '')
297
+ ) {
298
+ paragraphLines.push(lines[index] ?? '');
299
+ index += 1;
300
+ }
301
+ blocks.push(paragraphFromText(paragraphLines.join('\n'), true));
302
+ }
303
+
304
+ return blocks;
305
+ }
306
+
307
+ export function createPlainTextTaskDescriptionContent(text: string) {
308
+ const normalizedText = text.replace(/\r\n?/gu, '\n');
309
+ const paragraphs = normalizedText.split(/\n{2,}/u);
310
+ const content = paragraphs.map((paragraph) =>
311
+ paragraphFromText(paragraph, false)
312
+ );
313
+
314
+ return {
315
+ content: content.length > 0 ? content : [{ type: 'paragraph' }],
316
+ type: 'doc',
317
+ } satisfies JSONContent;
318
+ }
319
+
320
+ export function parseMarkdownTaskDescriptionContent(markdown: string) {
321
+ const content = parseMarkdownBlocks(markdown);
322
+ return {
323
+ content: content.length > 0 ? content : [{ type: 'paragraph' }],
324
+ type: 'doc',
325
+ } satisfies JSONContent;
326
+ }
327
+
328
+ export function normalizeTaskDescriptionContent(
329
+ content: JSONContent
330
+ ): JSONContent {
331
+ const transformed = parseTaskDescriptionContent(JSON.stringify(content));
332
+ return ProseMirrorNode.fromJSON(
333
+ taskDescriptionSchema,
334
+ transformed
335
+ ).toJSON() as JSONContent;
336
+ }
337
+
338
+ export function parseTaskDescriptionJson(input: string): JSONContent {
339
+ const parsed = JSON.parse(input) as unknown;
340
+ if (
341
+ !parsed ||
342
+ typeof parsed !== 'object' ||
343
+ Array.isArray(parsed) ||
344
+ (parsed as JSONContent).type !== 'doc'
345
+ ) {
346
+ throw new Error('Task description JSON must be a TipTap doc object');
347
+ }
348
+ return normalizeTaskDescriptionContent(parsed as JSONContent);
349
+ }
350
+
351
+ export function parseTaskDescriptionInput(
352
+ input: string,
353
+ format: TaskDescriptionInputFormat = 'text'
354
+ ): JSONContent {
355
+ if (format === 'json') {
356
+ return parseTaskDescriptionJson(input);
357
+ }
358
+ if (format === 'markdown') {
359
+ return normalizeTaskDescriptionContent(
360
+ parseMarkdownTaskDescriptionContent(input)
361
+ );
362
+ }
363
+ return normalizeTaskDescriptionContent(
364
+ createPlainTextTaskDescriptionContent(input)
365
+ );
366
+ }
367
+
368
+ export function parsePersistedTaskDescriptionContent({
369
+ description,
370
+ description_yjs_state,
371
+ }: {
372
+ description?: string | null;
373
+ description_yjs_state?: number[] | null;
374
+ }): JSONContent {
375
+ if (description_yjs_state && description_yjs_state.length > 0) {
376
+ return decodeTaskDescriptionYjsState(description_yjs_state);
377
+ }
378
+
379
+ try {
380
+ return normalizeTaskDescriptionContent(
381
+ parseTaskDescriptionContent(description ?? '')
382
+ );
383
+ } catch {
384
+ return normalizeTaskDescriptionContent(
385
+ createPlainTextTaskDescriptionContent(
386
+ getDescriptionText(description ?? '').trim()
387
+ )
388
+ );
389
+ }
390
+ }
391
+
392
+ export function encodeTaskDescriptionYjsState(content: JSONContent): number[] {
393
+ return Array.from(
394
+ convertJsonContentToYjsState(
395
+ normalizeTaskDescriptionContent(content),
396
+ taskDescriptionSchema
397
+ )
398
+ );
399
+ }
400
+
401
+ export function decodeTaskDescriptionYjsState(
402
+ yjsState: number[] | Uint8Array
403
+ ): JSONContent {
404
+ return convertYjsStateToJsonContent(
405
+ yjsState instanceof Uint8Array ? yjsState : Uint8Array.from(yjsState),
406
+ taskDescriptionSchema
407
+ );
408
+ }
409
+
410
+ export function taskDescriptionYjsStateToBase64(
411
+ yjsState: number[] | Uint8Array | null | undefined
412
+ ): string | null {
413
+ if (yjsState == null) return null;
414
+ const bytes =
415
+ yjsState instanceof Uint8Array ? yjsState : Uint8Array.from(yjsState);
416
+ return Buffer.from(bytes).toString('base64');
417
+ }
418
+
419
+ export function taskDescriptionYjsStateFromBase64(input: string): number[] {
420
+ return Array.from(Buffer.from(input.trim(), 'base64'));
421
+ }
422
+
423
+ export function taskDescriptionYjsStateFromBytesJson(input: string): number[] {
424
+ const parsed = JSON.parse(input) as unknown;
425
+ if (
426
+ !Array.isArray(parsed) ||
427
+ parsed.some((value) => !Number.isInteger(value) || value < 0 || value > 255)
428
+ ) {
429
+ throw new Error('Yjs bytes JSON must be an array of byte values');
430
+ }
431
+ return parsed as number[];
432
+ }
433
+
434
+ export function taskDescriptionPlainText(content: JSONContent): string {
435
+ return getDescriptionText(normalizeTaskDescriptionContent(content));
436
+ }
437
+
438
+ export function createTaskDescriptionPayload(
439
+ input: string,
440
+ format: TaskDescriptionInputFormat = 'text'
441
+ ): TaskDescriptionPayload {
442
+ const content = parseTaskDescriptionInput(input, format);
443
+
444
+ if (!hasMeaningfulTaskDescriptionContent(content)) {
445
+ return {
446
+ content,
447
+ description: null,
448
+ description_yjs_state: null,
449
+ };
450
+ }
451
+
452
+ return {
453
+ content,
454
+ description: JSON.stringify(content),
455
+ description_yjs_state: encodeTaskDescriptionYjsState(content),
456
+ };
457
+ }
@@ -165,6 +165,9 @@ export async function invalidateTaskCaches(
165
165
  promises.push(
166
166
  queryClient.invalidateQueries({ queryKey: ['task_lists', boardId] })
167
167
  );
168
+ promises.push(
169
+ queryClient.invalidateQueries({ queryKey: ['kanban-deadline-tasks'] })
170
+ );
168
171
  }
169
172
 
170
173
  promises.push(
@@ -1,15 +1,16 @@
1
1
  import type { JSONContent } from '@tiptap/core';
2
+ import {
3
+ decodeTaskDescriptionYjsState,
4
+ encodeTaskDescriptionYjsState,
5
+ parsePersistedTaskDescriptionContent,
6
+ parseTaskDescriptionInput,
7
+ } from './task-description-codec';
2
8
  import {
3
9
  hasMeaningfulTaskDescriptionContent,
4
10
  isValidTaskDescriptionContent,
5
- parseTaskDescriptionContent,
6
11
  taskDescriptionSchema,
7
12
  } from './task-description-content';
8
13
  import { getDescriptionText } from './text-helper';
9
- import {
10
- convertJsonContentToYjsState,
11
- convertYjsStateToJsonContent,
12
- } from './yjs-helper';
13
14
 
14
15
  export { isValidTaskDescriptionContent, taskDescriptionSchema };
15
16
 
@@ -21,10 +22,7 @@ export function isValidTaskDescriptionYjsState(
21
22
  }
22
23
 
23
24
  try {
24
- convertYjsStateToJsonContent(
25
- Uint8Array.from(yjsState),
26
- taskDescriptionSchema
27
- );
25
+ decodeTaskDescriptionYjsState(yjsState);
28
26
  return true;
29
27
  } catch {
30
28
  return false;
@@ -32,11 +30,11 @@ export function isValidTaskDescriptionYjsState(
32
30
  }
33
31
 
34
32
  function encodeYjsState(content: JSONContent): number[] | null {
35
- const encoded = convertJsonContentToYjsState(content, taskDescriptionSchema);
33
+ const encoded = encodeTaskDescriptionYjsState(content);
36
34
  if (encoded.length === 0) {
37
35
  return null;
38
36
  }
39
- return Array.from(encoded);
37
+ return encoded;
40
38
  }
41
39
 
42
40
  /**
@@ -51,7 +49,9 @@ export function deriveTaskDescriptionYjsState(
51
49
  return null;
52
50
  }
53
51
 
54
- const content = parseTaskDescriptionContent(normalizedDescription);
52
+ const content = parsePersistedTaskDescriptionContent({
53
+ description: normalizedDescription,
54
+ });
55
55
  if (!hasMeaningfulTaskDescriptionContent(content)) {
56
56
  return null;
57
57
  }
@@ -65,15 +65,7 @@ export function deriveTaskDescriptionYjsState(
65
65
  }
66
66
 
67
67
  try {
68
- return encodeYjsState({
69
- type: 'doc',
70
- content: [
71
- {
72
- type: 'paragraph',
73
- content: [{ type: 'text', text: fallbackText }],
74
- },
75
- ],
76
- });
68
+ return encodeYjsState(parseTaskDescriptionInput(fallbackText, 'text'));
77
69
  } catch {
78
70
  return null;
79
71
  }