stego-cli 0.3.2 → 0.3.3

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.
@@ -20,10 +20,34 @@ export function addCommentToManuscript(request) {
20
20
  throw new CommentsCommandError("NOT_STEGO_MANUSCRIPT", 3, "File is not recognized as a Stego manuscript (missing frontmatter and comments sentinels).");
21
21
  }
22
22
  const commentId = getNextCommentId(lines, sentinelState);
23
- const createdAt = new Date().toISOString();
24
- const meta = buildMetaPayload(request.range, request.sourceMeta);
23
+ const now = new Date();
24
+ const createdAt = now.toISOString();
25
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
26
+ const timezoneOffsetMinutes = -now.getTimezoneOffset();
27
+ const paragraphIndex = request.range
28
+ ? resolveParagraphIndex(lines, lineEnding, sentinelState, request.range)
29
+ : undefined;
30
+ const excerpt = request.range
31
+ ? extractExcerptForRange(lines, request.range)
32
+ : undefined;
33
+ const meta = buildMetaPayload({
34
+ createdAt,
35
+ timezone,
36
+ timezoneOffsetMinutes,
37
+ paragraphIndex,
38
+ range: request.range,
39
+ sourceMeta: request.sourceMeta
40
+ });
25
41
  const meta64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64url");
26
- const entryLines = buildCommentEntryLines(commentId, createdAt, request.author, request.message, meta64);
42
+ const entryLines = buildCommentEntryLines({
43
+ commentId,
44
+ createdAt,
45
+ author: request.author,
46
+ message: request.message,
47
+ meta64,
48
+ paragraphIndex,
49
+ excerpt
50
+ });
27
51
  const nextLines = applyCommentEntry(lines, sentinelState, entryLines);
28
52
  const nextContent = `${nextLines.join(lineEnding)}${lineEnding}`;
29
53
  try {
@@ -51,37 +75,53 @@ export function addCommentToManuscript(request) {
51
75
  };
52
76
  return result;
53
77
  }
