@credal/actions 0.2.202 → 0.2.205

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.
@@ -1,97 +1,6 @@
1
1
  import { axiosClient } from "../../util/axiosClient.js";
2
2
  import { MISSING_AUTH_TOKEN } from "../../util/missingAuthConstants.js";
3
- /**
4
- * Parses HTML content and converts it to Google Docs API batch update requests
5
- */
6
- function parseHtmlToDocRequests(htmlContent) {
7
- const requests = [];
8
- let currentIndex = 1;
9
- // Strip HTML tags and extract text with basic formatting
10
- const textWithFormatting = parseHtmlContent(htmlContent);
11
- for (const item of textWithFormatting) {
12
- // Insert text
13
- requests.push({
14
- insertText: {
15
- location: { index: currentIndex },
16
- text: item.text,
17
- },
18
- });
19
- // Apply formatting if present
20
- if (item.formatting && Object.keys(item.formatting).length > 0) {
21
- const endIndex = currentIndex + item.text.length;
22
- requests.push({
23
- updateTextStyle: {
24
- range: { startIndex: currentIndex, endIndex },
25
- textStyle: item.formatting,
26
- fields: Object.keys(item.formatting).join(","),
27
- },
28
- });
29
- }
30
- currentIndex += item.text.length;
31
- }
32
- return requests;
33
- }
34
- /**
35
- * Basic HTML parser that extracts text and formatting
36
- */
37
- function parseHtmlContent(html) {
38
- const result = [];
39
- // Handle line breaks
40
- html = html.replace(/<br\s*\/?>/gi, "\n");
41
- html = html.replace(/<\/p>/gi, "\n");
42
- html = html.replace(/<p[^>]*>/gi, "");
43
- // Simple regex-based parsing for basic HTML tags
44
- const segments = html.split(/(<[^>]+>)/);
45
- const currentFormatting = {};
46
- for (let i = 0; i < segments.length; i++) {
47
- const segment = segments[i];
48
- if (segment.startsWith("<")) {
49
- // This is an HTML tag
50
- if (segment.match(/<\s*b\s*>/i) || segment.match(/<\s*strong\s*>/i)) {
51
- currentFormatting.bold = true;
52
- }
53
- else if (segment.match(/<\/\s*b\s*>/i) || segment.match(/<\/\s*strong\s*>/i)) {
54
- delete currentFormatting.bold;
55
- }
56
- else if (segment.match(/<\s*i\s*>/i) || segment.match(/<\s*em\s*>/i)) {
57
- currentFormatting.italic = true;
58
- }
59
- else if (segment.match(/<\/\s*i\s*>/i) || segment.match(/<\/\s*em\s*>/i)) {
60
- delete currentFormatting.italic;
61
- }
62
- else if (segment.match(/<\s*u\s*>/i)) {
63
- currentFormatting.underline = true;
64
- }
65
- else if (segment.match(/<\/\s*u\s*>/i)) {
66
- delete currentFormatting.underline;
67
- }
68
- else if (segment.match(/<\s*h[1-6]\s*>/i)) {
69
- const headingLevel = segment.match(/<\s*h([1-6])\s*>/i)?.[1];
70
- currentFormatting.fontSize = { magnitude: 18 - (parseInt(headingLevel || "1") - 1) * 2, unit: "PT" };
71
- currentFormatting.bold = true;
72
- }
73
- else if (segment.match(/<\/\s*h[1-6]\s*>/i)) {
74
- delete currentFormatting.fontSize;
75
- delete currentFormatting.bold;
76
- }
77
- }
78
- else if (segment.trim()) {
79
- // This is text content
80
- result.push({
81
- text: segment,
82
- formatting: Object.keys(currentFormatting).length > 0 ? { ...currentFormatting } : undefined,
83
- });
84
- }
85
- }
86
- // If no formatted content was found, return the plain text
87
- if (result.length === 0) {
88
- const plainText = html.replace(/<[^>]*>/g, "");
89
- if (plainText.trim()) {
90
- result.push({ text: plainText });
91
- }
92
- }
93
- return result;
94
- }
3
+ import { resolveContentFormat, contentToDocRequests } from "./utils/googleDocsMarkdown.js";
95
4
  /**
96
5
  * Creates a new Google Doc document using OAuth authentication
97
6
  */
