stego-cli 0.3.2 → 0.3.4

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,919 @@
1
+ import process from "node:process";
2
+ export const START_SENTINEL = "<!-- stego-comments:start -->";
3
+ export const END_SENTINEL = "<!-- stego-comments:end -->";
4
+ export function parseCommentAppendix(markdown) {
5
+ const lineEnding = markdown.includes("\r\n") ? "\r\n" : "\n";
6
+ const lines = markdown.split(/\r?\n/);
7
+ const startIndexes = indexesOfTrimmedLine(lines, START_SENTINEL);
8
+ const endIndexes = indexesOfTrimmedLine(lines, END_SENTINEL);
9
+ if (startIndexes.length === 0 && endIndexes.length === 0) {
10
+ return {
11
+ contentWithoutComments: markdown,
12
+ comments: [],
13
+ errors: []
14
+ };
15
+ }
16
+ const errors = [];
17
+ if (startIndexes.length !== 1 || endIndexes.length !== 1) {
18
+ if (startIndexes.length !== 1) {
19
+ errors.push(`Expected exactly one '${START_SENTINEL}' marker.`);
20
+ }
21
+ if (endIndexes.length !== 1) {
22
+ errors.push(`Expected exactly one '${END_SENTINEL}' marker.`);
23
+ }
24
+ return {
25
+ contentWithoutComments: markdown,
26
+ comments: [],
27
+ errors
28
+ };
29
+ }
30
+ const start = startIndexes[0];
31
+ const end = endIndexes[0];
32
+ if (end <= start) {
33
+ return {
34
+ contentWithoutComments: markdown,
35
+ comments: [],
36
+ errors: [`'${END_SENTINEL}' must appear after '${START_SENTINEL}'.`]
37
+ };
38
+ }
39
+ let removeStart = start;
40
+ if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
41
+ removeStart -= 1;
42
+ }
43
+ const keptLines = [...lines.slice(0, removeStart), ...lines.slice(end + 1)];
44
+ while (keptLines.length > 0 && keptLines[keptLines.length - 1].trim().length === 0) {
45
+ keptLines.pop();
46
+ }
47
+ const blockLines = lines.slice(start + 1, end);
48
+ const parsed = parseCommentThreads(blockLines, start + 2);
49
+ return {
50
+ contentWithoutComments: keptLines.join(lineEnding),
51
+ comments: parsed.comments,
52
+ errors: parsed.errors
53
+ };
54
+ }
55
+ export function serializeCommentAppendix(comments, lineEnding = "\n") {
56
+ if (comments.length === 0) {
57
+ return "";
58
+ }
59
+ const lines = [];
60
+ lines.push(START_SENTINEL);
61
+ lines.push("");
62
+ for (const comment of comments) {
63
+ lines.push(`<!-- comment: ${comment.id} -->`);
64
+ lines.push(`<!-- meta64: ${encodeCommentMeta64(comment)} -->`);
65
+ const entry = comment.thread[0] ?? "";
66
+ const parsed = parseThreadEntry(entry);
67
+ const displayTimestamp = formatHumanTimestamp(parsed.timestamp || "Unknown time");
68
+ const headerTimestamp = escapeThreadHeaderPart(displayTimestamp);
69
+ const headerAuthor = escapeThreadHeaderPart(parsed.author || "Unknown");
70
+ lines.push(`> _${headerTimestamp} — ${headerAuthor}_`);
71
+ lines.push(">");
72
+ if (comment.paragraphIndex !== undefined && comment.excerpt) {
73
+ const truncated = comment.excerpt.length > 100
74
+ ? comment.excerpt.slice(0, 100).trimEnd() + "…"
75
+ : comment.excerpt;
76
+ lines.push(`> > “${truncated}”`);
77
+ lines.push(">");
78
+ }
79
+ const messageLines = parsed.message ? parsed.message.split(/\r?\n/) : ["(No message)"];
80
+ for (const messageLine of messageLines) {
81
+ lines.push(`> ${messageLine}`);
82
+ }
83
+ lines.push("");
84
+ }
85
+ lines.push(END_SENTINEL);
86
+ return lines.join(lineEnding);
87
+ }
88
+ export function upsertCommentAppendix(contentWithoutComments, comments, lineEnding = "\n") {
89
+ const appendix = serializeCommentAppendix(comments, lineEnding);
90
+ if (!appendix) {
91
+ return contentWithoutComments;
92
+ }
93
+ const trimmed = contentWithoutComments.replace(/\s*$/, "");
94
+ return `${trimmed}${lineEnding}${lineEnding}${appendix}${lineEnding}`;
95
+ }
96
+ export function loadCommentDocumentState(markdownText) {
97
+ const lineEnding = markdownText.includes("\r\n") ? "\r\n" : "\n";
98
+ const parsed = parseCommentAppendix(markdownText);
99
+ const { body, lineOffset } = splitFrontmatterForAnchors(parsed.contentWithoutComments);
100
+ const baseParagraphs = extractParagraphs(body);
101
+ const paragraphs = baseParagraphs.map((paragraph) => ({
102
+ ...paragraph,
103
+ startLine: paragraph.startLine + lineOffset,
104
+ endLine: paragraph.endLine + lineOffset
105
+ }));
106
+ const anchorsById = new Map();
107
+ for (const comment of parsed.comments) {
108
+ anchorsById.set(comment.id, resolveCommentAnchor(comment, paragraphs));
109
+ }
110
+ return {
111
+ lineEnding,
112
+ contentWithoutComments: parsed.contentWithoutComments,
113
+ comments: parsed.comments,
114
+ errors: parsed.errors,
115
+ paragraphs,
116
+ anchorsById
117
+ };
118
+ }
119
+ export function serializeLoadedState(state) {
120
+ const anchorsById = {};
121
+ for (const [id, anchor] of state.anchorsById.entries()) {
122
+ anchorsById[id] = anchor;
123
+ }
124
+ return {
125
+ contentWithoutComments: state.contentWithoutComments,
126
+ comments: state.comments,
127
+ parseErrors: state.errors,
128
+ anchorsById,
129
+ totalCount: state.comments.length,
130
+ unresolvedCount: state.comments.filter((comment) => comment.status === "open").length
131
+ };
132
+ }
133
+ export function ensureNoParseErrors(state) {
134
+ if (state.errors.length === 0) {
135
+ return;
136
+ }
137
+ const first = state.errors[0] ?? "Comment appendix is invalid.";
138
+ throw new Error(first);
139
+ }
140
+ export function addCommentToState(markdownText, state, input) {
141
+ const normalizedMessage = input.message.trim();
142
+ if (!normalizedMessage) {
143
+ throw new Error("Comment message cannot be empty.");
144
+ }
145
+ const now = new Date();
146
+ const createdAt = now.toISOString();
147
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
148
+ const timezoneOffsetMinutes = -now.getTimezoneOffset();
149
+ const commentId = createNextCommentId(state.comments);
150
+ const normalizedAuthor = normalizeAuthor(input.author ?? "");
151
+ const anchor = input.anchor ?? {};
152
+ const paragraphByRange = anchor.range
153
+ ? findParagraphForLine(state.paragraphs, anchor.range.startLine) ?? findPreviousParagraphForLine(state.paragraphs, anchor.range.startLine)
154
+ : undefined;
155
+ const paragraphByCursor = anchor.cursorLine !== undefined
156
+ ? findParagraphForLine(state.paragraphs, anchor.cursorLine) ?? findPreviousParagraphForLine(state.paragraphs, anchor.cursorLine)
157
+ : undefined;
158
+ const paragraph = paragraphByRange ?? paragraphByCursor;
159
+ const excerptFromRange = anchor.range
160
+ ? extractExcerptFromRange(markdownText, anchor.range)
161
+ : undefined;
162
+ const explicitExcerpt = anchor.excerpt ? compactExcerpt(anchor.excerpt) : undefined;
163
+ const nextComment = paragraph
164
+ ? {
165
+ id: commentId,
166
+ status: "open",
167
+ createdAt,
168
+ timezone,
169
+ timezoneOffsetMinutes,
170
+ paragraphIndex: paragraph.index,
171
+ excerpt: explicitExcerpt ?? excerptFromRange ?? compactExcerpt(paragraph.text),
172
+ ...(anchor.range
173
+ ? {
174
+ excerptStartLine: anchor.range.startLine,
175
+ excerptStartCol: anchor.range.startCol,
176
+ excerptEndLine: anchor.range.endLine,
177
+ excerptEndCol: anchor.range.endCol
178
+ }
179
+ : {}),
180
+ thread: [formatThreadEntry(createdAt, normalizedAuthor, normalizedMessage)]
181
+ }
182
+ : {
183
+ id: commentId,
184
+ status: "open",
185
+ createdAt,
186
+ timezone,
187
+ timezoneOffsetMinutes,
188
+ excerpt: "(File-level comment)",
189
+ thread: [formatThreadEntry(createdAt, normalizedAuthor, normalizedMessage)]
190
+ };
191
+ if (input.meta && Object.keys(input.meta).length > 0) {
192
+ // Preserve source meta under signature when serializing.
193
+ nextComment.signature = input.meta;
194
+ }
195
+ return {
196
+ commentId,
197
+ comments: [...state.comments, nextComment]
198
+ };
199
+ }
200
+ export function replyToCommentInState(state, input) {
201
+ const normalizedMessage = input.message.trim();
202
+ if (!normalizedMessage) {
203
+ throw new Error("Reply cannot be empty.");
204
+ }
205
+ const normalizedId = input.commentId.trim().toUpperCase();
206
+ const target = state.comments.find((comment) => comment.id.toUpperCase() === normalizedId);
207
+ if (!target) {
208
+ throw new Error(`Comment ${normalizedId} was not found.`);
209
+ }
210
+ const now = new Date();
211
+ const createdAt = now.toISOString();
212
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
213
+ const timezoneOffsetMinutes = -now.getTimezoneOffset();
214
+ const normalizedAuthor = normalizeAuthor(input.author ?? "");
215
+ const nextId = createNextCommentId(state.comments);
216
+ const reply = {
217
+ id: nextId,
218
+ status: "open",
219
+ createdAt,
220
+ timezone,
221
+ timezoneOffsetMinutes,
222
+ paragraphIndex: target.paragraphIndex,
223
+ excerpt: target.excerpt,
224
+ excerptStartLine: target.excerptStartLine,
225
+ excerptStartCol: target.excerptStartCol,
226
+ excerptEndLine: target.excerptEndLine,
227
+ excerptEndCol: target.excerptEndCol,
228
+ thread: [formatThreadEntry(createdAt, normalizedAuthor, normalizedMessage)]
229
+ };
230
+ return {
231
+ commentId: nextId,
232
+ comments: [...state.comments, reply]
233
+ };
234
+ }
235
+ export function setCommentStatusInState(state, input) {
236
+ const normalizedId = input.commentId.trim().toUpperCase();
237
+ const target = state.comments.find((comment) => comment.id.toUpperCase() === normalizedId);
238
+ if (!target) {
239
+ throw new Error(`Comment ${normalizedId} was not found.`);
240
+ }
241
+ const threadKey = getThreadKey(target);
242
+ const changedIds = [];
243
+ const nextComments = state.comments.map((comment) => {
244
+ const shouldChange = input.thread
245
+ ? getThreadKey(comment) === threadKey
246
+ : comment.id.toUpperCase() === normalizedId;
247
+ if (!shouldChange) {
248
+ return comment;
249
+ }
250
+ changedIds.push(comment.id);
251
+ return {
252
+ ...comment,
253
+ status: input.status
254
+ };
255
+ });
256
+ return {
257
+ changedIds,
258
+ comments: nextComments
259
+ };
260
+ }
261
+ export function deleteCommentInState(state, commentId) {
262
+ const normalizedId = commentId.trim().toUpperCase();
263
+ const next = state.comments.filter((comment) => comment.id.toUpperCase() !== normalizedId);
264
+ const removed = state.comments.length - next.length;
265
+ if (removed === 0) {
266
+ throw new Error(`Comment ${normalizedId} was not found.`);
267
+ }
268
+ return {
269
+ removed,
270
+ comments: next
271
+ };
272
+ }
273
+ export function clearResolvedInState(state) {
274
+ const before = state.comments.length;
275
+ const next = state.comments.filter((comment) => comment.status !== "resolved");
276
+ return {
277
+ removed: before - next.length,
278
+ comments: next
279
+ };
280
+ }
281
+ export function syncAnchorsInState(markdownText, state, input) {
282
+ const updatesById = new Map();
283
+ for (const update of input.updates ?? []) {
284
+ updatesById.set(update.id.trim().toUpperCase(), update);
285
+ }
286
+ let updatedCount = 0;
287
+ let nextComments = state.comments.map((comment) => {
288
+ const update = updatesById.get(comment.id.toUpperCase());
289
+ if (!update) {
290
+ return comment;
291
+ }
292
+ const range = {
293
+ startLine: update.start.line,
294
+ startCol: update.start.col,
295
+ endLine: update.end.line,
296
+ endCol: update.end.col
297
+ };
298
+ if (!hasValidRange(range)) {
299
+ return comment;
300
+ }
301
+ const excerpt = extractExcerptFromRange(markdownText, range) ?? comment.excerpt;
302
+ const paragraph = findParagraphForLine(state.paragraphs, range.startLine)
303
+ ?? findPreviousParagraphForLine(state.paragraphs, range.startLine);
304
+ updatedCount += 1;
305
+ return {
306
+ ...comment,
307
+ paragraphIndex: paragraph?.index,
308
+ excerpt,
309
+ excerptStartLine: range.startLine,
310
+ excerptStartCol: range.startCol,
311
+ excerptEndLine: range.endLine,
312
+ excerptEndCol: range.endCol
313
+ };
314
+ });
315
+ const deleteIdSet = new Set((input.deleteIds ?? []).map((id) => id.trim().toUpperCase()).filter((id) => id.length > 0));
316
+ const beforeDelete = nextComments.length;
317
+ if (deleteIdSet.size > 0) {
318
+ nextComments = nextComments.filter((comment) => !deleteIdSet.has(comment.id.toUpperCase()));
319
+ }
320
+ return {
321
+ updatedCount,
322
+ deletedCount: beforeDelete - nextComments.length,
323
+ comments: nextComments
324
+ };
325
+ }
326
+ export function renderStateDocument(state, comments) {
327
+ return upsertCommentAppendix(state.contentWithoutComments, comments, state.lineEnding);
328
+ }
329
+ export function normalizeAuthor(value) {
330
+ const author = value.trim();
331
+ if (author) {
332
+ return author;
333
+ }
334
+ return process.env.GIT_AUTHOR_NAME
335
+ || process.env.USER
336
+ || process.env.USERNAME
337
+ || "Unknown";
338
+ }
339
+ function parseCommentThreads(lines, baseLineNumber) {
340
+ const comments = [];
341
+ const errors = [];
342
+ let index = 0;
343
+ while (index < lines.length) {
344
+ const trimmed = lines[index].trim();
345
+ if (!trimmed) {
346
+ index += 1;
347
+ continue;
348
+ }
349
+ const heading = trimmed.match(/^<!--\s*comment:\s*(CMT-\d{4,})\s*-->$/);
350
+ if (!heading) {
351
+ errors.push(`Line ${baseLineNumber + index}: Expected comment delimiter '<!-- comment: CMT-0001 -->'.`);
352
+ index += 1;
353
+ continue;
354
+ }
355
+ const threadId = heading[1].toUpperCase();
356
+ index += 1;
357
+ const rows = [];
358
+ const rowLineNumbers = [];
359
+ while (index < lines.length) {
360
+ const rowTrimmed = lines[index].trim();
361
+ if (/^<!--\s*comment:\s*CMT-\d{4,}\s*-->$/.test(rowTrimmed)) {
362
+ break;
363
+ }
364
+ rows.push(lines[index]);
365
+ rowLineNumbers.push(baseLineNumber + index);
366
+ index += 1;
367
+ }
368
+ const parsed = parseSingleThread(threadId, rows, rowLineNumbers);
369
+ comments.push(parsed.comment);
370
+ errors.push(...parsed.errors);
371
+ }
372
+ return { comments, errors };
373
+ }
374
+ function parseSingleThread(id, rows, rowLineNumbers) {
375
+ let status = "open";
376
+ const thread = [];
377
+ const errors = [];
378
+ let paragraphIndex;
379
+ let createdAt;
380
+ let timezone;
381
+ let timezoneOffsetMinutes;
382
+ let excerpt;
383
+ let excerptStartLine;
384
+ let excerptStartCol;
385
+ let excerptEndLine;
386
+ let excerptEndCol;
387
+ let signature;
388
+ let sawMeta64 = false;
389
+ let rowIndex = 0;
390
+ while (rowIndex < rows.length) {
391
+ const raw = rows[rowIndex];
392
+ const lineNumber = rowLineNumbers[rowIndex] ?? 0;
393
+ const trimmed = raw.trim();
394
+ if (!trimmed) {
395
+ rowIndex += 1;
396
+ continue;
397
+ }
398
+ if (thread.length > 0) {
399
+ errors.push(`Line ${lineNumber}: Multiple message blocks found for ${id}. Create a new CMT id for each reply.`);
400
+ break;
401
+ }
402
+ if (!sawMeta64) {
403
+ const metaMatch = trimmed.match(/^<!--\s*meta64:\s*(\S+)\s*-->\s*$/);
404
+ if (!metaMatch) {
405
+ errors.push(`Line ${lineNumber}: Invalid comment metadata row '${trimmed}'. Expected '<!-- meta64: <base64url-json> -->'.`);
406
+ rowIndex += 1;
407
+ continue;
408
+ }
409
+ sawMeta64 = true;
410
+ const decoded = decodeCommentMeta64(metaMatch[1], id, lineNumber, errors);
411
+ if (decoded) {
412
+ status = decoded.status;
413
+ createdAt = decoded.createdAt;
414
+ timezone = decoded.timezone;
415
+ timezoneOffsetMinutes = decoded.timezoneOffsetMinutes;
416
+ paragraphIndex = decoded.paragraphIndex;
417
+ excerptStartLine = decoded.excerptStartLine;
418
+ excerptStartCol = decoded.excerptStartCol;
419
+ excerptEndLine = decoded.excerptEndLine;
420
+ excerptEndCol = decoded.excerptEndCol;
421
+ signature = decoded.signature;
422
+ }
423
+ rowIndex += 1;
424
+ continue;
425
+ }
426
+ const headerQuote = extractQuotedLine(raw);
427
+ if (headerQuote === undefined) {
428
+ errors.push(`Line ${lineNumber}: Invalid thread header '${trimmed}'. Expected blockquote header like '> _timestamp — author_'.`);
429
+ rowIndex += 1;
430
+ continue;
431
+ }
432
+ const header = parseThreadHeader(headerQuote);
433
+ if (!header) {
434
+ errors.push(`Line ${lineNumber}: Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp — author_'.`);
435
+ rowIndex += 1;
436
+ continue;
437
+ }
438
+ rowIndex += 1;
439
+ while (rowIndex < rows.length) {
440
+ const separatorRaw = rows[rowIndex];
441
+ const separatorTrimmed = separatorRaw.trim();
442
+ if (!separatorTrimmed) {
443
+ rowIndex += 1;
444
+ continue;
445
+ }
446
+ const separatorQuote = extractQuotedLine(separatorRaw);
447
+ if (separatorQuote !== undefined && separatorQuote.trim().length === 0) {
448
+ rowIndex += 1;
449
+ }
450
+ break;
451
+ }
452
+ if (rowIndex < rows.length) {
453
+ const nestedMatch = rows[rowIndex].match(/^\s*>\s*>\s*(.*)$/);
454
+ if (nestedMatch) {
455
+ let excerptContent = nestedMatch[1].trim();
456
+ excerptContent = excerptContent.replace(/^[\u201c"]\s*/, "").replace(/\s*[\u201d"]$/, "");
457
+ excerpt = excerptContent;
458
+ rowIndex += 1;
459
+ while (rowIndex < rows.length) {
460
+ const sepRaw = rows[rowIndex];
461
+ const sepTrimmed = sepRaw.trim();
462
+ if (!sepTrimmed) {
463
+ rowIndex += 1;
464
+ continue;
465
+ }
466
+ const sepQuote = extractQuotedLine(sepRaw);
467
+ if (sepQuote !== undefined && sepQuote.trim().length === 0) {
468
+ rowIndex += 1;
469
+ }
470
+ break;
471
+ }
472
+ }
473
+ }
474
+ const messageLines = [];
475
+ while (rowIndex < rows.length) {
476
+ const messageRaw = rows[rowIndex];
477
+ const messageLineNumber = rowLineNumbers[rowIndex] ?? lineNumber;
478
+ const messageTrimmed = messageRaw.trim();
479
+ if (!messageTrimmed) {
480
+ rowIndex += 1;
481
+ if (messageLines.length > 0) {
482
+ break;
483
+ }
484
+ continue;
485
+ }
486
+ const messageQuote = extractQuotedLine(messageRaw);
487
+ if (messageQuote === undefined) {
488
+ errors.push(`Line ${messageLineNumber}: Invalid thread line '${messageTrimmed}'. Expected blockquote content starting with '>'.`);
489
+ rowIndex += 1;
490
+ if (messageLines.length > 0) {
491
+ break;
492
+ }
493
+ continue;
494
+ }
495
+ if (parseThreadHeader(messageQuote)) {
496
+ break;
497
+ }
498
+ messageLines.push(messageQuote);
499
+ rowIndex += 1;
500
+ }
501
+ while (messageLines.length > 0 && messageLines[messageLines.length - 1].trim().length === 0) {
502
+ messageLines.pop();
503
+ }
504
+ if (messageLines.length === 0) {
505
+ errors.push(`Line ${lineNumber}: Thread entry for ${id} is missing message text.`);
506
+ continue;
507
+ }
508
+ const message = messageLines.join("\n").trim();
509
+ thread.push(`${createdAt || header.timestamp} | ${header.author} | ${message}`);
510
+ }
511
+ if (!sawMeta64) {
512
+ errors.push(`Comment ${id}: Missing metadata row ('<!-- meta64: <base64url-json> -->').`);
513
+ }
514
+ if (thread.length === 0) {
515
+ errors.push(`Comment ${id}: Missing valid blockquote thread entries.`);
516
+ }
517
+ const comment = {
518
+ id,
519
+ status,
520
+ createdAt,
521
+ timezone,
522
+ timezoneOffsetMinutes,
523
+ paragraphIndex,
524
+ excerpt,
525
+ excerptStartLine,
526
+ excerptStartCol,
527
+ excerptEndLine,
528
+ excerptEndCol,
529
+ thread
530
+ };
531
+ if (signature && Object.keys(signature).length > 0) {
532
+ comment.signature = signature;
533
+ }
534
+ return {
535
+ comment,
536
+ errors
537
+ };
538
+ }
539
+ function encodeCommentMeta64(comment) {
540
+ const payload = {
541
+ status: comment.status
542
+ };
543
+ if (comment.createdAt) {
544
+ payload.created_at = comment.createdAt;
545
+ }
546
+ if (comment.timezone) {
547
+ payload.timezone = comment.timezone;
548
+ }
549
+ if (comment.timezoneOffsetMinutes !== undefined) {
550
+ payload.timezone_offset_minutes = comment.timezoneOffsetMinutes;
551
+ }
552
+ if (comment.paragraphIndex !== undefined) {
553
+ payload.paragraph_index = comment.paragraphIndex;
554
+ }
555
+ if (comment.excerptStartLine !== undefined) {
556
+ payload.excerpt_start_line = comment.excerptStartLine;
557
+ }
558
+ if (comment.excerptStartCol !== undefined) {
559
+ payload.excerpt_start_col = comment.excerptStartCol;
560
+ }
561
+ if (comment.excerptEndLine !== undefined) {
562
+ payload.excerpt_end_line = comment.excerptEndLine;
563
+ }
564
+ if (comment.excerptEndCol !== undefined) {
565
+ payload.excerpt_end_col = comment.excerptEndCol;
566
+ }
567
+ const signature = comment.signature;
568
+ if (signature && Object.keys(signature).length > 0) {
569
+ payload.signature = signature;
570
+ }
571
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
572
+ }
573
+ function decodeCommentMeta64(encoded, commentId, lineNumber, errors) {
574
+ let rawJson = "";
575
+ try {
576
+ rawJson = Buffer.from(encoded, "base64url").toString("utf8");
577
+ }
578
+ catch {
579
+ errors.push(`Line ${lineNumber}: Invalid meta64 payload for ${commentId}; expected base64url-encoded JSON.`);
580
+ return undefined;
581
+ }
582
+ let parsed;
583
+ try {
584
+ parsed = JSON.parse(rawJson);
585
+ }
586
+ catch {
587
+ errors.push(`Line ${lineNumber}: Invalid meta64 JSON for ${commentId}.`);
588
+ return undefined;
589
+ }
590
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
591
+ errors.push(`Line ${lineNumber}: Invalid meta64 object for ${commentId}.`);
592
+ return undefined;
593
+ }
594
+ const record = parsed;
595
+ const status = record.status === "open" || record.status === "resolved"
596
+ ? record.status
597
+ : undefined;
598
+ if (!status) {
599
+ errors.push(`Line ${lineNumber}: meta64 for ${commentId} is missing valid 'status' ('open' or 'resolved').`);
600
+ return undefined;
601
+ }
602
+ const signature = isPlainObject(record.signature) ? record.signature : undefined;
603
+ return {
604
+ status,
605
+ createdAt: typeof record.created_at === "string" ? record.created_at : undefined,
606
+ timezone: typeof record.timezone === "string" ? record.timezone : undefined,
607
+ timezoneOffsetMinutes: parseOptionalSignedInteger(record.timezone_offset_minutes),
608
+ paragraphIndex: parseOptionalInteger(record.paragraph_index),
609
+ excerptStartLine: parseOptionalInteger(record.excerpt_start_line),
610
+ excerptStartCol: parseOptionalInteger(record.excerpt_start_col),
611
+ excerptEndLine: parseOptionalInteger(record.excerpt_end_line),
612
+ excerptEndCol: parseOptionalInteger(record.excerpt_end_col),
613
+ signature
614
+ };
615
+ }
616
+ function parseOptionalInteger(value) {
617
+ if (value === undefined || value === null) {
618
+ return undefined;
619
+ }
620
+ if (typeof value === "number") {
621
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
622
+ }
623
+ if (typeof value !== "string" || !/^\d+$/.test(value.trim())) {
624
+ return undefined;
625
+ }
626
+ return Number(value.trim());
627
+ }
628
+ function parseOptionalSignedInteger(value) {
629
+ if (value === undefined || value === null) {
630
+ return undefined;
631
+ }
632
+ if (typeof value === "number") {
633
+ return Number.isInteger(value) ? value : undefined;
634
+ }
635
+ if (typeof value !== "string" || !/^-?\d+$/.test(value.trim())) {
636
+ return undefined;
637
+ }
638
+ return Number(value.trim());
639
+ }
640
+ function splitFrontmatterForAnchors(markdownText) {
641
+ const frontmatterMatch = markdownText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
642
+ if (!frontmatterMatch) {
643
+ return {
644
+ body: markdownText,
645
+ lineOffset: 0
646
+ };
647
+ }
648
+ const consumed = frontmatterMatch[0];
649
+ const body = markdownText.slice(consumed.length);
650
+ return {
651
+ body,
652
+ lineOffset: countLineBreaks(consumed)
653
+ };
654
+ }
655
+ function countLineBreaks(value) {
656
+ const matches = value.match(/\r?\n/g);
657
+ return matches ? matches.length : 0;
658
+ }
659
+ function extractParagraphs(markdownText) {
660
+ const lines = markdownText.split(/\r?\n/);
661
+ const paragraphs = [];
662
+ let currentStart = -1;
663
+ const currentLines = [];
664
+ const flush = (endIndex) => {
665
+ if (currentStart < 0 || currentLines.length === 0) {
666
+ return;
667
+ }
668
+ const joined = currentLines.join(" ").replace(/\s+/g, " ").trim();
669
+ if (joined.length > 0) {
670
+ paragraphs.push({
671
+ index: paragraphs.length,
672
+ startLine: currentStart + 1,
673
+ endLine: endIndex + 1,
674
+ text: joined
675
+ });
676
+ }
677
+ currentStart = -1;
678
+ currentLines.length = 0;
679
+ };
680
+ for (let index = 0; index < lines.length; index += 1) {
681
+ const trimmed = lines[index].trim();
682
+ if (!trimmed) {
683
+ flush(index - 1);
684
+ continue;
685
+ }
686
+ if (currentStart < 0) {
687
+ currentStart = index;
688
+ }
689
+ currentLines.push(trimmed);
690
+ }
691
+ flush(lines.length - 1);
692
+ return paragraphs;
693
+ }
694
+ function resolveCommentAnchor(comment, paragraphs) {
695
+ if (comment.paragraphIndex === undefined) {
696
+ return {
697
+ anchorType: "file",
698
+ line: 1,
699
+ degraded: false
700
+ };
701
+ }
702
+ const matched = paragraphs.find((paragraph) => paragraph.index === comment.paragraphIndex);
703
+ if (matched) {
704
+ const anchor = {
705
+ anchorType: "paragraph",
706
+ line: matched.startLine,
707
+ degraded: false
708
+ };
709
+ if (hasValidExcerptRange(comment)) {
710
+ anchor.underlineStartLine = comment.excerptStartLine;
711
+ anchor.underlineStartCol = comment.excerptStartCol;
712
+ anchor.underlineEndLine = comment.excerptEndLine;
713
+ anchor.underlineEndCol = comment.excerptEndCol;
714
+ }
715
+ else {
716
+ anchor.paragraphEndLine = matched.endLine;
717
+ }
718
+ return anchor;
719
+ }
720
+ for (let index = comment.paragraphIndex - 1; index >= 0; index -= 1) {
721
+ const previous = paragraphs.find((paragraph) => paragraph.index === index);
722
+ if (previous) {
723
+ return {
724
+ anchorType: "paragraph",
725
+ line: previous.startLine,
726
+ degraded: true
727
+ };
728
+ }
729
+ }
730
+ return {
731
+ anchorType: "file",
732
+ line: 1,
733
+ degraded: true
734
+ };
735
+ }
736
+ function hasValidExcerptRange(comment) {
737
+ if (comment.excerptStartLine === undefined
738
+ || comment.excerptStartCol === undefined
739
+ || comment.excerptEndLine === undefined
740
+ || comment.excerptEndCol === undefined) {
741
+ return false;
742
+ }
743
+ return comment.excerptStartLine < comment.excerptEndLine
744
+ || (comment.excerptStartLine === comment.excerptEndLine && comment.excerptStartCol < comment.excerptEndCol);
745
+ }
746
+ function hasValidRange(range) {
747
+ return range.startLine < range.endLine
748
+ || (range.startLine === range.endLine && range.startCol < range.endCol);
749
+ }
750
+ function findParagraphForLine(paragraphs, lineNumber) {
751
+ return paragraphs.find((paragraph) => lineNumber >= paragraph.startLine && lineNumber <= paragraph.endLine);
752
+ }
753
+ function findPreviousParagraphForLine(paragraphs, lineNumber) {
754
+ for (let index = paragraphs.length - 1; index >= 0; index -= 1) {
755
+ if (paragraphs[index].endLine < lineNumber) {
756
+ return paragraphs[index];
757
+ }
758
+ }
759
+ return undefined;
760
+ }
761
+ function parseThreadEntry(entry) {
762
+ const firstPipe = entry.indexOf("|");
763
+ if (firstPipe < 0) {
764
+ return {
765
+ timestamp: "",
766
+ author: "Unknown",
767
+ message: entry.trim()
768
+ };
769
+ }
770
+ const secondPipe = entry.indexOf("|", firstPipe + 1);
771
+ if (secondPipe < 0) {
772
+ return {
773
+ timestamp: entry.slice(0, firstPipe).trim(),
774
+ author: "Unknown",
775
+ message: entry.slice(firstPipe + 1).trim()
776
+ };
777
+ }
778
+ return {
779
+ timestamp: entry.slice(0, firstPipe).trim(),
780
+ author: entry.slice(firstPipe + 1, secondPipe).trim() || "Unknown",
781
+ message: entry.slice(secondPipe + 1).trim()
782
+ };
783
+ }
784
+ function parseThreadHeader(value) {
785
+ const match = value.trim().match(/^_(.+?)\s*—\s*(.+?)_\s*$/);
786
+ if (!match) {
787
+ return undefined;
788
+ }
789
+ const timestamp = match[1].trim();
790
+ const author = match[2].trim();
791
+ if (!timestamp || !author) {
792
+ return undefined;
793
+ }
794
+ return { timestamp, author };
795
+ }
796
+ function extractQuotedLine(raw) {
797
+ const quoteMatch = raw.match(/^\s*>\s?(.*)$/);
798
+ if (!quoteMatch) {
799
+ return undefined;
800
+ }
801
+ return quoteMatch[1];
802
+ }
803
+ function formatHumanTimestamp(raw) {
804
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) {
805
+ return raw;
806
+ }
807
+ const date = new Date(raw);
808
+ if (isNaN(date.getTime())) {
809
+ return raw;
810
+ }
811
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
812
+ const month = months[date.getUTCMonth()];
813
+ const day = date.getUTCDate();
814
+ const year = date.getUTCFullYear();
815
+ const hours24 = date.getUTCHours();
816
+ const hours = hours24 % 12 || 12;
817
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
818
+ const ampm = hours24 < 12 ? "AM" : "PM";
819
+ return `${month} ${day}, ${year}, ${hours}:${minutes} ${ampm}`;
820
+ }
821
+ function escapeThreadHeaderPart(value) {
822
+ return value
823
+ .replace(/\\/g, "\\\\")
824
+ .replace(/_/g, "\\_");
825
+ }
826
+ function formatThreadEntry(timestamp, author, message) {
827
+ return `${timestamp} | ${author} | ${message}`;
828
+ }
829
+ function compactExcerpt(value, max = 180) {
830
+ const compact = value.replace(/\s+/g, " ").trim();
831
+ if (compact.length <= max) {
832
+ return compact;
833
+ }
834
+ return `${compact.slice(0, max - 1)}…`;
835
+ }
836
+ function extractExcerptFromRange(markdownText, range) {
837
+ const lines = markdownText.split(/\r?\n/);
838
+ const startLineIndex = range.startLine - 1;
839
+ const endLineIndex = range.endLine - 1;
840
+ if (startLineIndex < 0 || endLineIndex < 0 || startLineIndex >= lines.length || endLineIndex >= lines.length) {
841
+ return undefined;
842
+ }
843
+ if (!hasValidRange(range)) {
844
+ return undefined;
845
+ }
846
+ const startLineText = lines[startLineIndex] ?? "";
847
+ const endLineText = lines[endLineIndex] ?? "";
848
+ const safeStartCol = clamp(range.startCol, 0, startLineText.length);
849
+ const safeEndCol = clamp(range.endCol, 0, endLineText.length);
850
+ let selected = "";
851
+ if (startLineIndex === endLineIndex) {
852
+ if (safeStartCol >= safeEndCol) {
853
+ return undefined;
854
+ }
855
+ selected = startLineText.slice(safeStartCol, safeEndCol);
856
+ }
857
+ else {
858
+ const segments = [];
859
+ segments.push(startLineText.slice(safeStartCol));
860
+ for (let lineIndex = startLineIndex + 1; lineIndex < endLineIndex; lineIndex += 1) {
861
+ segments.push(lines[lineIndex] ?? "");
862
+ }
863
+ segments.push(endLineText.slice(0, safeEndCol));
864
+ selected = segments.join("\n");
865
+ }
866
+ const compact = compactExcerpt(selected);
867
+ return compact || undefined;
868
+ }
869
+ function clamp(value, min, max) {
870
+ if (value < min) {
871
+ return min;
872
+ }
873
+ if (value > max) {
874
+ return max;
875
+ }
876
+ return value;
877
+ }
878
+ function createNextCommentId(comments) {
879
+ let max = 0;
880
+ for (const comment of comments) {
881
+ const match = comment.id.match(/^CMT-(\d{4,})$/i);
882
+ if (!match) {
883
+ continue;
884
+ }
885
+ const value = Number(match[1]);
886
+ if (Number.isFinite(value) && value > max) {
887
+ max = value;
888
+ }
889
+ }
890
+ return `CMT-${String(max + 1).padStart(4, "0")}`;
891
+ }
892
+ function getThreadKey(comment) {
893
+ const hasExplicitExcerptAnchor = hasValidExcerptRange(comment);
894
+ if (hasExplicitExcerptAnchor) {
895
+ return [
896
+ "excerpt",
897
+ String(comment.paragraphIndex ?? -1),
898
+ String(comment.excerptStartLine),
899
+ String(comment.excerptStartCol),
900
+ String(comment.excerptEndLine),
901
+ String(comment.excerptEndCol)
902
+ ].join(":");
903
+ }
904
+ return comment.paragraphIndex !== undefined
905
+ ? `paragraph:${comment.paragraphIndex}`
906
+ : "file";
907
+ }
908
+ function indexesOfTrimmedLine(lines, needle) {
909
+ const indexes = [];
910
+ for (let index = 0; index < lines.length; index += 1) {
911
+ if (lines[index].trim() === needle) {
912
+ indexes.push(index);
913
+ }
914
+ }
915
+ return indexes;
916
+ }
917
+ function isPlainObject(value) {
918
+ return typeof value === "object" && value !== null && !Array.isArray(value);
919
+ }