trekoon 0.1.9 → 0.2.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.
@@ -3,7 +3,91 @@ import { type Database } from "bun:sqlite";
3
3
  import { appendEventWithGitContext } from "../sync/event-writes";
4
4
  import { ENTITY_OPERATIONS } from "./mutation-operations";
5
5
  import { TrackerDomain } from "./tracker-domain";
6
- import { type DependencyRecord, type EpicRecord, type SubtaskRecord, type TaskRecord } from "./types";
6
+ import {
7
+ type CompactEpicCreateResult,
8
+ type CompactEpicExpandResult,
9
+ type CompactDependencyBatchAddResult,
10
+ type CompactDependencySpec,
11
+ type CompactSubtaskBatchCreateResult,
12
+ type CompactSubtaskSpec,
13
+ type CompactTaskBatchCreateResult,
14
+ type CompactTaskSpec,
15
+ type DependencyRecord,
16
+ type EpicRecord,
17
+ type SearchEntityMatch,
18
+ type SearchField,
19
+ type SearchNode,
20
+ type SearchSummary,
21
+ type SubtaskRecord,
22
+ type TaskRecord,
23
+ } from "./types";
24
+
25
+ function countMatches(value: string, searchText: string): number {
26
+ if (searchText.length === 0) {
27
+ return 0;
28
+ }
29
+
30
+ let count = 0;
31
+ let offset = 0;
32
+ while (offset <= value.length - searchText.length) {
33
+ const nextIndex = value.indexOf(searchText, offset);
34
+ if (nextIndex === -1) {
35
+ return count;
36
+ }
37
+
38
+ count += 1;
39
+ offset = nextIndex + searchText.length;
40
+ }
41
+
42
+ return count;
43
+ }
44
+
45
+ function replaceMatches(value: string, searchText: string, replacement: string): string {
46
+ return searchText.length === 0 ? value : value.split(searchText).join(replacement);
47
+ }
48
+
49
+ function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
50
+ if (searchText.length === 0) {
51
+ return "";
52
+ }
53
+
54
+ const matchIndex = value.indexOf(searchText);
55
+ if (matchIndex === -1) {
56
+ return "";
57
+ }
58
+
59
+ const start = Math.max(0, matchIndex - contextSize);
60
+ const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
61
+ const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
62
+ const prefix = start > 0 ? "…" : "";
63
+ const suffix = end < value.length ? "…" : "";
64
+ return `${prefix}${rawSnippet}${suffix}`;
65
+ }
66
+
67
+ function buildReplacementSnippet(value: string, replacementIndex: number, replacementLength: number, contextSize = 24): string {
68
+ const start = Math.max(0, replacementIndex - contextSize);
69
+ const end = Math.min(value.length, replacementIndex + replacementLength + contextSize);
70
+ const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
71
+ const prefix = start > 0 ? "…" : "";
72
+ const suffix = end < value.length ? "…" : "";
73
+ return `${prefix}${rawSnippet}${suffix}`;
74
+ }
75
+
76
+ function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
77
+ return {
78
+ matchedEntities: matches.length,
79
+ matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
80
+ totalMatches: matches.reduce(
81
+ (total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
82
+ 0,
83
+ ),
84
+ };
85
+ }
86
+
87
+ interface ScopeReplacementResult {
88
+ readonly matches: readonly SearchEntityMatch[];
89
+ readonly summary: SearchSummary;
90
+ }
7
91
 
8
92
  export class MutationService {
9
93
  readonly #db: Database;
@@ -28,6 +112,66 @@ export class MutationService {
28
112
  })();
29
113
  }
30
114
 
115
+ createEpicGraph(input: {
116
+ title: string;
117
+ description: string;
118
+ status?: string | undefined;
119
+ taskSpecs: readonly CompactTaskSpec[];
120
+ subtaskSpecs: readonly CompactSubtaskSpec[];
121
+ dependencySpecs: readonly CompactDependencySpec[];
122
+ }): CompactEpicCreateResult {
123
+ return this.#db.transaction((): CompactEpicCreateResult => {
124
+ const epic = this.#domain.createEpic(input);
125
+ const created = this.#domain.expandEpic({
126
+ epicId: epic.id,
127
+ taskSpecs: input.taskSpecs,
128
+ subtaskSpecs: input.subtaskSpecs,
129
+ dependencySpecs: input.dependencySpecs,
130
+ });
131
+
132
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
133
+ title: epic.title,
134
+ description: epic.description,
135
+ status: epic.status,
136
+ });
137
+
138
+ for (const task of created.tasks) {
139
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
140
+ epic_id: task.epicId,
141
+ title: task.title,
142
+ description: task.description,
143
+ status: task.status,
144
+ });
145
+ }
146
+
147
+ for (const subtask of created.subtasks) {
148
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
149
+ task_id: subtask.taskId,
150
+ title: subtask.title,
151
+ description: subtask.description,
152
+ status: subtask.status,
153
+ });
154
+ }
155
+
156
+ for (const dependency of created.dependencies) {
157
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
158
+ source_id: dependency.sourceId,
159
+ source_kind: dependency.sourceKind,
160
+ depends_on_id: dependency.dependsOnId,
161
+ depends_on_kind: dependency.dependsOnKind,
162
+ });
163
+ }
164
+
165
+ return {
166
+ epic,
167
+ tasks: created.tasks,
168
+ subtasks: created.subtasks,
169
+ dependencies: created.dependencies,
170
+ result: created.result,
171
+ };
172
+ })();
173
+ }
174
+
31
175
  updateEpic(
32
176
  id: string,
33
177
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -63,6 +207,60 @@ export class MutationService {
63
207
  })();