@@ -99,8 +8,9 @@ const createNewGoogleDoc = async ({ params, authParams, }) => {
99
8
  if (!authParams.authToken) {
100
9
  throw new Error(MISSING_AUTH_TOKEN);
101
10
  }
102
- const { title, content, usesHtml } = params;
11
+ const { title, content, usesHtml, contentFormat } = params;
103
12
  const baseApiUrl = "https://docs.googleapis.com/v1/documents";
13
+ const resolvedContentFormat = resolveContentFormat({ contentFormat, usesHtml });
104
14
  // Create the document with the provided title
105
15
  const response = await axiosClient.post(baseApiUrl, { title }, {
106
16
  headers: {
@@ -111,36 +21,13 @@ const createNewGoogleDoc = async ({ params, authParams, }) => {
111
21
  // If content is provided, update the document body with the content
112
22
  if (content) {
113
23
  const documentId = response.data.documentId;
114
- if (usesHtml) {
115
- // Parse HTML content and create requests for rich text formatting
116
- const requests = parseHtmlToDocRequests(content);
117
- await axiosClient.post(`${baseApiUrl}/${documentId}:batchUpdate`, { requests }, {
118
- headers: {
119
- Authorization: `Bearer ${authParams.authToken}`,
120
- "Content-Type": "application/json",
121
- },
122
- });
123
- }
124
- else {
125
- // Add plain text content to the document
126
- await axiosClient.post(`${baseApiUrl}/${documentId}:batchUpdate`, {
127
- requests: [
128
- {
129
- insertText: {
130
- location: {
131
- index: 1, // Insert at the beginning of the document
132
- },
133
- text: content,
134
- },
135
- },
136
- ],
137
- }, {
138
- headers: {
139
- Authorization: `Bearer ${authParams.authToken}`,
140
- "Content-Type": "application/json",
141
- },
142
- });
143
- }
24
+ const requests = contentToDocRequests({ content, format: resolvedContentFormat });
25
+ await axiosClient.post(`${baseApiUrl}/${documentId}:batchUpdate`, { requests }, {
26
+ headers: {
27
+ Authorization: `Bearer ${authParams.authToken}`,
28
+ "Content-Type": "application/json",
29
+ },
30
+ });
144
31
  }
145
32
  return {
146
33
  documentId: response.data.documentId,
@@ -0,0 +1,88 @@
1
+ import type { docs_v1 } from "@googleapis/docs";
2
+ export type GoogleDocContentFormat = "plain" | "html" | "markdown";
3
+ /** Maps heading level (1–6) to Google Docs namedStyleType. */
4
+ declare const HEADING_STYLE_MAP: {
5
+ readonly 1: "HEADING_1";
6
+ readonly 2: "HEADING_2";
7
+ readonly 3: "HEADING_3";
8
+ readonly 4: "HEADING_4";
9
+ readonly 5: "HEADING_5";
10
+ readonly 6: "HEADING_6";
11
+ };
12
+ /** Google Docs bullet presets used by createParagraphBullets. */
13
+ declare const BULLET_PRESET: {
14
+ readonly UNORDERED: "BULLET_DISC_CIRCLE_SQUARE";
15
+ readonly ORDERED: "NUMBERED_DECIMAL_ALPHA_ROMAN";
16
+ };
17
+ type BulletPreset = (typeof BULLET_PRESET)[keyof typeof BULLET_PRESET];
18
+ type HeadingLevel = keyof typeof HEADING_STYLE_MAP;
19
+ /**
20
+ * Intermediate representation bridging HTML parsing and Google Docs API request generation.
21
+ *
22
+ * The Google Docs API doesn't accept HTML or Markdown directly — it requires explicit
23
+ * batch requests (insertText, updateTextStyle, updateParagraphStyle, createParagraphBullets).
24
+ * This IR lets us decouple the two concerns:
25
+ * 1. parseHtmlContent() walks HTML and produces an array of these segments
26
+ * 2. parseHtmlToDocRequests() transforms them into the specific API requests Google expects
27
+ */
28
+ interface TextWithFormatting {
29
+ text: string;
30
+ formatting?: docs_v1.Schema$TextStyle;
31
+ bulletPreset?: BulletPreset;
32
+ headingLevel?: HeadingLevel;
33
+ }
34
+ /** Wraps plain text in a single `insertText` request at document index 1 (start of body). */
35
+ export declare function plainTextToDocRequests(content: string): docs_v1.Schema$Request[];
36
+ /**
37
+ * Parses an HTML string into an array of `TextWithFormatting` segments.
38
+ *
39
+ * Strategy: split on HTML tags, walk segments sequentially, and maintain a mutable
40
+ * formatting state (`currentFormatting`, `listStack`, `currentHeadingLevel`).
41
+ * Opening tags push state (e.g. `<b>` sets `bold: true`), closing tags pop it.
42
+ * Non-tag segments are emitted with a snapshot of the current state.
43
+ *
44
+ * Supported elements: `<b>/<strong>`, `<i>/<em>`, `<u>`, `<code>`, `<a href>`,
45
+ * `<ul>/<ol>/<li>`, `<h1>`–`<h6>`, `<p>`, `<br>`.
46
+ *
47
+ * @example
48
+ * parseHtmlContent("<p><b>hello</b> world</p>")
49
+ * // → [{ text: "hello", formatting: { bold: true } }, { text: " world" }, { text: "\n" }]
50
+ */
51
+ export declare function parseHtmlContent(html: string): TextWithFormatting[];
52
+ /**
53
+ * Converts an HTML string into Google Docs API batch requests.
54
+ *
55
+ * Pipeline: HTML → `parseHtmlContent()` → TextWithFormatting[] → four request buckets:
56
+ * 1. `insertText` – insert each text segment sequentially starting at index 1
57
+ * 2. `updateTextStyle` – apply bold/italic/underline/link/code formatting
58
+ * 3. `updateParagraphStyle` – apply heading levels (HEADING_1–HEADING_6)
59
+ * 4. `createParagraphBullets`– apply bullet/numbered list presets
60
+ *
61
+ * Requests are ordered inserts-first so that character indices remain valid when
62
+ * style/bullet/heading requests reference them (styles are applied after all text is inserted).
63
+ */
64
+ export declare function parseHtmlToDocRequests(htmlContent: string): docs_v1.Schema$Request[];
65
+ /**
66
+ * Converts a Markdown string into Google Docs API batch requests.
67
+ *
68
+ * Two-step pipeline: Markdown → HTML (via `marked`) → Docs requests (via `parseHtmlToDocRequests`).
69
+ * Falls back to plain-text insertion if `marked` throws.
70
+ */
71
+ export declare function markdownToDocRequests(markdown: string): docs_v1.Schema$Request[];
72
+ /**
73
+ * Resolves which content format to use, with backwards-compatible `usesHtml` fallback.
74
+ * Priority: explicit `contentFormat` > legacy `usesHtml: true` → "html" > default "plain".
75
+ */
76
+ export declare function resolveContentFormat(args: {
77
+ contentFormat?: GoogleDocContentFormat;
78
+ usesHtml?: boolean;
79
+ }): GoogleDocContentFormat;
80
+ /**
81
+ * Dispatches content to the appropriate converter based on format.
82
+ * Single entry point so callers don't need to branch on format themselves.
83
+ */
84
+ export declare function contentToDocRequests(args: {
85
+ content: string;
86
+ format: GoogleDocContentFormat;
87
+ }): docs_v1.Schema$Request[];
88
+ export {};
@@ -0,0 +1,350 @@
1
+ import { marked } from "marked";
2
+ /** Maps heading level (1–6) to Google Docs namedStyleType. */
3
+ const HEADING_STYLE_MAP = {
4
+ 1: "HEADING_1",
5
+ 2: "HEADING_2",
6
+ 3: "HEADING_3",
7
+ 4: "HEADING_4",
8
+ 5: "HEADING_5",
9
+ 6: "HEADING_6",
10
+ };
11
+ /** Google Docs bullet presets used by createParagraphBullets. */
12
+ const BULLET_PRESET = {
13
+ UNORDERED: "BULLET_DISC_CIRCLE_SQUARE",
14
+ ORDERED: "NUMBERED_DECIMAL_ALPHA_ROMAN",
15
+ };
16
+ /** Replaces common HTML entities (`&amp;`, `&lt;`, `&nbsp;`, etc.) with their literal characters. */
17
+ function decodeHtmlEntities(text) {
18
+ return text
19
+ .replace(/&nbsp;/g, " ")
20
+ .replace(/&amp;/g, "&")
21
+ .replace(/&lt;/g, "<")
22
+ .replace(/&gt;/g, ">")
23
+ .replace(/&quot;/g, '"')
24
+ .replace(/&#39;/g, "'")
25
+ .replace(/&mdash;/g, "\u2014")
26
+ .replace(/&ndash;/g, "\u2013")
27
+ .replace(/&hellip;/g, "\u2026")
28
+ .replace(/&copy;/g, "\u00A9")
29
+ .replace(/&trade;/g, "\u2122")
30
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)));
31
+ }
32
+ /** Maps a heading level (1–6) to the corresponding Google Docs `namedStyleType` (e.g. 3 → "HEADING_3"). */
33
+ function getNamedStyleType(level) {
34
+ return HEADING_STYLE_MAP[level] ?? "HEADING_1";
35
+ }
36
+ /**
37
+ * Builds the `fields` mask required by `updateTextStyle` from the keys present in a TextStyle.
38
+ * E.g. `{ bold: true, link: { url: "..." } }` → `"bold,link"`.
39
+ */
40
+ function fieldMaskFromTextStyle(style) {
41
+ return Object.keys(style).join(",");
42
+ }
43
+ const ALLOWED_URL_SCHEMES = /^https?:\/\//i;
44
+ /** Returns true if the URL uses a safe scheme (http/https). Rejects javascript:, data:, etc. */
45
+ function isSafeUrl(url) {
46
+ return ALLOWED_URL_SCHEMES.test(url.trim());
47
+ }
48
+ /** Removes all HTML tags and decodes entities. Used as a last-resort fallback when parsing yields no segments. */
49
+ function stripHtmlTags(content) {
50
+ return decodeHtmlEntities(content.replace(/<[^>]*>/g, ""));
51
+ }
52
+ /** Wraps plain text in a single `insertText` request at document index 1 (start of body). */
53
+ export function plainTextToDocRequests(content) {
54
+ return [
55
+ {
56
+ insertText: {
57
+ location: { index: 1 },
58
+ text: content,
59
+ },
60
+ },
61
+ ];
62
+ }
63
+ /**
64
+ * Parses an HTML string into an array of `TextWithFormatting` segments.
65
+ *
66
+ * Strategy: split on HTML tags, walk segments sequentially, and maintain a mutable
67
+ * formatting state (`currentFormatting`, `listStack`, `currentHeadingLevel`).
68
+ * Opening tags push state (e.g. `<b>` sets `bold: true`), closing tags pop it.
69
+ * Non-tag segments are emitted with a snapshot of the current state.
70
+ *
71
+ * Supported elements: `<b>/<strong>`, `<i>/<em>`, `<u>`, `<code>`, `<a href>`,
72
+ * `<ul>/<ol>/<li>`, `<h1>`–`<h6>`, `<p>`, `<br>`.
73
+ *
74
+ * @example
75
+ * parseHtmlContent("<p><b>hello</b> world</p>")
76
+ * // → [{ text: "hello", formatting: { bold: true } }, { text: " world" }, { text: "\n" }]
77
+ */
78
+ export function parseHtmlContent(html) {
79
+ // Normalize <br> to newlines, then split into alternating [text, tag, text, tag, ...] segments.
80
+ // The capturing group in the regex keeps the tags in the result array.
81
+ const segments = html
82
+ .replace(/<br\s*\/?>/gi, "\n")
83
+ .split(/(<[^>]+>)/)
84
+ .filter(segment => segment.length > 0);
85
+ const result = [];
86
+ // Mutable state that tracks currently active formatting as we walk through segments.
87
+ // Opening tags add properties, closing tags delete them.
88
+ const currentFormatting = {};
89
+ // Stack to support nested lists — top of stack is the active bullet preset
90
+ const listStack = [];
91
+ let currentHeadingLevel;
92
+ let insidePre = false;
93
+ for (const segment of segments) {
94
+ // --- Text node: emit with a snapshot of the current formatting state ---
95
+ if (!segment.startsWith("<")) {
96
+ const decodedText = decodeHtmlEntities(segment);
97
+ if (!decodedText) {
98
+ continue;
99
+ }
100
+ // Skip newline-only text nodes — these are HTML formatting artifacts
101
+ // We preserve spaces (e.g. " " between inline elements like </b> and <a>).
102
+ if (/^\n+$/.test(decodedText)) {
103
+ continue;
104
+ }
105
+ result.push({
106
+ text: decodedText,
107
+ formatting: Object.keys(currentFormatting).length > 0 ? { ...currentFormatting } : undefined,
108
+ bulletPreset: listStack[listStack.length - 1],
109
+ headingLevel: currentHeadingLevel,
110
+ });
111
+ continue;
112
+ }
113
+ // --- Character-level formatting: toggle on open, remove on close ---
114
+ if (segment.match(/<\s*b\s*>/i) || segment.match(/<\s*strong\s*>/i)) {
115
+ currentFormatting.bold = true;
116
+ continue;
117
+ }
118
+ if (segment.match(/<\/\s*b\s*>/i) || segment.match(/<\/\s*strong\s*>/i)) {
119
+ delete currentFormatting.bold;
120
+ continue;
121
+ }
122
+ if (segment.match(/<\s*i\s*>/i) || segment.match(/<\s*em\s*>/i)) {
123
+ currentFormatting.italic = true;
124
+ continue;
125
+ }
126
+ if (segment.match(/<\/\s*i\s*>/i) || segment.match(/<\/\s*em\s*>/i)) {
127
+ delete currentFormatting.italic;
128
+ continue;
129
+ }
130
+ if (segment.match(/<\s*u\s*>/i)) {
131
+ currentFormatting.underline = true;
132
+ continue;
133
+ }
134
+ if (segment.match(/<\/\s*u\s*>/i)) {
135
+ delete currentFormatting.underline;
136
+ continue;
137
+ }
138
+ // Code: rendered as Courier New
139
+ if (segment.match(/<\s*code[^>]*>/i)) {
140
+ currentFormatting.weightedFontFamily = { fontFamily: "Courier New" };
141
+ continue;
142
+ }
143
+ if (segment.match(/<\/\s*code\s*>/i)) {
144
+ // Only clear monospace if we're not inside a <pre> block —
145
+ // <pre><code>...</code>text</pre> should keep text monospaced
146
+ if (!insidePre) {
147
+ delete currentFormatting.weightedFontFamily;
148
+ }
149
+ continue;
150
+ }
151
+ // Pre-formatted blocks: same monospace treatment as <code>, with trailing newline on close
152
+ if (segment.match(/<\s*pre[^>]*>/i)) {
153
+ insidePre = true;
154
+ currentFormatting.weightedFontFamily = { fontFamily: "Courier New" };
155
+ continue;
156
+ }
157
+ if (segment.match(/<\/\s*pre\s*>/i)) {
158
+ insidePre = false;
159
+ delete currentFormatting.weightedFontFamily;
160
+ result.push({ text: "\n" });
161
+ continue;
162
+ }
163
+ // --- Lists: stack-based to handle nesting (e.g. <ul> inside <ol>) ---
164
+ if (segment.match(/<\s*ul[^>]*>/i)) {
165
+ listStack.push(BULLET_PRESET.UNORDERED);
166
+ continue;
167
+ }
168
+ if (segment.match(/<\s*ol[^>]*>/i)) {
169
+ listStack.push(BULLET_PRESET.ORDERED);
170
+ continue;
171
+ }
172
+ if (segment.match(/<\/\s*(ul|ol)\s*>/i)) {
173
+ listStack.pop();
174
+ result.push({ text: "\n" });
175
+ continue;
176
+ }
177
+ // <li> open is a no-op; </li> emits a newline tagged with the current bullet preset
178
+ // so parseHtmlToDocRequests knows which lines to apply createParagraphBullets to
179
+ if (segment.match(/<\s*li[^>]*>/i)) {
180
+ continue;
181
+ }
182
+ if (segment.match(/<\/\s*li\s*>/i)) {
183
+ result.push({
184
+ text: "\n",
185
+ bulletPreset: listStack[listStack.length - 1],
186
+ });
187
+ continue;
188
+ }
189
+ // --- Block-level elements: emit newlines for paragraph breaks ---
190
+ if (segment.match(/<\s*p[^>]*>/i)) {
191
+ continue;
192
+ }
193
+ if (segment.match(/<\/\s*p\s*>/i)) {
194
+ result.push({ text: "\n" });
195
+ continue;
196
+ }
197
+ // --- Headings: set level on open, clear on close + emit newline ---
198
+ const headingStart = segment.match(/<\s*h([1-6])[^>]*>/i);
199
+ if (headingStart) {
200
+ currentHeadingLevel = Number.parseInt(headingStart[1], 10);
201
+ continue;
202
+ }
203
+ if (segment.match(/<\/\s*h[1-6]\s*>/i)) {
204
+ currentHeadingLevel = undefined;
205
+ result.push({ text: "\n" });
206
+ continue;
207
+ }
208
+ // --- Links: extract href on open, clear on close ---
209
+ // Two regexes so a double-quoted href can contain ' and vice versa
210
+ const linkStart = segment.match(/<\s*a[^>]*href="([^"]*)"[^>]*>/i) ?? segment.match(/<\s*a[^>]*href='([^']*)'[^>]*>/i);
211
+ if (linkStart) {
212
+ const url = decodeHtmlEntities(linkStart[1]);
213
+ if (isSafeUrl(url)) {
214
+ // allow safe links only (javascript:, data:, etc. are rejected)
215
+ currentFormatting.link = { url };
216
+ }
217
+ continue;
218
+ }
219
+ if (segment.match(/<\/\s*a\s*>/i)) {
220
+ delete currentFormatting.link;
221
+ continue;
222
+ }
223
+ }
224
+ if (result.length === 0) {
225
+ const text = stripHtmlTags(html);
226
+ if (text) {
227
+ result.push({ text });
228
+ }
229
+ }
230
+ return result;
231
+ }
232
+ /**
233
+ * Converts an HTML string into Google Docs API batch requests.
234
+ *
235
+ * Pipeline: HTML → `parseHtmlContent()` → TextWithFormatting[] → four request buckets:
236
+ * 1. `insertText` – insert each text segment sequentially starting at index 1
237
+ * 2. `updateTextStyle` – apply bold/italic/underline/link/code formatting
238
+ * 3. `updateParagraphStyle` – apply heading levels (HEADING_1–HEADING_6)
239
+ * 4. `createParagraphBullets`– apply bullet/numbered list presets
240
+ *
241
+ * Requests are ordered inserts-first so that character indices remain valid when
242
+ * style/bullet/heading requests reference them (styles are applied after all text is inserted).
243
+ */
244
+ export function parseHtmlToDocRequests(htmlContent) {
245
+ const textWithFormatting = parseHtmlContent(htmlContent);
246
+ // Requests are collected into separate buckets because the Google Docs API
247
+ // processes them sequentially — all text must be inserted before styles can
248
+ // reference the resulting character indices.
249
+ const insertRequests = [];
250
+ const styleRequests = [];
251
+ const bulletRequests = [];
252
+ const paragraphStyleRequests = [];
253
+ // Google Docs body starts at index 1 (index 0 is the document root)
254
+ let currentIndex = 1;
255
+ for (const item of textWithFormatting) {
256
+ if (!item.text) {
257
+ continue;
258
+ }
259
+ // Track the character range this segment will occupy once inserted
260
+ const startIndex = currentIndex;
261
+ const endIndex = currentIndex + item.text.length;
262
+ // Always insert the raw text first
263
+ insertRequests.push({
264
+ insertText: {
265
+ location: { index: startIndex },
266
+ text: item.text,
267
+ },
268
+ });
269
+ // Apply character-level styles (bold, italic, etc.) to the range we just inserted
270
+ if (item.formatting && Object.keys(item.formatting).length > 0) {
271
+ styleRequests.push({
272
+ updateTextStyle: {
273
+ range: { startIndex, endIndex },
274
+ textStyle: item.formatting,
275
+ fields: fieldMaskFromTextStyle(item.formatting),
276
+ },
277
+ });
278
+ }
279
+ // Apply bullet/numbered list preset — skip whitespace-only segments to avoid
280
+ // creating empty bullet points
281
+ if (item.bulletPreset && item.text.trim().length > 0) {
282
+ bulletRequests.push({
283
+ createParagraphBullets: {
284
+ range: { startIndex, endIndex },
285
+ bulletPreset: item.bulletPreset,
286
+ },
287
+ });
288
+ }
289
+ // Apply heading paragraph style (HEADING_1–HEADING_6)
290
+ if (item.headingLevel) {
291
+ paragraphStyleRequests.push({
292
+ updateParagraphStyle: {
293
+ range: { startIndex, endIndex },
294
+ paragraphStyle: {
295
+ namedStyleType: getNamedStyleType(item.headingLevel),
296
+ },
297
+ fields: "namedStyleType",
298
+ },
299
+ });
300
+ }
301
+ currentIndex = endIndex;
302
+ }
303
+ // Order matters: inserts → styles → paragraph styles → bullets.
304
+ // Inserts must come first so character indices exist when later requests reference them.
305
+ return [...insertRequests, ...styleRequests, ...paragraphStyleRequests, ...bulletRequests];
306
+ }
307
+ /**
308
+ * Converts a Markdown string into Google Docs API batch requests.
309
+ *
310
+ * Two-step pipeline: Markdown → HTML (via `marked`) → Docs requests (via `parseHtmlToDocRequests`).
311
+ * Falls back to plain-text insertion if `marked` throws.
312
+ */
313
+ export function markdownToDocRequests(markdown) {
314
+ let html;
315
+ try {
316
+ html = marked.parse(markdown, { async: false });
317
+ }
318
+ catch {
319
+ // if marked.parse() throws, fallback to plain text insertion
320
+ return plainTextToDocRequests(markdown);
321
+ }
322
+ return parseHtmlToDocRequests(html);
323
+ }
324
+ /**
325
+ * Resolves which content format to use, with backwards-compatible `usesHtml` fallback.
326
+ * Priority: explicit `contentFormat` > legacy `usesHtml: true` → "html" > default "plain".
327
+ */
328
+ export function resolveContentFormat(args) {
329
+ if (args.contentFormat) {
330
+ return args.contentFormat;
331
+ }
332
+ if (args.usesHtml) {
333
+ return "html";
334
+ }
335
+ return "plain";
336
+ }
337
+ /**
338
+ * Dispatches content to the appropriate converter based on format.
339
+ * Single entry point so callers don't need to branch on format themselves.
340
+ */
341
+ export function contentToDocRequests(args) {
342
+ const { content, format } = args;
343
+ if (format === "html") {
344
+ return parseHtmlToDocRequests(content);
345
+ }
346
+ else if (format === "markdown") {
347
+ return markdownToDocRequests(content);
348
+ }
349
+ return plainTextToDocRequests(content);
350
+ }
@@ -0,0 +1,3 @@
1
+ import type { jiraCommentJiraTicketWithMentionsFunction } from "../../autogen/types.js";
2
+ declare const commentJiraTicketWithMentions: jiraCommentJiraTicketWithMentionsFunction;
3
+ export default commentJiraTicketWithMentions;
@@ -0,0 +1,39 @@
1
+ import { axiosClient } from "../../util/axiosClient.js";
2
+ import { getJiraApiConfig, getErrorMessage } from "./utils.js";
3
+ import { extractMentions, insertMentionNodes } from "./convertMentionsInAdf.js";
4
+ import { markdownToAdf } from "marklassian";
5
+ const commentJiraTicketWithMentions = async ({ params, authParams, }) => {
6
+ const { authToken } = authParams;
7
+ const { issueId, comment } = params;
8
+ try {
9
+ const { apiUrl, browseUrl, isDataCenter } = getJiraApiConfig(authParams);
10
+ if (isDataCenter) {
11
+ return {
12
+ success: false,
13
+ error: "commentJiraTicketWithMentions is only supported on Jira Cloud. Use commentJiraTicket for Jira Data Center.",
14
+ };
15
+ }
16
+ const { sanitized, mentions } = extractMentions(comment);
17
+ const adf = markdownToAdf(sanitized);
18
+ const body = insertMentionNodes(adf, mentions);
19
+ const response = await axiosClient.post(`${apiUrl}/issue/${issueId}/comment`, { body }, {
20
+ headers: {
21
+ Authorization: `Bearer ${authToken}`,
22
+ Accept: "application/json",
23
+ "Content-Type": "application/json",
24
+ },
25
+ });
26
+ return {
27
+ success: true,
28
+ commentUrl: `${browseUrl}/browse/${issueId}?focusedCommentId=${response.data.id}`,
29
+ };
30
+ }
31
+ catch (error) {
32
+ console.error("Error commenting on Jira ticket with mentions: ", error);
33
+ return {
34
+ success: false,
35
+ error: getErrorMessage(error),
36
+ };
37
+ }
38
+ };
39
+ export default commentJiraTicketWithMentions;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Extracts [~accountid:XXXXX] patterns from raw text and replaces them with
3
+ * safe placeholders that won't be mangled by markdown parsers.
4
+ *
5
+ * Use with {@link insertMentionNodes} after markdown-to-ADF conversion.
6
+ */
7
+ export declare function extractMentions(text: string): {
8
+ sanitized: string;
9
+ mentions: string[];
10
+ };
11
+ /**
12
+ * Walks an ADF tree and replaces placeholder tokens (produced by
13
+ * {@link extractMentions}) with proper ADF mention nodes.
14
+ */
15
+ export declare function insertMentionNodes(adf: unknown, mentions: string[]): unknown;
16
+ /**
17
+ * Walks an ADF tree and converts raw [~accountid:XXXXX] text patterns into
18
+ * ADF mention nodes. Works reliably for single mentions per paragraph; for
19
+ * multiple mentions use extractMentions + insertMentionNodes instead.
20
+ */
21
+ export declare function convertMentionsInAdf(adf: unknown): unknown;