54
- function buildMetaPayload(range, sourceMeta) {
78
+ function buildMetaPayload(input) {
55
79
  const payload = {
56
- status: "open"
80
+ status: "open",
81
+ created_at: input.createdAt,
82
+ timezone_offset_minutes: input.timezoneOffsetMinutes
57
83
  };
58
- if (range) {
59
- payload.anchor = "selection";
60
- payload.excerpt = {
61
- start_line: range.startLine,
62
- start_col: range.startCol,
63
- end_line: range.endLine,
64
- end_col: range.endCol
65
- };
84
+ if (input.timezone) {
85
+ payload.timezone = input.timezone;
86
+ }
87
+ if (input.paragraphIndex !== undefined) {
88
+ payload.paragraph_index = input.paragraphIndex;
66
89
  }
67
- if (sourceMeta && Object.keys(sourceMeta).length > 0) {
68
- payload.signature = sourceMeta;
90
+ if (input.range) {
91
+ payload.excerpt_start_line = input.range.startLine;
92
+ payload.excerpt_start_col = input.range.startCol;
93
+ payload.excerpt_end_line = input.range.endLine;
94
+ payload.excerpt_end_col = input.range.endCol;
95
+ }
96
+ if (input.sourceMeta && Object.keys(input.sourceMeta).length > 0) {
97
+ payload.signature = input.sourceMeta;
69
98
  }
70
99
  return payload;
71
100
  }
72
- function buildCommentEntryLines(commentId, timestamp, author, message, meta64) {
73
- const safeAuthor = author.trim() || "Saurus";
74
- const normalizedMessageLines = message
101
+ function buildCommentEntryLines(input) {
102
+ const safeAuthor = input.author.trim() || "Saurus";
103
+ const normalizedMessageLines = input.message
75
104
  .replace(/\r\n/g, "\n")
76
105
  .split("\n")
77
106
  .map((line) => line.trimEnd());
78
- return [
79
- `<!-- comment: ${commentId} -->`,
80
- `<!-- meta64: ${meta64} -->`,
81
- `> _${timestamp} | ${safeAuthor}_`,
82
- ">",
83
- ...normalizedMessageLines.map((line) => `> ${line}`)
107
+ const displayTimestamp = formatHumanTimestamp(input.createdAt);
108
+ const headerTimestamp = escapeThreadHeaderPart(displayTimestamp);
109
+ const headerAuthor = escapeThreadHeaderPart(safeAuthor);
110
+ const lines = [
111
+ `<!-- comment: ${input.commentId} -->`,
112
+ `<!-- meta64: ${input.meta64} -->`,
113
+ `> _${headerTimestamp} — ${headerAuthor}_`,
114
+ ">"
84
115
  ];
116
+ if (input.paragraphIndex !== undefined && input.excerpt) {
117
+ const truncatedExcerpt = input.excerpt.length > 100
118
+ ? `${input.excerpt.slice(0, 100).trimEnd()}…`
119
+ : input.excerpt;
120
+ lines.push(`> > “${truncatedExcerpt}”`);
121
+ lines.push(">");
122
+ }
123
+ lines.push(...normalizedMessageLines.map((line) => `> ${line}`));
124
+ return lines;
85
125
  }
86
126
  function applyCommentEntry(lines, sentinelState, entryLines) {
87
127
  if (!sentinelState.hasSentinels) {
@@ -162,6 +202,166 @@ function getNextCommentId(lines, sentinelState) {
162
202
  const next = maxId + 1;
163
203
  return `CMT-${String(next).padStart(4, "0")}`;
164
204
  }
205
+ function resolveParagraphIndex(lines, lineEnding, sentinelState, range) {
206
+ const markdownWithoutComments = stripCommentsAppendix(lines, sentinelState, lineEnding);
207
+ const { body, lineOffset } = splitFrontmatterForAnchors(markdownWithoutComments);
208
+ const paragraphs = extractParagraphs(body).map((paragraph) => ({
209
+ ...paragraph,
210
+ startLine: paragraph.startLine + lineOffset,
211
+ endLine: paragraph.endLine + lineOffset
212
+ }));
213
+ const matched = findParagraphForLine(paragraphs, range.startLine) ?? findPreviousParagraphForLine(paragraphs, range.startLine);
214
+ return matched?.index;
215
+ }
216
+ function extractExcerptForRange(lines, range) {
217
+ const startLineIndex = range.startLine - 1;
218
+ const endLineIndex = range.endLine - 1;
219
+ if (startLineIndex < 0 || endLineIndex < 0 || startLineIndex >= lines.length || endLineIndex >= lines.length) {
220
+ return undefined;
221
+ }
222
+ if (startLineIndex > endLineIndex || (startLineIndex === endLineIndex && range.startCol >= range.endCol)) {
223
+ return undefined;
224
+ }
225
+ const startLineText = lines[startLineIndex] ?? "";
226
+ const endLineText = lines[endLineIndex] ?? "";
227
+ const safeStartCol = clamp(range.startCol, 0, startLineText.length);
228
+ const safeEndCol = clamp(range.endCol, 0, endLineText.length);
229
+ let selected = "";
230
+ if (startLineIndex === endLineIndex) {
231
+ if (safeStartCol >= safeEndCol) {
232
+ return undefined;
233
+ }
234
+ selected = startLineText.slice(safeStartCol, safeEndCol);
235
+ }
236
+ else {
237
+ const segments = [];
238
+ segments.push(startLineText.slice(safeStartCol));
239
+ for (let lineIndex = startLineIndex + 1; lineIndex < endLineIndex; lineIndex += 1) {
240
+ segments.push(lines[lineIndex] ?? "");
241
+ }
242
+ segments.push(endLineText.slice(0, safeEndCol));
243
+ selected = segments.join("\n");
244
+ }
245
+ const compact = compactExcerpt(selected);
246
+ return compact || undefined;
247
+ }
248
+ function clamp(value, min, max) {
249
+ if (value < min) {
250
+ return min;
251
+ }
252
+ if (value > max) {
253
+ return max;
254
+ }
255
+ return value;
256
+ }
257
+ function compactExcerpt(value, max = 180) {
258
+ const compact = value.replace(/\s+/g, " ").trim();
259
+ if (compact.length <= max) {
260
+ return compact;
261
+ }
262
+ return `${compact.slice(0, max - 1)}…`;
263
+ }
264
+ function stripCommentsAppendix(lines, sentinelState, lineEnding) {
265
+ if (!sentinelState.hasSentinels) {
266
+ return lines.join(lineEnding);
267
+ }
268
+ let removeStart = sentinelState.startIndex;
269
+ if (removeStart > 0 && lines[removeStart - 1].trim().length === 0) {
270
+ removeStart -= 1;
271
+ }
272
+ const keptLines = [...lines.slice(0, removeStart), ...lines.slice(sentinelState.endIndex + 1)];
273
+ while (keptLines.length > 0 && keptLines[keptLines.length - 1].trim().length === 0) {
274
+ keptLines.pop();
275
+ }
276
+ return keptLines.join(lineEnding);
277
+ }
278
+ function splitFrontmatterForAnchors(markdownText) {
279
+ const frontmatterMatch = markdownText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
280
+ if (!frontmatterMatch) {
281
+ return {
282
+ body: markdownText,
283
+ lineOffset: 0
284
+ };
285
+ }
286
+ const consumed = frontmatterMatch[0];
287
+ return {
288
+ body: markdownText.slice(consumed.length),
289
+ lineOffset: countLineBreaks(consumed)
290
+ };
291
+ }
292
+ function countLineBreaks(value) {
293
+ const matches = value.match(/\r?\n/g);
294
+ return matches ? matches.length : 0;
295
+ }
296
+ function extractParagraphs(markdownText) {
297
+ const lines = markdownText.split(/\r?\n/);
298
+ const paragraphs = [];
299
+ let currentStart = -1;
300
+ const currentLines = [];
301
+ const flush = (endIndex) => {
302
+ if (currentStart < 0 || currentLines.length === 0) {
303
+ return;
304
+ }
305
+ const joined = currentLines.join(" ").replace(/\s+/g, " ").trim();
306
+ if (joined.length > 0) {
307
+ paragraphs.push({
308
+ index: paragraphs.length,
309
+ startLine: currentStart + 1,
310
+ endLine: endIndex + 1,
311
+ text: joined
312
+ });
313
+ }
314
+ currentStart = -1;
315
+ currentLines.length = 0;
316
+ };
317
+ for (let index = 0; index < lines.length; index += 1) {
318
+ const trimmed = lines[index].trim();
319
+ if (!trimmed) {
320
+ flush(index - 1);
321
+ continue;
322
+ }
323
+ if (currentStart < 0) {
324
+ currentStart = index;
325
+ }
326
+ currentLines.push(trimmed);
327
+ }
328
+ flush(lines.length - 1);
329
+ return paragraphs;
330
+ }
331
+ function findParagraphForLine(paragraphs, lineNumber) {
332
+ return paragraphs.find((paragraph) => lineNumber >= paragraph.startLine && lineNumber <= paragraph.endLine);
333
+ }
334
+ function findPreviousParagraphForLine(paragraphs, lineNumber) {
335
+ for (let index = paragraphs.length - 1; index >= 0; index -= 1) {
336
+ if (paragraphs[index].endLine < lineNumber) {
337
+ return paragraphs[index];
338
+ }
339
+ }
340
+ return undefined;
341
+ }
342
+ function formatHumanTimestamp(raw) {
343
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(raw)) {
344
+ return raw;
345
+ }
346
+ const date = new Date(raw);
347
+ if (Number.isNaN(date.getTime())) {
348
+ return raw;
349
+ }
350
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
351
+ const month = months[date.getUTCMonth()];
352
+ const day = date.getUTCDate();
353
+ const year = date.getUTCFullYear();
354
+ const hours24 = date.getUTCHours();
355
+ const hours = hours24 % 12 || 12;
356
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
357
+ const ampm = hours24 < 12 ? "AM" : "PM";
358
+ return `${month} ${day}, ${year}, ${hours}:${minutes} ${ampm}`;
359
+ }
360
+ function escapeThreadHeaderPart(value) {
361
+ return value
362
+ .replace(/\\/g, "\\\\")
363
+ .replace(/_/g, "\\_");
364
+ }
165
365
  function trimTrailingBlankLines(lines) {
166
366
  const copy = [...lines];
167
367
  while (copy.length > 0 && copy[copy.length - 1].trim().length === 0) {
package/dist/stego-cli.js CHANGED
@@ -1476,13 +1476,13 @@ function parseStegoCommentThreads(lines, relativePath, baseLine, issues) {
1476
1476
  }
1477
1477
  const headerQuote = extractQuotedLine(rawRow);
1478
1478
  if (headerQuote === undefined) {
1479
- issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp | author_'.`, relativePath, lineNumber));
1479
+ issues.push(makeIssue("error", "comments", `Invalid thread header '${trimmedRow}'. Expected blockquote header like '> _timestamp author_'.`, relativePath, lineNumber));
1480
1480
  rowIndex += 1;
1481
1481
  continue;
1482
1482
  }
1483
1483
  const header = parseThreadHeader(headerQuote);
1484
1484
  if (!header) {
1485
- issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp | author_'.`, relativePath, lineNumber));
1485
+ issues.push(makeIssue("error", "comments", `Invalid thread header '${headerQuote.trim()}'. Expected '> _timestamp author_'.`, relativePath, lineNumber));
1486
1486
  rowIndex += 1;
1487
1487
  continue;
1488
1488
  }
@@ -1580,7 +1580,20 @@ function decodeCommentMeta64(encoded, commentId, relativePath, lineNumber, issue
1580
1580
  issues.push(makeIssue("error", "comments", `Invalid meta64 object for comment ${commentId}.`, relativePath, lineNumber));
1581
1581
  return undefined;
1582
1582
  }
1583
- const allowedKeys = new Set(["status", "anchor", "paragraph_index", "signature", "excerpt"]);
1583
+ const allowedKeys = new Set([
1584
+ "status",
1585
+ "created_at",
1586
+ "timezone",
1587
+ "timezone_offset_minutes",
1588
+ "paragraph_index",
1589
+ "excerpt_start_line",
1590
+ "excerpt_start_col",
1591
+ "excerpt_end_line",
1592
+ "excerpt_end_col",
1593
+ "anchor",
1594
+ "excerpt",
1595
+ "signature"
1596
+ ]);
1584
1597
  for (const key of Object.keys(parsed)) {
1585
1598
  if (!allowedKeys.has(key)) {
1586
1599
  issues.push(makeIssue("error", "comments", `meta64 for comment ${commentId} contains unsupported key '${key}'.`, relativePath, lineNumber));
@@ -1602,7 +1615,7 @@ function extractQuotedLine(raw) {
1602
1615
  return quoteMatch[1];
1603
1616
  }
1604
1617
  function parseThreadHeader(value) {
1605
- const match = value.trim().match(/^_(.+?)\s*\|\s*(.+?)_\s*$/);
1618
+ const match = value.trim().match(/^_(.+?)\s*(?:—|\|)\s*(.+?)_\s*$/);
1606
1619
  if (!match) {
1607
1620
  return undefined;
1608
1621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stego-cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Installable CLI for the Stego writing monorepo workflow.",
6
6
  "license": "Apache-2.0",