64
208
  }
65
209
 
210
+ createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
211
+ return this.#db.transaction((): CompactTaskBatchCreateResult => {
212
+ const created = this.#domain.createTaskBatch(input);
213
+ for (const task of created.tasks) {
214
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
215
+ epic_id: task.epicId,
216
+ title: task.title,
217
+ description: task.description,
218
+ status: task.status,
219
+ });
220
+ }
221
+ return created;
222
+ })();
223
+ }
224
+
225
+ expandEpic(input: {
226
+ epicId: string;
227
+ taskSpecs: readonly CompactTaskSpec[];
228
+ subtaskSpecs: readonly CompactSubtaskSpec[];
229
+ dependencySpecs: readonly CompactDependencySpec[];
230
+ }): CompactEpicExpandResult {
231
+ return this.#db.transaction((): CompactEpicExpandResult => {
232
+ const created = this.#domain.expandEpic(input);
233
+ for (const task of created.tasks) {
234
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
235
+ epic_id: task.epicId,
236
+ title: task.title,
237
+ description: task.description,
238
+ status: task.status,
239
+ });
240
+ }
241
+
242
+ for (const subtask of created.subtasks) {
243
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
244
+ task_id: subtask.taskId,
245
+ title: subtask.title,
246
+ description: subtask.description,
247
+ status: subtask.status,
248
+ });
249
+ }
250
+
251
+ for (const dependency of created.dependencies) {
252
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
253
+ source_id: dependency.sourceId,
254
+ source_kind: dependency.sourceKind,
255
+ depends_on_id: dependency.dependsOnId,
256
+ depends_on_kind: dependency.dependsOnKind,
257
+ });
258
+ }
259
+
260
+ return created;
261
+ })();
262
+ }
263
+
66
264
  updateTask(
67
265
  id: string,
68
266
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -104,6 +302,21 @@ export class MutationService {
104
302
  })();
105
303
  }
106
304
 
305
+ createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
306
+ return this.#db.transaction((): CompactSubtaskBatchCreateResult => {
307
+ const created = this.#domain.createSubtaskBatch(input);
308
+ for (const subtask of created.subtasks) {
309
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
310
+ task_id: subtask.taskId,
311
+ title: subtask.title,
312
+ description: subtask.description,
313
+ status: subtask.status,
314
+ });
315
+ }
316
+ return created;
317
+ })();
318
+ }
319
+
107
320
  updateSubtask(
108
321
  id: string,
109
322
  input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
@@ -140,6 +353,21 @@ export class MutationService {
140
353
  })();
141
354
  }
142
355
 
356
+ addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
357
+ return this.#db.transaction((): CompactDependencyBatchAddResult => {
358
+ const created = this.#domain.addDependencyBatch(input);
359
+ for (const dependency of created.dependencies) {
360
+ this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
361
+ source_id: dependency.sourceId,
362
+ source_kind: dependency.sourceKind,
363
+ depends_on_id: dependency.dependsOnId,
364
+ depends_on_kind: dependency.dependsOnKind,
365
+ });
366
+ }
367
+ return created;
368
+ })();
369
+ }
370
+
143
371
  removeDependency(sourceId: string, dependsOnId: string): number {
144
372
  return this.#db.transaction((): number => {
145
373
  const removed = this.#domain.removeDependency(sourceId, dependsOnId);
@@ -153,6 +381,60 @@ export class MutationService {
153
381
  })();
154
382
  }
155
383
 
