@cueloop/schema 0.1.0-alpha.83 → 0.1.0-alpha.85
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/package.json +1 -1
- package/src/feedback.ts +2 -1
- package/src/history.ts +8 -2
- package/src/markdown.ts +103 -3
- package/src/path-view.test.ts +5 -0
- package/src/path-view.ts +6 -1
- package/src/types.test.ts +1 -1
- package/src/types.ts +13 -1
- package/src/working-copy.test.ts +139 -1
- package/src/working-copy.ts +124 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.85",
|
|
4
4
|
"description": "The Thread primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/feedback.ts
CHANGED
|
@@ -250,6 +250,7 @@ export function feedbackForSession(
|
|
|
250
250
|
outcome: MessageOutcome,
|
|
251
251
|
summary: string,
|
|
252
252
|
actionBodies?: Record<string, string>,
|
|
253
|
+
annotations: Annotation[] = session.annotations,
|
|
253
254
|
): string {
|
|
254
255
|
return renderFeedback({
|
|
255
256
|
outcome,
|
|
@@ -257,7 +258,7 @@ export function feedbackForSession(
|
|
|
257
258
|
artifactContent: session.artifact.content,
|
|
258
259
|
workingCopy: session.workingCopy,
|
|
259
260
|
artifactType: session.artifact.type,
|
|
260
|
-
annotations
|
|
261
|
+
annotations,
|
|
261
262
|
artifactPath: session.artifact.meta.prototypePath ?? session.artifact.meta.planPath,
|
|
262
263
|
sessionId: session.id,
|
|
263
264
|
actionBodies,
|
package/src/history.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* feedback document read what this module derives.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { Annotation, Thread, Message } from "./types";
|
|
13
|
+
import type { Annotation, Thread, Message, TextCut } from "./types";
|
|
14
14
|
|
|
15
15
|
export type EntryAuthor = "agent" | "reviewer";
|
|
16
16
|
|
|
@@ -22,7 +22,13 @@ interface EntryBase {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export type SessionEntry =
|
|
25
|
-
| (EntryBase & {
|
|
25
|
+
| (EntryBase & {
|
|
26
|
+
type: "revision";
|
|
27
|
+
by: EntryAuthor;
|
|
28
|
+
content: string;
|
|
29
|
+
/** Exact character Cuts that produced a reviewer revision. */
|
|
30
|
+
textCuts?: TextCut[];
|
|
31
|
+
})
|
|
26
32
|
| (EntryBase & { type: "comment"; annotationId: string })
|
|
27
33
|
| (EntryBase & { type: "comment-removed"; annotationId: string })
|
|
28
34
|
| (EntryBase & { type: "message"; message: Message })
|
package/src/markdown.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown block model: parse source into addressable blocks
|
|
3
|
-
* their source
|
|
4
|
-
* granular; the quote carries sub-block precision. Round-trip safe for the
|
|
2
|
+
* Markdown block model: parse source into addressable blocks with source line
|
|
3
|
+
* ranges and project their text offsets back to source on demand. Round-trip safe for the
|
|
5
4
|
* subset cueloop plans use; unknown constructs fall back to paragraph blocks
|
|
6
5
|
* so no content is ever lost.
|
|
7
6
|
*/
|
|
@@ -30,6 +29,12 @@ export interface Block {
|
|
|
30
29
|
lineEnd: number;
|
|
31
30
|
}
|
|
32
31
|
|
|
32
|
+
export interface BlockTextSourceSegment {
|
|
33
|
+
textStart: number;
|
|
34
|
+
textEnd: number;
|
|
35
|
+
sourceStart: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/**
|
|
34
39
|
* The leading block markers the parser strips from a line: headings (h1-h3),
|
|
35
40
|
* bullet list, ordered list, and blockquote. Single source of truth so the
|
|
@@ -210,6 +215,101 @@ function nextBlock(lines: string[], lineIndex: number): BlockScan {
|
|
|
210
215
|
return paragraphBlock(lines, lineIndex);
|
|
211
216
|
}
|
|
212
217
|
|
|
218
|
+
/** Project marker-free block text boundaries onto the source consumed by the parser. */
|
|
219
|
+
function blockSourceSegments(
|
|
220
|
+
sourceLines: string[],
|
|
221
|
+
block: Block,
|
|
222
|
+
sourceStart = sourceLines
|
|
223
|
+
.slice(0, block.lineStart)
|
|
224
|
+
.reduce((offset, line) => offset + line.length + 1, 0),
|
|
225
|
+
): BlockTextSourceSegment[] | undefined {
|
|
226
|
+
const rawLines = sourceLines.slice(block.lineStart, block.lineEnd + 1);
|
|
227
|
+
let contentLines: Array<{ rawLine: number; prefix: number }>;
|
|
228
|
+
|
|
229
|
+
if (block.kind === "code") {
|
|
230
|
+
const hasClosingFence = rawLines.at(-1)?.startsWith("```") ?? false;
|
|
231
|
+
const end = hasClosingFence ? rawLines.length - 1 : rawLines.length;
|
|
232
|
+
|
|
233
|
+
contentLines = rawLines.slice(1, end).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
|
|
234
|
+
} else if (block.kind === "frontmatter") {
|
|
235
|
+
contentLines = rawLines.slice(1, -1).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
|
|
236
|
+
} else if (block.kind === "quote") {
|
|
237
|
+
contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 2 }));
|
|
238
|
+
} else if (
|
|
239
|
+
block.kind === "h1" ||
|
|
240
|
+
block.kind === "h2" ||
|
|
241
|
+
block.kind === "h3" ||
|
|
242
|
+
block.kind === "li" ||
|
|
243
|
+
block.kind === "oli"
|
|
244
|
+
) {
|
|
245
|
+
contentLines = [{ rawLine: 0, prefix: rawLines[0]!.length - block.text.length }];
|
|
246
|
+
} else {
|
|
247
|
+
contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 0 }));
|
|
248
|
+
}
|
|
249
|
+
const content = contentLines
|
|
250
|
+
.map(({ rawLine, prefix }) => rawLines[rawLine]!.slice(prefix))
|
|
251
|
+
.join("\n");
|
|
252
|
+
|
|
253
|
+
if (content !== block.text) return undefined;
|
|
254
|
+
const rawLineStarts: number[] = [];
|
|
255
|
+
let rawOffset = 0;
|
|
256
|
+
|
|
257
|
+
for (const line of rawLines) {
|
|
258
|
+
rawLineStarts.push(rawOffset);
|
|
259
|
+
rawOffset += line.length + 1;
|
|
260
|
+
}
|
|
261
|
+
let textOffset = 0;
|
|
262
|
+
const segments = contentLines.map(({ rawLine, prefix }) => {
|
|
263
|
+
const line = rawLines[rawLine]!.slice(prefix);
|
|
264
|
+
const lineStart = sourceStart + rawLineStarts[rawLine]! + prefix;
|
|
265
|
+
const segment = {
|
|
266
|
+
textStart: textOffset,
|
|
267
|
+
textEnd: textOffset + line.length,
|
|
268
|
+
sourceStart: lineStart,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
textOffset += line.length + 1;
|
|
272
|
+
|
|
273
|
+
return segment;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return segments;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Project several parsed blocks onto source offsets without splitting the Markdown per block. */
|
|
280
|
+
export function blockTextSourceSegments(
|
|
281
|
+
markdown: string,
|
|
282
|
+
blocks: readonly Block[],
|
|
283
|
+
): Array<readonly BlockTextSourceSegment[] | undefined> {
|
|
284
|
+
const sourceLines = markdown.split("\n");
|
|
285
|
+
const lineStarts: number[] = [];
|
|
286
|
+
let sourceOffset = 0;
|
|
287
|
+
|
|
288
|
+
for (const line of sourceLines) {
|
|
289
|
+
lineStarts.push(sourceOffset);
|
|
290
|
+
sourceOffset += line.length + 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return blocks.map((block) =>
|
|
294
|
+
blockSourceSegments(sourceLines, block, lineStarts[block.lineStart] ?? sourceOffset),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Resolve a marker-free block-text boundary through the parser's source projection. */
|
|
299
|
+
export function sourceOffsetAt(
|
|
300
|
+
markdown: string,
|
|
301
|
+
block: Block,
|
|
302
|
+
textOffset: number,
|
|
303
|
+
): number | undefined {
|
|
304
|
+
for (const segment of blockSourceSegments(markdown.split("\n"), block) ?? []) {
|
|
305
|
+
if (textOffset < segment.textStart || textOffset > segment.textEnd) continue;
|
|
306
|
+
|
|
307
|
+
return segment.sourceStart + textOffset - segment.textStart;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
213
313
|
export function parseBlocks(markdown: string): Block[] {
|
|
214
314
|
const lines = markdown.split("\n");
|
|
215
315
|
const blocks: Block[] = [];
|
package/src/path-view.test.ts
CHANGED
|
@@ -37,6 +37,7 @@ function threaded(): SessionHistory {
|
|
|
37
37
|
type: "revision",
|
|
38
38
|
by: "reviewer",
|
|
39
39
|
content: "Plan v1 edited",
|
|
40
|
+
textCuts: [{ start: 5, end: 8, quote: "v1 " }],
|
|
40
41
|
createdAt: AT,
|
|
41
42
|
}).history;
|
|
42
43
|
history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
|
|
@@ -52,6 +53,7 @@ describe("viewOfPath", () => {
|
|
|
52
53
|
// Assert
|
|
53
54
|
expect(view.content).toBe("Plan v1");
|
|
54
55
|
expect(view.workingCopy).toBe("Plan v1 edited");
|
|
56
|
+
expect(view.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
|
|
55
57
|
expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
56
58
|
expect(view.shelvedAnnotations).toEqual([]);
|
|
57
59
|
});
|
|
@@ -73,9 +75,11 @@ describe("viewOfPath", () => {
|
|
|
73
75
|
|
|
74
76
|
// Assert
|
|
75
77
|
expect(back.workingCopy).toBeUndefined();
|
|
78
|
+
expect(back.textCuts).toBeUndefined();
|
|
76
79
|
expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
77
80
|
expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
|
|
78
81
|
expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
82
|
+
expect(forward.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
|
|
79
83
|
expect(forward.shelvedAnnotations).toEqual([]);
|
|
80
84
|
});
|
|
81
85
|
|
|
@@ -119,6 +123,7 @@ describe("applyPathView", () => {
|
|
|
119
123
|
applyPathView(session, {
|
|
120
124
|
content: "Plan v1",
|
|
121
125
|
workingCopy: undefined,
|
|
126
|
+
textCuts: undefined,
|
|
122
127
|
annotations: [annotation("a1"), annotation("a2")],
|
|
123
128
|
shelvedAnnotations: [],
|
|
124
129
|
});
|
package/src/path-view.ts
CHANGED
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { derivePath, followBranch, MAIN_BRANCH, pathOf, type SessionHistory } from "./history";
|
|
10
|
-
import type { Annotation, Thread } from "./types";
|
|
10
|
+
import type { Annotation, Thread, TextCut } from "./types";
|
|
11
11
|
|
|
12
12
|
export interface PathView {
|
|
13
13
|
/** The last agent revision on the path: what the artifact shows. */
|
|
14
14
|
content: string;
|
|
15
15
|
/** The reviewer's edits over it, when the path's head is a reviewer revision. */
|
|
16
16
|
workingCopy: string | undefined;
|
|
17
|
+
/** Exact character Cuts attached to the reviewer revision at the path head. */
|
|
18
|
+
textCuts: TextCut[] | undefined;
|
|
17
19
|
/** The comments open on the path, in path order. */
|
|
18
20
|
annotations: Annotation[];
|
|
19
21
|
/** Every other comment the session knows. */
|
|
@@ -45,6 +47,7 @@ export function viewOfPath(history: SessionHistory, known: Annotation[]): PathVi
|
|
|
45
47
|
return {
|
|
46
48
|
content,
|
|
47
49
|
workingCopy: derived.head.content === content ? undefined : derived.head.content,
|
|
50
|
+
textCuts: derived.head.by === "reviewer" ? derived.head.textCuts : undefined,
|
|
48
51
|
annotations,
|
|
49
52
|
shelvedAnnotations: [...byId.values()],
|
|
50
53
|
};
|
|
@@ -54,6 +57,8 @@ export function viewOfPath(history: SessionHistory, known: Annotation[]): PathVi
|
|
|
54
57
|
export function applyPathView(session: Thread, view: PathView): void {
|
|
55
58
|
session.artifact = { ...session.artifact, content: view.content };
|
|
56
59
|
session.annotations = view.annotations;
|
|
60
|
+
if (view.textCuts?.length) session.textCuts = view.textCuts;
|
|
61
|
+
else delete session.textCuts;
|
|
57
62
|
if (view.workingCopy === undefined) delete session.workingCopy;
|
|
58
63
|
else session.workingCopy = view.workingCopy;
|
|
59
64
|
if (view.shelvedAnnotations.length === 0) delete session.shelvedAnnotations;
|
package/src/types.test.ts
CHANGED
|
@@ -45,7 +45,7 @@ describe("WORKFLOW_KINDS", () => {
|
|
|
45
45
|
describe("messageAllows", () => {
|
|
46
46
|
test("only approve maps to allow", () => {
|
|
47
47
|
expect(messageAllows("approved")).toBe(true);
|
|
48
|
-
expect(messageAllows("
|
|
48
|
+
expect(messageAllows("comment")).toBe(false);
|
|
49
49
|
expect(messageAllows("changes_requested")).toBe(false);
|
|
50
50
|
});
|
|
51
51
|
});
|
package/src/types.ts
CHANGED
|
@@ -232,7 +232,8 @@ export interface PendingDelivery {
|
|
|
232
232
|
message: Message;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
export
|
|
235
|
+
export const MESSAGE_OUTCOMES = ["comment", "approved", "changes_requested"] as const;
|
|
236
|
+
export type MessageOutcome = (typeof MESSAGE_OUTCOMES)[number];
|
|
236
237
|
|
|
237
238
|
export interface Message {
|
|
238
239
|
/** Stable identity used to deduplicate at-least-once harness delivery. */
|
|
@@ -241,6 +242,8 @@ export interface Message {
|
|
|
241
242
|
summary: string;
|
|
242
243
|
/** The structured review document sent to the harness. */
|
|
243
244
|
body: string;
|
|
245
|
+
/** Annotation snapshots included in this delivery, for incremental sends. */
|
|
246
|
+
annotations?: Annotation[];
|
|
244
247
|
sentAt: string;
|
|
245
248
|
}
|
|
246
249
|
|
|
@@ -294,6 +297,13 @@ export interface ShareLink {
|
|
|
294
297
|
shareBranch?: string;
|
|
295
298
|
}
|
|
296
299
|
|
|
300
|
+
/** One exact character range removed from the submitted artifact source. */
|
|
301
|
+
export interface TextCut {
|
|
302
|
+
start: number;
|
|
303
|
+
end: number;
|
|
304
|
+
quote: string;
|
|
305
|
+
}
|
|
306
|
+
|
|
297
307
|
export interface Thread {
|
|
298
308
|
schemaVersion: string;
|
|
299
309
|
id: string;
|
|
@@ -317,6 +327,8 @@ export interface Thread {
|
|
|
317
327
|
* Undefined = no direct edits.
|
|
318
328
|
*/
|
|
319
329
|
workingCopy?: string;
|
|
330
|
+
/** Exact source ranges behind character Cuts in the current working copy. */
|
|
331
|
+
textCuts?: TextCut[];
|
|
320
332
|
/**
|
|
321
333
|
* File paths the reviewer marked viewed during the guided walk (diff
|
|
322
334
|
* sessions). Persisting with the session means a resumed review keeps its
|
package/src/working-copy.test.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
applyTextCuts,
|
|
4
|
+
blockCutSourceRange,
|
|
5
|
+
cutBlock,
|
|
6
|
+
cutTextRange,
|
|
7
|
+
mergeTextCut,
|
|
8
|
+
restoreTextCut,
|
|
9
|
+
restoreBlock,
|
|
10
|
+
restoreLine,
|
|
11
|
+
sourceChunk,
|
|
12
|
+
} from "./working-copy";
|
|
3
13
|
import { parseBlocks, type Block } from "./markdown";
|
|
4
14
|
|
|
5
15
|
const BASE = `# Plan
|
|
@@ -64,6 +74,134 @@ describe("cutBlock", () => {
|
|
|
64
74
|
expect(cut).not.toContain("const x");
|
|
65
75
|
expect(cut).toContain("- second item");
|
|
66
76
|
});
|
|
77
|
+
|
|
78
|
+
test.each(["Plan", "spans two lines", "first item", "const x"])(
|
|
79
|
+
"reports the exact source interval cut from %s",
|
|
80
|
+
(text) => {
|
|
81
|
+
const target = block(BASE, text);
|
|
82
|
+
const range = blockCutSourceRange(BASE, target);
|
|
83
|
+
|
|
84
|
+
expect(BASE.slice(0, range.start) + BASE.slice(range.end)).toBe(cutBlock(BASE, target));
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("cutTextRange", () => {
|
|
90
|
+
test("cuts only the selected characters inside a block", () => {
|
|
91
|
+
const paragraph = block(BASE, "spans two lines");
|
|
92
|
+
const start = paragraph.text.indexOf("paragraph");
|
|
93
|
+
const cut = cutTextRange(BASE, paragraph, start, paragraph, start + "paragraph".length);
|
|
94
|
+
|
|
95
|
+
expect(cut).toContain("A that\nspans two lines.");
|
|
96
|
+
expect(cut).toContain("## Context");
|
|
97
|
+
expect(cut).toContain("- first item");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("preserves a list marker around a partial cut", () => {
|
|
101
|
+
const item = block(BASE, "first item");
|
|
102
|
+
const cut = cutTextRange(BASE, item, 0, item, "first ".length);
|
|
103
|
+
|
|
104
|
+
expect(cut).toContain("- item");
|
|
105
|
+
expect(cut).not.toContain("- first item");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test.each([
|
|
109
|
+
["heading", "# remove keep", "# keep"],
|
|
110
|
+
["ordered list", "12. remove keep", "12. keep"],
|
|
111
|
+
["fenced code", "```ts\nremove keep\n```", "```ts\n keep\n```"],
|
|
112
|
+
["frontmatter", "---\nname: remove keep\n---", "---\nname: keep\n---"],
|
|
113
|
+
["table", "| remove | keep |\n| --- | --- |", "| | keep |\n| --- | --- |"],
|
|
114
|
+
])("preserves %s source markers", (_label, source, expected) => {
|
|
115
|
+
const target = block(source, "remove");
|
|
116
|
+
const start = target.text.indexOf("remove");
|
|
117
|
+
|
|
118
|
+
expect(cutTextRange(source, target, start, target, start + "remove".length)).toBe(expected);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cuts an exact range across marked quote lines", () => {
|
|
122
|
+
const source = "> keep alpha\n> remove beta\n> keep gamma";
|
|
123
|
+
const quote = parseBlocks(source)[0]!;
|
|
124
|
+
const start = quote.text.indexOf("alpha");
|
|
125
|
+
const end = quote.text.indexOf("keep gamma");
|
|
126
|
+
|
|
127
|
+
const cut = cutTextRange(source, quote, start, quote, end);
|
|
128
|
+
|
|
129
|
+
expect(cut).toBe("> keep keep gamma");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("cuts an exact range across separate blocks", () => {
|
|
133
|
+
const source = "Keep before remove this.\n\n- remove that keep after";
|
|
134
|
+
const blocks = parseBlocks(source);
|
|
135
|
+
const first = blocks[0]!;
|
|
136
|
+
const second = blocks[1]!;
|
|
137
|
+
const start = first.text.indexOf("remove");
|
|
138
|
+
const end = second.text.indexOf("keep after");
|
|
139
|
+
|
|
140
|
+
const cut = cutTextRange(source, first, start, second, end);
|
|
141
|
+
|
|
142
|
+
expect(cut).toBe("Keep before keep after");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("exact text Cuts", () => {
|
|
147
|
+
test("stores and applies the selected source characters exactly", () => {
|
|
148
|
+
const source = "It lands under unin Threads then in the sidebar.";
|
|
149
|
+
const start = source.indexOf("under");
|
|
150
|
+
const end = source.indexOf(" in the sidebar");
|
|
151
|
+
const cuts = mergeTextCut(source, [], start, end);
|
|
152
|
+
|
|
153
|
+
expect(cuts).toEqual([{ start, end, quote: "under unin Threads then" }]);
|
|
154
|
+
expect(applyTextCuts(source, cuts)).toBe("It lands in the sidebar.");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("merges overlapping and adjacent ranges in one linear source interval", () => {
|
|
158
|
+
const source = "alpha beta gamma delta";
|
|
159
|
+
const first = mergeTextCut(source, [], 6, 10);
|
|
160
|
+
const merged = mergeTextCut(source, first, 10, 16);
|
|
161
|
+
|
|
162
|
+
expect(merged).toEqual([{ start: 6, end: 16, quote: "beta gamma" }]);
|
|
163
|
+
expect(applyTextCuts(source, merged)).toBe("alpha delta");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("returns the original references for no-op ranges", () => {
|
|
167
|
+
const source = "unchanged";
|
|
168
|
+
const cuts = [{ start: 0, end: 2, quote: "un" }];
|
|
169
|
+
|
|
170
|
+
expect(mergeTextCut(source, cuts, 4, 4)).toBe(cuts);
|
|
171
|
+
expect(applyTextCuts(source, [])).toBe(source);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("applies out-of-order Cuts and ignores stale or overlapping records", () => {
|
|
175
|
+
const source = "alpha beta gamma";
|
|
176
|
+
|
|
177
|
+
expect(
|
|
178
|
+
applyTextCuts(source, [
|
|
179
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
180
|
+
{ start: 0, end: 5, quote: "alpha" },
|
|
181
|
+
{ start: 2, end: 8, quote: "stale" },
|
|
182
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
183
|
+
]),
|
|
184
|
+
).toBe(" ");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("restores an exact subrange without disturbing the rest of its Cut", () => {
|
|
188
|
+
const source = "alpha beta gamma delta";
|
|
189
|
+
const cuts = mergeTextCut(source, [], 6, 16);
|
|
190
|
+
const restored = restoreTextCut(source, cuts, 11, 16);
|
|
191
|
+
|
|
192
|
+
expect(restored).toEqual([{ start: 6, end: 11, quote: "beta " }]);
|
|
193
|
+
expect(applyTextCuts(source, restored ?? [])).toBe("alpha gamma delta");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not restore across separate Cuts", () => {
|
|
197
|
+
const source = "alpha beta gamma delta";
|
|
198
|
+
const cuts = [
|
|
199
|
+
{ start: 6, end: 10, quote: "beta" },
|
|
200
|
+
{ start: 11, end: 16, quote: "gamma" },
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
expect(restoreTextCut(source, cuts, 6, 16)).toBeNull();
|
|
204
|
+
});
|
|
67
205
|
});
|
|
68
206
|
|
|
69
207
|
describe("restoreBlock", () => {
|
package/src/working-copy.ts
CHANGED
|
@@ -1,11 +1,90 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
1
|
+
/** Working-copy source surgery for block and character cuts plus block restoration. */
|
|
2
|
+
|
|
3
|
+
import { parseBlocks, sourceOffsetAt, type Block } from "./markdown";
|
|
4
|
+
import type { TextCut } from "./types";
|
|
5
|
+
|
|
6
|
+
/** Add one source range and normalize overlaps without copying on a no-op. */
|
|
7
|
+
export function mergeTextCut(
|
|
8
|
+
source: string,
|
|
9
|
+
cuts: readonly TextCut[],
|
|
10
|
+
start: number,
|
|
11
|
+
end: number,
|
|
12
|
+
): TextCut[] {
|
|
13
|
+
const boundedStart = Math.max(0, Math.min(start, source.length));
|
|
14
|
+
const boundedEnd = Math.max(boundedStart, Math.min(end, source.length));
|
|
15
|
+
|
|
16
|
+
if (boundedEnd === boundedStart) {
|
|
17
|
+
// SAFETY: a no-op returns the caller's array unchanged; callers never mutate the result.
|
|
18
|
+
return cuts as TextCut[];
|
|
19
|
+
}
|
|
20
|
+
const ranges = [
|
|
21
|
+
...cuts.map((cut) => ({ start: cut.start, end: cut.end })),
|
|
22
|
+
{ start: boundedStart, end: boundedEnd },
|
|
23
|
+
]
|
|
24
|
+
.map((range) => ({
|
|
25
|
+
start: Math.max(0, Math.min(range.start, source.length)),
|
|
26
|
+
end: Math.max(0, Math.min(range.end, source.length)),
|
|
27
|
+
}))
|
|
28
|
+
.filter((range) => range.end > range.start)
|
|
29
|
+
.toSorted((left, right) => left.start - right.start || left.end - right.end);
|
|
30
|
+
const merged: Array<{ start: number; end: number }> = [];
|
|
31
|
+
|
|
32
|
+
for (const range of ranges) {
|
|
33
|
+
const previous = merged.at(-1);
|
|
34
|
+
|
|
35
|
+
if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end);
|
|
36
|
+
else merged.push(range);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return merged.map((range) => ({
|
|
40
|
+
...range,
|
|
41
|
+
quote: source.slice(range.start, range.end),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Restore a selected part of one contiguous character Cut. */
|
|
46
|
+
export function restoreTextCut(
|
|
47
|
+
source: string,
|
|
48
|
+
cuts: readonly TextCut[],
|
|
49
|
+
start: number,
|
|
50
|
+
end: number,
|
|
51
|
+
): TextCut[] | null {
|
|
52
|
+
const cutIndex = cuts.findIndex((cut) => cut.start <= start && end <= cut.end);
|
|
53
|
+
|
|
54
|
+
if (cutIndex === -1 || end <= start) return null;
|
|
55
|
+
const cut = cuts[cutIndex]!;
|
|
56
|
+
const replacements: TextCut[] = [];
|
|
57
|
+
|
|
58
|
+
if (cut.start < start) {
|
|
59
|
+
replacements.push({ start: cut.start, end: start, quote: source.slice(cut.start, start) });
|
|
60
|
+
}
|
|
61
|
+
if (end < cut.end) {
|
|
62
|
+
replacements.push({ start: end, end: cut.end, quote: source.slice(end, cut.end) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [...cuts.slice(0, cutIndex), ...replacements, ...cuts.slice(cutIndex + 1)];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Apply valid source ranges in source order, ignoring stale or overlapping records. */
|
|
69
|
+
export function applyTextCuts(source: string, cuts: readonly TextCut[]): string {
|
|
70
|
+
if (cuts.length === 0) return source;
|
|
71
|
+
let cursor = 0;
|
|
72
|
+
const pieces: string[] = [];
|
|
7
73
|
|
|
8
|
-
|
|
74
|
+
for (const cut of cuts.toSorted(
|
|
75
|
+
(left, right) => left.start - right.start || left.end - right.end,
|
|
76
|
+
)) {
|
|
77
|
+
if (cut.start < cursor || cut.start < 0 || cut.end > source.length || cut.end <= cut.start)
|
|
78
|
+
continue;
|
|
79
|
+
if (source.slice(cut.start, cut.end) !== cut.quote) continue;
|
|
80
|
+
|
|
81
|
+
pieces.push(source.slice(cursor, cut.start));
|
|
82
|
+
cursor = cut.end;
|
|
83
|
+
}
|
|
84
|
+
pieces.push(source.slice(cursor));
|
|
85
|
+
|
|
86
|
+
return pieces.join("");
|
|
87
|
+
}
|
|
9
88
|
|
|
10
89
|
/** Chunk of the base source a block occupies (for restore and display). */
|
|
11
90
|
export function sourceChunk(base: string, block: Block): string {
|
|
@@ -25,6 +104,44 @@ export function cutBlock(working: string, block: Block): string {
|
|
|
25
104
|
return [...before, ...after].join("\n");
|
|
26
105
|
}
|
|
27
106
|
|
|
107
|
+
/** Source interval removed by a whole-block Cut, including its leading blank-line padding. */
|
|
108
|
+
export function blockCutSourceRange(source: string, block: Block) {
|
|
109
|
+
const lines = source.split("\n");
|
|
110
|
+
let startLine = block.lineStart;
|
|
111
|
+
|
|
112
|
+
while (startLine > 0 && lines[startLine - 1]!.trim() === "") startLine--;
|
|
113
|
+
const lineStarts = [0];
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < source.length; index++) {
|
|
116
|
+
if (source.charCodeAt(index) === 10) lineStarts.push(index + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
start: lineStarts[startLine] ?? source.length,
|
|
121
|
+
end: lineStarts[block.lineEnd + 1] ?? source.length,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove exactly the selected block-text range while retaining surrounding
|
|
127
|
+
* Markdown markers. A cross-block range also removes the source between its
|
|
128
|
+
* endpoint characters.
|
|
129
|
+
*/
|
|
130
|
+
export function cutTextRange(
|
|
131
|
+
working: string,
|
|
132
|
+
startBlock: Block,
|
|
133
|
+
start: number,
|
|
134
|
+
endBlock: Block,
|
|
135
|
+
end: number,
|
|
136
|
+
): string {
|
|
137
|
+
const sourceStart = sourceOffsetAt(working, startBlock, start) ?? -1;
|
|
138
|
+
const sourceEnd = sourceOffsetAt(working, endBlock, end) ?? -1;
|
|
139
|
+
|
|
140
|
+
if (sourceStart < 0 || sourceEnd <= sourceStart) return working;
|
|
141
|
+
|
|
142
|
+
return working.slice(0, sourceStart) + working.slice(sourceEnd);
|
|
143
|
+
}
|
|
144
|
+
|
|
28
145
|
/** The line a restored block re-enters at: the next surviving block's start. */
|
|
29
146
|
export function restoreLine(next: Block | undefined, workingLineCount: number): number {
|
|
30
147
|
return next ? next.lineStart : workingLineCount;
|