Skip to content

Commit 7763a6a

Browse files
committed
[FEATURE] Create styled google docs documents from markdown
1 parent 7898384 commit 7763a6a

File tree

5 files changed

+372
-9
lines changed

5 files changed

+372
-9
lines changed

components/google_docs/actions/create-document/create-document.mjs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export default {
44
key: "google_docs-create-document",
55
name: "Create a New Document",
66
description: "Create a new document. [See the documentation](https://developers.google.com/docs/api/reference/rest/v1/documents/create)",
7-
version: "0.1.8",
7+
version: "0.2.0",
88
annotations: {
99
destructiveHint: false,
1010
openWorldHint: true,
@@ -25,6 +25,12 @@ export default {
2525
],
2626
optional: true,
2727
},
28+
useMarkdown: {
29+
type: "boolean",
30+
label: "Use Markdown Format",
31+
description: "Enable markdown formatting support. When enabled, the text will be parsed as markdown and converted to Google Docs formatting (headings, bold, italic, lists, etc.)",
32+
optional: true,
33+
},
2834
folderId: {
2935
propDefinition: [
3036
googleDocs,
@@ -39,14 +45,20 @@ export default {
3945

4046
// Insert text
4147
if (this.text) {
42-
await this.googleDocs.insertText(documentId, {
43-
text: this.text,
44-
});
48+
if (this.useMarkdown) {
49+
// Use markdown formatting
50+
await this.googleDocs.insertMarkdownText(documentId, this.text);
51+
} else {
52+
// Use plain text
53+
await this.googleDocs.insertText(documentId, {
54+
text: this.text,
55+
});
56+
}
4557
}
4658

4759
// Move file
4860
if (this.folderId) {
49-
// Get file to get parents to remove
61+
// Get file to get parents to remove
5062
const file = await this.googleDocs.getFile(documentId);
5163

5264
// Move file, removing old parents, adding new parent folder
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
/**
2+
* Markdown to Google Docs converter using markdown-it
3+
* Converts markdown text to Google Docs API batch update requests
4+
*/
5+
6+
import MarkdownIt from "markdown-it";
7+
8+
/**
9+
* Create a custom markdown-it instance configured for Google Docs conversion
10+
* @returns {MarkdownIt} Configured markdown-it instance
11+
*/
12+
function createMarkdownParser() {
13+
return new MarkdownIt({
14+
html: false,
15+
breaks: true,
16+
linkify: false,
17+
});
18+
}
19+
20+
/**
21+
* Parse markdown and convert to Google Docs API requests
22+
* @param {string} markdown - The markdown text to parse
23+
* @returns {Object} Object with text and formatting requests
24+
*/
25+
function parseMarkdown(markdown) {
26+
const md = createMarkdownParser();
27+
const tokens = md.parse(markdown, {});
28+
29+
const textContent = [];
30+
const formattingRequests = [];
31+
let currentIndex = 1; // Start after document header
32+
33+
// Store state for heading and list detection
34+
let nextIsHeading = false;
35+
let headingLevel = 0;
36+
let inBulletList = false;
37+
let inOrderedList = false;
38+
let listItemStartIndex = -1;
39+
40+
tokens.forEach((token) => {
41+
if (token.type === "heading_open") {
42+
nextIsHeading = true;
43+
headingLevel = parseInt(token.tag[1], 10);
44+
} else if (token.type === "inline" && nextIsHeading) {
45+
const textStartIndex = currentIndex;
46+
const text = token.content;
47+
48+
textContent.push(text);
49+
currentIndex += text.length;
50+
51+
// Apply heading style
52+
formattingRequests.push({
53+
type: "updateParagraphStyle",
54+
textRange: {
55+
startIndex: textStartIndex,
56+
endIndex: currentIndex,
57+
},
58+
style: `HEADING_${headingLevel}`,
59+
});
60+
61+
nextIsHeading = false;
62+
} else if (token.type === "heading_close") {
63+
// Add newline after heading
64+
textContent.push("\n");
65+
currentIndex += 1;
66+
} else if (token.type === "bullet_list_open") {
67+
inBulletList = true;
68+
} else if (token.type === "ordered_list_open") {
69+
inOrderedList = true;
70+
} else if (token.type === "list_item_open") {
71+
listItemStartIndex = currentIndex;
72+
} else if (token.type === "inline" && (inBulletList || inOrderedList)) {
73+
const text = token.content;
74+
75+
textContent.push(text);
76+
currentIndex += text.length;
77+
78+
// Apply bullet formatting
79+
const bulletPreset = inOrderedList
80+
? "NUMBER_ASCENDING"
81+
: "BULLET_DISC_CIRCLE_SQUARE";
82+
formattingRequests.push({
83+
type: "createParagraphBullets",
84+
textRange: {
85+
startIndex: listItemStartIndex,
86+
endIndex: currentIndex,
87+
},
88+
bulletPreset,
89+
});
90+
} else if (token.type === "inline") {
91+
// Regular paragraph text with potential formatting
92+
const result = processInlineToken(token, textContent, formattingRequests, currentIndex);
93+
currentIndex = result;
94+
} else if (token.type === "paragraph_close") {
95+
// Add newline after paragraph (but not after list items)
96+
if (!inBulletList && !inOrderedList) {
97+
textContent.push("\n");
98+
currentIndex += 1;
99+
}
100+
} else if (token.type === "list_item_close") {
101+
// Add newline after list item
102+
textContent.push("\n");
103+
currentIndex += 1;
104+
} else if (token.type === "bullet_list_close") {
105+
inBulletList = false;
106+
// Add newline after list
107+
textContent.push("\n");
108+
currentIndex += 1;
109+
} else if (token.type === "ordered_list_close") {
110+
inOrderedList = false;
111+
// Add newline after list
112+
textContent.push("\n");
113+
currentIndex += 1;
114+
}
115+
});
116+
117+
return {
118+
text: textContent.join(""),
119+
formattingRequests,
120+
totalLength: currentIndex,
121+
};
122+
}
123+
124+
/**
125+
* Process inline token content with formatting
126+
* @returns {number} Updated currentIndex
127+
*/
128+
function processInlineToken(token, textContent, formattingRequests, startIndex) {
129+
if (!token.children) {
130+
return startIndex;
131+
}
132+
133+
let currentIndex = startIndex;
134+
let isBold = false;
135+
let isItalic = false;
136+
let isCode = false;
137+
138+
token.children.forEach((child) => {
139+
if (child.type === "text") {
140+
const textStartIndex = currentIndex;
141+
const text = child.content;
142+
textContent.push(text);
143+
currentIndex += text.length;
144+
145+
// Apply formatting if needed
146+
if (isBold || isItalic || isCode) {
147+
const formatting = {
148+
bold: isBold,
149+
italic: isItalic,
150+
underline: false,
151+
};
152+
153+
if (isCode) {
154+
formatting.weightedFontFamily = {
155+
fontFamily: "Courier New",
156+
weight: 400,
157+
};
158+
formatting.backgroundColor = {
159+
color: {
160+
rgbColor: {
161+
red: 0.95,
162+
green: 0.95,
163+
blue: 0.95,
164+
},
165+
},
166+
};
167+
}
168+
169+
formattingRequests.push({
170+
type: "updateTextStyle",
171+
textRange: {
172+
startIndex: textStartIndex,
173+
endIndex: currentIndex,
174+
},
175+
formatting,
176+
});
177+
}
178+
} else if (child.type === "strong_open") {
179+
isBold = true;
180+
} else if (child.type === "strong_close") {
181+
isBold = false;
182+
} else if (child.type === "em_open") {
183+
isItalic = true;
184+
} else if (child.type === "em_close") {
185+
isItalic = false;
186+
} else if (child.type === "code_inline") {
187+
const textStartIndex = currentIndex;
188+
const text = child.content;
189+
textContent.push(text);
190+
currentIndex += text.length;
191+
192+
formattingRequests.push({
193+
type: "updateTextStyle",
194+
textRange: {
195+
startIndex: textStartIndex,
196+
endIndex: currentIndex,
197+
},
198+
formatting: {
199+
bold: false,
200+
italic: false,
201+
underline: false,
202+
weightedFontFamily: {
203+
fontFamily: "Courier New",
204+
weight: 400,
205+
},
206+
backgroundColor: {
207+
color: {
208+
rgbColor: {
209+
red: 0.95,
210+
green: 0.95,
211+
blue: 0.95,
212+
},
213+
},
214+
},
215+
},
216+
});
217+
} else if (child.type === "softbreak" || child.type === "hardbreak") {
218+
textContent.push("\n");
219+
currentIndex += 1;
220+
}
221+
});
222+
223+
return currentIndex;
224+
}
225+
226+
/**
227+
* Convert parsed markdown structure to Google Docs batchUpdate requests
228+
* @param {Object} parseResult - Result from parseMarkdown()
229+
* @returns {Array} Array of Google Docs API requests
230+
*/
231+
function convertToGoogleDocsRequests(parseResult) {
232+
const {
233+
text,
234+
formattingRequests,
235+
} = parseResult;
236+
const batchRequests = [];
237+
238+
// First, insert all the text
239+
if (text) {
240+
batchRequests.push({
241+
insertText: {
242+
text,
243+
location: {
244+
index: 1,
245+
},
246+
},
247+
});
248+
}
249+
250+
// Then apply all formatting requests
251+
formattingRequests.forEach((req) => {
252+
batchRequests.push(buildFormattingRequest(req));
253+
});
254+
255+
return batchRequests;
256+
}
257+
258+
/**
259+
* Build formatting requests for Google Docs API
260+
* @param {Object} req - Formatting request from parseMarkdown
261+
* @returns {Object} Google Docs API request
262+
*/
263+
function buildFormattingRequest(req) {
264+
switch (req.type) {
265+
case "updateParagraphStyle":
266+
return {
267+
updateParagraphStyle: {
268+
range: {
269+
startIndex: req.textRange.startIndex,
270+
endIndex: req.textRange.endIndex,
271+
},
272+
paragraphStyle: {
273+
namedStyleType: req.style,
274+
},
275+
fields: "namedStyleType",
276+
},
277+
};
278+
279+
case "updateTextStyle": {
280+
const textStyle = {
281+
bold: req.formatting.bold,
282+
italic: req.formatting.italic,
283+
underline: req.formatting.underline,
284+
};
285+
const fields = [
286+
"bold",
287+
"italic",
288+
"underline",
289+
];
290+
291+
if (req.formatting.weightedFontFamily) {
292+
textStyle.weightedFontFamily = req.formatting.weightedFontFamily;
293+
fields.push("weightedFontFamily");
294+
}
295+
296+
if (req.formatting.backgroundColor) {
297+
textStyle.backgroundColor = req.formatting.backgroundColor;
298+
fields.push("backgroundColor");
299+
}
300+
301+
return {
302+
updateTextStyle: {
303+
range: {
304+
startIndex: req.textRange.startIndex,
305+
endIndex: req.textRange.endIndex,
306+
},
307+
textStyle,
308+
fields: fields.join(","),
309+
},
310+
};
311+
}
312+
313+
case "createParagraphBullets":
314+
return {
315+
createParagraphBullets: {
316+
range: {
317+
startIndex: req.textRange.startIndex,
318+
endIndex: req.textRange.endIndex,
319+
},
320+
bulletPreset: req.bulletPreset || "BULLET_DISC_CIRCLE_SQUARE",
321+
},
322+
};
323+
324+
default:
325+
return null;
326+
}
327+
}
328+
329+
export default {
330+
parseMarkdown,
331+
convertToGoogleDocsRequests,
332+
};

0 commit comments

Comments
 (0)