384
+ previewEpicReplacement(
385
+ epicId: string,
386
+ searchText: string,
387
+ replacementText: string,
388
+ fields: readonly SearchField[],
389
+ ): ScopeReplacementResult {
390
+ return this.#previewScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
391
+ }
392
+
393
+ applyEpicReplacement(
394
+ epicId: string,
395
+ searchText: string,
396
+ replacementText: string,
397
+ fields: readonly SearchField[],
398
+ ): ScopeReplacementResult {
399
+ return this.#applyScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
400
+ }
401
+
402
+ previewTaskReplacement(
403
+ taskId: string,
404
+ searchText: string,
405
+ replacementText: string,
406
+ fields: readonly SearchField[],
407
+ ): ScopeReplacementResult {
408
+ return this.#previewScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
409
+ }
410
+
411
+ applyTaskReplacement(
412
+ taskId: string,
413
+ searchText: string,
414
+ replacementText: string,
415
+ fields: readonly SearchField[],
416
+ ): ScopeReplacementResult {
417
+ return this.#applyScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
418
+ }
419
+
420
+ previewSubtaskReplacement(
421
+ subtaskId: string,
422
+ searchText: string,
423
+ replacementText: string,
424
+ fields: readonly SearchField[],
425
+ ): ScopeReplacementResult {
426
+ return this.#previewScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
427
+ }
428
+
429
+ applySubtaskReplacement(
430
+ subtaskId: string,
431
+ searchText: string,
432
+ replacementText: string,
433
+ fields: readonly SearchField[],
434
+ ): ScopeReplacementResult {
435
+ return this.#applyScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
436
+ }
437
+
156
438
  #appendEntityEvent(
157
439
  entityKind: "epic" | "task" | "subtask" | "dependency",
158
440
  entityId: string,
@@ -166,4 +448,115 @@ export class MutationService {
166
448
  fields,
167
449
  });
168
450
  }
451
+
452
+ #previewScopeReplacement(
453
+ nodes: readonly SearchNode[],
454
+ searchText: string,
455
+ replacementText: string,
456
+ fields: readonly SearchField[],
457
+ ): ScopeReplacementResult {
458
+ return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
459
+ }
460
+
461
+ #applyScopeReplacement(
462
+ nodes: readonly SearchNode[],
463
+ searchText: string,
464
+ replacementText: string,
465
+ fields: readonly SearchField[],
466
+ ): ScopeReplacementResult {
467
+ const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
468
+
469
+ this.#db.transaction((): void => {
470
+ for (const node of nodes) {
471
+ const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
472
+ const nextDescription = fields.includes("description")
473
+ ? replaceMatches(node.description, searchText, replacementText)
474
+ : node.description;
475
+
476
+ if (nextTitle === node.title && nextDescription === node.description) {
477
+ continue;
478
+ }
479
+
480
+ if (node.kind === "epic") {
481
+ const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
482
+ this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
483
+ title: epic.title,
484
+ description: epic.description,
485
+ status: epic.status,
486
+ });
487
+ continue;
488
+ }
489
+
490
+ if (node.kind === "task") {
491
+ const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
492
+ this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
493
+ epic_id: task.epicId,
494
+ title: task.title,
495
+ description: task.description,
496
+ status: task.status,
497
+ });
498
+ continue;
499
+ }
500
+
501
+ const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
502
+ this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
503
+ task_id: subtask.taskId,
504
+ title: subtask.title,
505
+ description: subtask.description,
506
+ status: subtask.status,
507
+ });
508
+ }
509
+ })();
510
+
511
+ return result;
512
+ }
513
+
514
+ #buildScopeReplacementResult(
515
+ nodes: readonly SearchNode[],
516
+ searchText: string,
517
+ replacementText: string,
518
+ fields: readonly SearchField[],
519
+ mode: "preview" | "apply" = "preview",
520
+ ): ScopeReplacementResult {
521
+ const matches: SearchEntityMatch[] = [];
522
+
523
+ for (const node of nodes) {
524
+ const fieldMatches = fields
525
+ .map((field) => {
526
+ const value = field === "title" ? node.title : node.description;
527
+ const matchIndex = value.indexOf(searchText);
528
+ const nextValue = replaceMatches(value, searchText, replacementText);
529
+ const count = nextValue === value ? 0 : countMatches(value, searchText);
530
+
531
+ if (count === 0) {
532
+ return null;
533
+ }
534
+
535
+ return {
536
+ field,
537
+ count,
538
+ snippet:
539
+ mode === "apply"
540
+ ? buildReplacementSnippet(nextValue, matchIndex, replacementText.length)
541
+ : buildMatchSnippet(value, searchText),
542
+ };
543
+ })
544
+ .filter((fieldMatch) => fieldMatch !== null);
545
+
546
+ if (fieldMatches.length === 0) {
547
+ continue;
548
+ }
549
+
550
+ matches.push({
551
+ kind: node.kind,
552
+ id: node.id,
553
+ fields: fieldMatches,
554
+ });
555
+ }
556
+
557
+ return {
558
+ matches,
559
+ summary: summarizeMatches(matches),
560
+ };
561
+ }
169
562
  }