@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.
- package/dist/actions/actionMapper.js +15 -1
- package/dist/actions/autogen/templates.d.ts +3 -0
- package/dist/actions/autogen/templates.js +193 -1
- package/dist/actions/autogen/types.d.ts +135 -26
- package/dist/actions/autogen/types.js +53 -1
- package/dist/actions/providers/google-oauth/addTextToTopOfDoc.js +5 -13
- package/dist/actions/providers/google-oauth/createNewGoogleDoc.js +10 -123
- package/dist/actions/providers/google-oauth/utils/googleDocsMarkdown.d.ts +88 -0
- package/dist/actions/providers/google-oauth/utils/googleDocsMarkdown.js +350 -0
- package/dist/actions/providers/jira/commentJiraTicketWithMentions.d.ts +3 -0
- package/dist/actions/providers/jira/commentJiraTicketWithMentions.js +39 -0
- package/dist/actions/providers/jira/convertMentionsInAdf.d.ts +21 -0
- package/dist/actions/providers/jira/convertMentionsInAdf.js +125 -0
- package/dist/actions/providers/jira/utils.d.ts +1 -1
- package/dist/actions/providers/jira/utils.js +1 -0
- package/dist/actions/providers/linear/createIssue.d.ts +3 -0
- package/dist/actions/providers/linear/createIssue.js +90 -0
- package/package.json +3 -1
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 (`&`, `<`, ` `, etc.) with their literal characters. */
|
|
17
|
+
function decodeHtmlEntities(text) {
|
|
18
|
+
return text
|
|
19
|
+
.replace(/ /g, " ")
|
|
20
|
+
.replace(/&/g, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, "'")
|
|
25
|
+
.replace(/—/g, "\u2014")
|
|
26
|
+
.replace(/–/g, "\u2013")
|
|
27
|
+
.replace(/…/g, "\u2026")
|
|
28
|
+
.replace(/©/g, "\u00A9")
|
|
29
|
+
.replace(/™/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,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;
|