trekoon 0.1.8 → 0.2.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.
- package/.agents/skills/trekoon/SKILL.md +117 -2
- package/README.md +158 -18
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +164 -0
- package/src/commands/epic.ts +256 -3
- package/src/commands/help.ts +45 -4
- package/src/commands/subtask.ts +209 -3
- package/src/commands/sync.ts +130 -52
- package/src/commands/task.ts +257 -3
- package/src/domain/mutation-service.ts +242 -1
- package/src/domain/tracker-domain.ts +171 -0
- package/src/domain/types.ts +27 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +98 -5
- package/src/runtime/cli-shell.ts +159 -22
- package/src/runtime/command-types.ts +18 -0
- package/src/storage/path.ts +58 -1
- package/src/sync/event-writes.ts +21 -1
package/src/commands/task.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
+
SEARCH_REPLACE_FIELDS,
|
|
3
|
+
findUnknownOption,
|
|
2
4
|
hasFlag,
|
|
3
5
|
parseArgs,
|
|
6
|
+
parseCsvEnumOption,
|
|
4
7
|
parseStrictNonNegativeInt,
|
|
5
8
|
parseStrictPositiveInt,
|
|
6
9
|
readEnumOption,
|
|
7
10
|
readMissingOptionValue,
|
|
8
11
|
readOption,
|
|
12
|
+
resolvePreviewApplyMode,
|
|
13
|
+
suggestOptions,
|
|
9
14
|
} from "./arg-parser";
|
|
10
15
|
|
|
11
16
|
import { MutationService } from "../domain/mutation-service";
|
|
12
17
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
13
|
-
import { DomainError, type TaskRecord } from "../domain/types";
|
|
18
|
+
import { DomainError, type SearchEntityMatch, type TaskRecord } from "../domain/types";
|
|
14
19
|
import { formatHumanTable } from "../io/human-table";
|
|
15
20
|
import { failResult, okResult } from "../io/output";
|
|
16
21
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -26,6 +31,8 @@ const DEFAULT_TASK_LIST_LIMIT = 10;
|
|
|
26
31
|
const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
27
32
|
const READY_REASON_READY = "all_dependencies_done";
|
|
28
33
|
const READY_REASON_BLOCKED = "blocked_by_dependencies";
|
|
34
|
+
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
35
|
+
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
29
36
|
|
|
30
37
|
interface DependencyBlocker {
|
|
31
38
|
readonly id: string;
|
|
@@ -81,6 +88,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
|
|
|
81
88
|
.filter((value) => value.length > 0);
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
function prefixedOptions(options: readonly string[]): string[] {
|
|
92
|
+
return options.map((option) => `--${option}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
|
|
96
|
+
const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
|
|
97
|
+
const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
|
|
98
|
+
return failResult({
|
|
99
|
+
command,
|
|
100
|
+
human: `Unknown option --${option}.${suggestionMessage}`,
|
|
101
|
+
data: {
|
|
102
|
+
option: `--${option}`,
|
|
103
|
+
allowedOptions: prefixedOptions(allowedOptions),
|
|
104
|
+
suggestions,
|
|
105
|
+
},
|
|
106
|
+
error: {
|
|
107
|
+
code: "unknown_option",
|
|
108
|
+
message: `Unknown option --${option}`,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
|
|
114
|
+
return failResult({
|
|
115
|
+
command,
|
|
116
|
+
human,
|
|
117
|
+
data,
|
|
118
|
+
error: {
|
|
119
|
+
code: "invalid_input",
|
|
120
|
+
message,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
|
|
126
|
+
if (matches.length === 0) {
|
|
127
|
+
return emptyMessage;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return matches
|
|
131
|
+
.map(
|
|
132
|
+
(match) =>
|
|
133
|
+
`${match.kind} ${match.id}: ${match.fields
|
|
134
|
+
.map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
|
|
135
|
+
.join(", ")}`,
|
|
136
|
+
)
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
85
141
|
if (rawStatuses === undefined) {
|
|
86
142
|
return undefined;
|
|
@@ -537,7 +593,29 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
537
593
|
command: "task.list",
|
|
538
594
|
human,
|
|
539
595
|
data: { tasks },
|
|
540
|
-
...(context.mode === "human"
|
|
596
|
+
...(context.mode === "human"
|
|
597
|
+
? {}
|
|
598
|
+
: {
|
|
599
|
+
meta: {
|
|
600
|
+
pagination: listed.pagination,
|
|
601
|
+
defaults: {
|
|
602
|
+
statuses: !includeAll && statuses === undefined ? [...DEFAULT_OPEN_TASK_STATUSES] : null,
|
|
603
|
+
limit: !includeAll && parsedLimit === undefined ? DEFAULT_TASK_LIST_LIMIT : null,
|
|
604
|
+
cursor: parsedCursor === undefined ? 0 : null,
|
|
605
|
+
view: view === undefined ? "table" : null,
|
|
606
|
+
},
|
|
607
|
+
filters: {
|
|
608
|
+
epicId: epicId ?? null,
|
|
609
|
+
statuses: selectedStatuses ?? null,
|
|
610
|
+
includeAll,
|
|
611
|
+
},
|
|
612
|
+
truncation: {
|
|
613
|
+
applied: listed.pagination.hasMore,
|
|
614
|
+
returned: tasks.length,
|
|
615
|
+
limit: selectedLimit ?? null,
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
}),
|
|
541
619
|
});
|
|
542
620
|
}
|
|
543
621
|
case "show": {
|
|
@@ -593,6 +671,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
593
671
|
command: "task.show",
|
|
594
672
|
human: formatTask(task),
|
|
595
673
|
data: { task, includeAll: false },
|
|
674
|
+
...(context.mode === "human"
|
|
675
|
+
? {}
|
|
676
|
+
: {
|
|
677
|
+
meta: {
|
|
678
|
+
defaults: {
|
|
679
|
+
view: view === undefined ? effectiveView : null,
|
|
680
|
+
},
|
|
681
|
+
filters: {
|
|
682
|
+
includeAll: false,
|
|
683
|
+
},
|
|
684
|
+
truncation: {
|
|
685
|
+
applied: true,
|
|
686
|
+
scope: "compact",
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
}),
|
|
596
690
|
});
|
|
597
691
|
}
|
|
598
692
|
|
|
@@ -603,6 +697,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
603
697
|
command: "task.show",
|
|
604
698
|
human: formatTaskShowTree(taskTree),
|
|
605
699
|
data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
|
|
700
|
+
...(context.mode === "human"
|
|
701
|
+
? {}
|
|
702
|
+
: {
|
|
703
|
+
meta: {
|
|
704
|
+
defaults: {
|
|
705
|
+
view: view === undefined ? effectiveView : null,
|
|
706
|
+
},
|
|
707
|
+
filters: {
|
|
708
|
+
includeAll: true,
|
|
709
|
+
},
|
|
710
|
+
truncation: {
|
|
711
|
+
applied: effectiveView === "tree",
|
|
712
|
+
scope: "tree",
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
}),
|
|
606
716
|
});
|
|
607
717
|
}
|
|
608
718
|
|
|
@@ -610,6 +720,22 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
610
720
|
command: "task.show",
|
|
611
721
|
human: effectiveView === "table" ? formatTaskShowTable(taskTree) : formatTaskShowDetail(taskTree),
|
|
612
722
|
data: { task: taskTree, includeAll: true, subtasksCount: taskTree.subtasks.length },
|
|
723
|
+
...(context.mode === "human"
|
|
724
|
+
? {}
|
|
725
|
+
: {
|
|
726
|
+
meta: {
|
|
727
|
+
defaults: {
|
|
728
|
+
view: view === undefined ? effectiveView : null,
|
|
729
|
+
},
|
|
730
|
+
filters: {
|
|
731
|
+
includeAll: true,
|
|
732
|
+
},
|
|
733
|
+
truncation: {
|
|
734
|
+
applied: false,
|
|
735
|
+
scope: "full",
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
}),
|
|
613
739
|
});
|
|
614
740
|
}
|
|
615
741
|
case "ready": {
|
|
@@ -693,6 +819,134 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
693
819
|
},
|
|
694
820
|
});
|
|
695
821
|
}
|
|
822
|
+
case "search": {
|
|
823
|
+
const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
|
|
824
|
+
if (searchUnknownOption !== undefined) {
|
|
825
|
+
return unknownOption("task.search", searchUnknownOption, SEARCH_OPTIONS);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
|
|
829
|
+
if (missingSearchOption !== undefined) {
|
|
830
|
+
return failMissingOptionValue("task.search", missingSearchOption);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const taskId: string = parsed.positional[1] ?? "";
|
|
834
|
+
const searchText: string = parsed.positional[2] ?? "";
|
|
835
|
+
if (taskId.length === 0 || searchText.trim().length === 0) {
|
|
836
|
+
return invalidSearchInput(
|
|
837
|
+
"task.search",
|
|
838
|
+
"Usage: trekoon task search <task-id> \"search text\" [--fields <csv>] [--preview]",
|
|
839
|
+
"Missing search target",
|
|
840
|
+
{
|
|
841
|
+
taskId,
|
|
842
|
+
},
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
|
|
847
|
+
if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
|
|
848
|
+
return invalidSearchInput("task.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
|
|
849
|
+
fields: readOption(parsed.options, "fields"),
|
|
850
|
+
invalidFields: parsedFields.invalidValues,
|
|
851
|
+
allowedFields: [...SEARCH_REPLACE_FIELDS],
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const { matches, summary } = domain.searchTaskScope(taskId, searchText, parsedFields.values);
|
|
856
|
+
|
|
857
|
+
return okResult({
|
|
858
|
+
command: "task.search",
|
|
859
|
+
human: formatSearchHuman(matches, "No matches found."),
|
|
860
|
+
data: {
|
|
861
|
+
scope: {
|
|
862
|
+
kind: "task",
|
|
863
|
+
id: taskId,
|
|
864
|
+
},
|
|
865
|
+
query: {
|
|
866
|
+
search: searchText,
|
|
867
|
+
fields: parsedFields.values,
|
|
868
|
+
mode: "preview",
|
|
869
|
+
},
|
|
870
|
+
summary,
|
|
871
|
+
matches,
|
|
872
|
+
},
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
case "replace": {
|
|
876
|
+
const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
|
|
877
|
+
if (replaceUnknownOption !== undefined) {
|
|
878
|
+
return unknownOption("task.replace", replaceUnknownOption, REPLACE_OPTIONS);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const missingReplaceOption =
|
|
882
|
+
readMissingOptionValue(parsed.missingOptionValues, "search") ??
|
|
883
|
+
readMissingOptionValue(parsed.missingOptionValues, "replace") ??
|
|
884
|
+
readMissingOptionValue(parsed.missingOptionValues, "fields");
|
|
885
|
+
if (missingReplaceOption !== undefined) {
|
|
886
|
+
return failMissingOptionValue("task.replace", missingReplaceOption);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const taskId: string = parsed.positional[1] ?? "";
|
|
890
|
+
const searchText = readOption(parsed.options, "search") ?? "";
|
|
891
|
+
const replacementText = readOption(parsed.options, "replace") ?? "";
|
|
892
|
+
if (taskId.length === 0 || searchText.trim().length === 0) {
|
|
893
|
+
return invalidSearchInput(
|
|
894
|
+
"task.replace",
|
|
895
|
+
"Usage: trekoon task replace <task-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
|
|
896
|
+
"Missing replace target",
|
|
897
|
+
{
|
|
898
|
+
taskId,
|
|
899
|
+
search: searchText,
|
|
900
|
+
},
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const rawFields = readOption(parsed.options, "fields");
|
|
905
|
+
const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
|
|
906
|
+
if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
|
|
907
|
+
return invalidSearchInput("task.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
|
|
908
|
+
fields: rawFields,
|
|
909
|
+
invalidFields: parsedFields.invalidValues,
|
|
910
|
+
allowedFields: [...SEARCH_REPLACE_FIELDS],
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const previewMode = resolvePreviewApplyMode(parsed.flags);
|
|
915
|
+
if (previewMode.conflict) {
|
|
916
|
+
return invalidSearchInput("task.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
|
|
917
|
+
flags: ["preview", "apply"],
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const replacementSummary = previewMode.mode === "apply"
|
|
922
|
+
? mutations.applyTaskReplacement(taskId, searchText, replacementText, parsedFields.values)
|
|
923
|
+
: mutations.previewTaskReplacement(taskId, searchText, replacementText, parsedFields.values);
|
|
924
|
+
const { matches, summary: matchSummary } = replacementSummary;
|
|
925
|
+
|
|
926
|
+
const summary = {
|
|
927
|
+
...matchSummary,
|
|
928
|
+
mode: previewMode.mode,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
return okResult({
|
|
932
|
+
command: "task.replace",
|
|
933
|
+
human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
|
|
934
|
+
data: {
|
|
935
|
+
scope: {
|
|
936
|
+
kind: "task",
|
|
937
|
+
id: taskId,
|
|
938
|
+
},
|
|
939
|
+
query: {
|
|
940
|
+
search: searchText,
|
|
941
|
+
replace: replacementText,
|
|
942
|
+
fields: parsedFields.values,
|
|
943
|
+
mode: previewMode.mode,
|
|
944
|
+
},
|
|
945
|
+
summary,
|
|
946
|
+
matches,
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
}
|
|
696
950
|
case "update": {
|
|
697
951
|
const missingUpdateOption =
|
|
698
952
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
@@ -830,7 +1084,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
830
1084
|
default:
|
|
831
1085
|
return failResult({
|
|
832
1086
|
command: "task",
|
|
833
|
-
human: "Usage: trekoon task <create|list|show|ready|next|update|delete>",
|
|
1087
|
+
human: "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete>",
|
|
834
1088
|
data: {
|
|
835
1089
|
args: context.args,
|
|
836
1090
|
},
|
|
@@ -3,7 +3,83 @@ 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 {
|
|
6
|
+
import {
|
|
7
|
+
type DependencyRecord,
|
|
8
|
+
type EpicRecord,
|
|
9
|
+
type SearchEntityMatch,
|
|
10
|
+
type SearchField,
|
|
11
|
+
type SearchNode,
|
|
12
|
+
type SearchSummary,
|
|
13
|
+
type SubtaskRecord,
|
|
14
|
+
type TaskRecord,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
function countMatches(value: string, searchText: string): number {
|
|
18
|
+
if (searchText.length === 0) {
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let count = 0;
|
|
23
|
+
let offset = 0;
|
|
24
|
+
while (offset <= value.length - searchText.length) {
|
|
25
|
+
const nextIndex = value.indexOf(searchText, offset);
|
|
26
|
+
if (nextIndex === -1) {
|
|
27
|
+
return count;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
count += 1;
|
|
31
|
+
offset = nextIndex + searchText.length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function replaceMatches(value: string, searchText: string, replacement: string): string {
|
|
38
|
+
return searchText.length === 0 ? value : value.split(searchText).join(replacement);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
|
|
42
|
+
if (searchText.length === 0) {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const matchIndex = value.indexOf(searchText);
|
|
47
|
+
if (matchIndex === -1) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const start = Math.max(0, matchIndex - contextSize);
|
|
52
|
+
const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
|
|
53
|
+
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
54
|
+
const prefix = start > 0 ? "…" : "";
|
|
55
|
+
const suffix = end < value.length ? "…" : "";
|
|
56
|
+
return `${prefix}${rawSnippet}${suffix}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildReplacementSnippet(value: string, replacementIndex: number, replacementLength: number, contextSize = 24): string {
|
|
60
|
+
const start = Math.max(0, replacementIndex - contextSize);
|
|
61
|
+
const end = Math.min(value.length, replacementIndex + replacementLength + contextSize);
|
|
62
|
+
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
63
|
+
const prefix = start > 0 ? "…" : "";
|
|
64
|
+
const suffix = end < value.length ? "…" : "";
|
|
65
|
+
return `${prefix}${rawSnippet}${suffix}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
|
|
69
|
+
return {
|
|
70
|
+
matchedEntities: matches.length,
|
|
71
|
+
matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
|
|
72
|
+
totalMatches: matches.reduce(
|
|
73
|
+
(total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
|
|
74
|
+
0,
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ScopeReplacementResult {
|
|
80
|
+
readonly matches: readonly SearchEntityMatch[];
|
|
81
|
+
readonly summary: SearchSummary;
|
|
82
|
+
}
|
|
7
83
|
|
|
8
84
|
export class MutationService {
|
|
9
85
|
readonly #db: Database;
|
|
@@ -153,6 +229,60 @@ export class MutationService {
|
|
|
153
229
|
})();
|
|
154
230
|
}
|
|
155
231
|
|
|
232
|
+
previewEpicReplacement(
|
|
233
|
+
epicId: string,
|
|
234
|
+
searchText: string,
|
|
235
|
+
replacementText: string,
|
|
236
|
+
fields: readonly SearchField[],
|
|
237
|
+
): ScopeReplacementResult {
|
|
238
|
+
return this.#previewScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
applyEpicReplacement(
|
|
242
|
+
epicId: string,
|
|
243
|
+
searchText: string,
|
|
244
|
+
replacementText: string,
|
|
245
|
+
fields: readonly SearchField[],
|
|
246
|
+
): ScopeReplacementResult {
|
|
247
|
+
return this.#applyScopeReplacement(this.#domain.collectEpicSearchScope(epicId), searchText, replacementText, fields);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
previewTaskReplacement(
|
|
251
|
+
taskId: string,
|
|
252
|
+
searchText: string,
|
|
253
|
+
replacementText: string,
|
|
254
|
+
fields: readonly SearchField[],
|
|
255
|
+
): ScopeReplacementResult {
|
|
256
|
+
return this.#previewScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
applyTaskReplacement(
|
|
260
|
+
taskId: string,
|
|
261
|
+
searchText: string,
|
|
262
|
+
replacementText: string,
|
|
263
|
+
fields: readonly SearchField[],
|
|
264
|
+
): ScopeReplacementResult {
|
|
265
|
+
return this.#applyScopeReplacement(this.#domain.collectTaskSearchScope(taskId), searchText, replacementText, fields);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
previewSubtaskReplacement(
|
|
269
|
+
subtaskId: string,
|
|
270
|
+
searchText: string,
|
|
271
|
+
replacementText: string,
|
|
272
|
+
fields: readonly SearchField[],
|
|
273
|
+
): ScopeReplacementResult {
|
|
274
|
+
return this.#previewScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
applySubtaskReplacement(
|
|
278
|
+
subtaskId: string,
|
|
279
|
+
searchText: string,
|
|
280
|
+
replacementText: string,
|
|
281
|
+
fields: readonly SearchField[],
|
|
282
|
+
): ScopeReplacementResult {
|
|
283
|
+
return this.#applyScopeReplacement(this.#domain.collectSubtaskSearchScope(subtaskId), searchText, replacementText, fields);
|
|
284
|
+
}
|
|
285
|
+
|
|
156
286
|
#appendEntityEvent(
|
|
157
287
|
entityKind: "epic" | "task" | "subtask" | "dependency",
|
|
158
288
|
entityId: string,
|
|
@@ -166,4 +296,115 @@ export class MutationService {
|
|
|
166
296
|
fields,
|
|
167
297
|
});
|
|
168
298
|
}
|
|
299
|
+
|
|
300
|
+
#previewScopeReplacement(
|
|
301
|
+
nodes: readonly SearchNode[],
|
|
302
|
+
searchText: string,
|
|
303
|
+
replacementText: string,
|
|
304
|
+
fields: readonly SearchField[],
|
|
305
|
+
): ScopeReplacementResult {
|
|
306
|
+
return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#applyScopeReplacement(
|
|
310
|
+
nodes: readonly SearchNode[],
|
|
311
|
+
searchText: string,
|
|
312
|
+
replacementText: string,
|
|
313
|
+
fields: readonly SearchField[],
|
|
314
|
+
): ScopeReplacementResult {
|
|
315
|
+
const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
|
|
316
|
+
|
|
317
|
+
this.#db.transaction((): void => {
|
|
318
|
+
for (const node of nodes) {
|
|
319
|
+
const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
|
|
320
|
+
const nextDescription = fields.includes("description")
|
|
321
|
+
? replaceMatches(node.description, searchText, replacementText)
|
|
322
|
+
: node.description;
|
|
323
|
+
|
|
324
|
+
if (nextTitle === node.title && nextDescription === node.description) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (node.kind === "epic") {
|
|
329
|
+
const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
|
|
330
|
+
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
331
|
+
title: epic.title,
|
|
332
|
+
description: epic.description,
|
|
333
|
+
status: epic.status,
|
|
334
|
+
});
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (node.kind === "task") {
|
|
339
|
+
const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
|
|
340
|
+
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
341
|
+
epic_id: task.epicId,
|
|
342
|
+
title: task.title,
|
|
343
|
+
description: task.description,
|
|
344
|
+
status: task.status,
|
|
345
|
+
});
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
|
|
350
|
+
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
351
|
+
task_id: subtask.taskId,
|
|
352
|
+
title: subtask.title,
|
|
353
|
+
description: subtask.description,
|
|
354
|
+
status: subtask.status,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#buildScopeReplacementResult(
|
|
363
|
+
nodes: readonly SearchNode[],
|
|
364
|
+
searchText: string,
|
|
365
|
+
replacementText: string,
|
|
366
|
+
fields: readonly SearchField[],
|
|
367
|
+
mode: "preview" | "apply" = "preview",
|
|
368
|
+
): ScopeReplacementResult {
|
|
369
|
+
const matches: SearchEntityMatch[] = [];
|
|
370
|
+
|
|
371
|
+
for (const node of nodes) {
|
|
372
|
+
const fieldMatches = fields
|
|
373
|
+
.map((field) => {
|
|
374
|
+
const value = field === "title" ? node.title : node.description;
|
|
375
|
+
const matchIndex = value.indexOf(searchText);
|
|
376
|
+
const nextValue = replaceMatches(value, searchText, replacementText);
|
|
377
|
+
const count = nextValue === value ? 0 : countMatches(value, searchText);
|
|
378
|
+
|
|
379
|
+
if (count === 0) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
field,
|
|
385
|
+
count,
|
|
386
|
+
snippet:
|
|
387
|
+
mode === "apply"
|
|
388
|
+
? buildReplacementSnippet(nextValue, matchIndex, replacementText.length)
|
|
389
|
+
: buildMatchSnippet(value, searchText),
|
|
390
|
+
};
|
|
391
|
+
})
|
|
392
|
+
.filter((fieldMatch) => fieldMatch !== null);
|
|
393
|
+
|
|
394
|
+
if (fieldMatches.length === 0) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
matches.push({
|
|
399
|
+
kind: node.kind,
|
|
400
|
+
id: node.id,
|
|
401
|
+
fields: fieldMatches,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
matches,
|
|
407
|
+
summary: summarizeMatches(matches),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
169
410
|
}
|