@mcpher/gas-fakes 2.5.4 → 2.5.6

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.
Files changed (30) hide show
  1. package/.claspignore +1 -1
  2. package/README.md +27 -1
  3. package/exgcp.sh +63 -0
  4. package/package.json +1 -1
  5. package/src/services/advslides/fakeadvslides.js +20 -1
  6. package/src/services/documentapp/appenderhelpers.js +8 -3
  7. package/src/services/documentapp/elementhelpers.js +148 -7
  8. package/src/services/documentapp/elementoptions.js +40 -0
  9. package/src/services/documentapp/elements.js +7 -0
  10. package/src/services/documentapp/fakebookmark.js +1 -3
  11. package/src/services/documentapp/fakecontainerelement.js +188 -0
  12. package/src/services/documentapp/fakedate.js +92 -0
  13. package/src/services/documentapp/fakedocument.js +51 -0
  14. package/src/services/documentapp/fakedocumenttab.js +9 -25
  15. package/src/services/documentapp/fakeelement.js +453 -90
  16. package/src/services/documentapp/fakeequation.js +28 -0
  17. package/src/services/documentapp/fakeequationfunction.js +37 -0
  18. package/src/services/documentapp/fakeequationfunctionargumentseparator.js +28 -0
  19. package/src/services/documentapp/fakeequationsymbol.js +37 -0
  20. package/src/services/documentapp/fakefootersection.js +64 -1
  21. package/src/services/documentapp/fakeheadersection.js +64 -0
  22. package/src/services/documentapp/fakeperson.js +67 -0
  23. package/src/services/documentapp/fakerangeelement.js +27 -1
  24. package/src/services/documentapp/fakerichlink.js +79 -0
  25. package/src/services/documentapp/fakesectionelement.js +51 -12
  26. package/src/services/documentapp/faketablecell.js +98 -0
  27. package/src/services/documentapp/nrhelpers.js +2 -1
  28. package/src/services/documentapp/shadowdocument.js +19 -2
  29. package/src/services/spreadsheetapp/fakeembeddedchartbuilder.js +2 -7
  30. package/src/support/sxslides.js +5 -4
package/.claspignore CHANGED
@@ -3,4 +3,4 @@ run.js
3
3
  node_modules/
4
4
  package.json
5
5
  package-lock.json
6
- .gemini/gasmess/
6
+
package/README.md CHANGED
@@ -157,7 +157,33 @@ For pushing modified files back to the Apps Script IDE, use the built-in `gas-fa
157
157
 
158
158
  As I mentioned earlier, to take this further, I'm going to need a lot of help to extend the methods and services supported - so if you feel this would be useful to you, and would like to collaborate, please ping me on bruce@mcpher.com and we'll talk.
159
159
 
160
- ## <img src="../pngs/logo.png" alt="gas-fakes logo" width="50" align="top"> Further Reading
160
+ # Gas-Fakes Progress Summary
161
+
162
+ | Service | Classes | Methods | Completed | In Progress | Not Started |
163
+ |---|---|---|---|---|---|
164
+ | [Base](./progress/base.md) | 17 | 127 | 93 | 2 | 32 |
165
+ | [Cache](./progress/cache.md) | 2 | 11 | 7 | 4 | 0 |
166
+ | [Calendar](./progress/calendar.md) | 13 | 273 | 273 | 0 | 0 |
167
+ | [Charts](./progress/charts.md) | 29 | 238 | 37 | 0 | 201 |
168
+ | [Content](./progress/content.md) | 3 | 16 | 16 | 0 | 0 |
169
+ | [Document](./progress/document.md) | 47 | 1032 | 880 | 12 | 140 |
170
+ | [Drive](./progress/drive.md) | 8 | 164 | 124 | 8 | 32 |
171
+ | [Forms](./progress/forms.md) | 41 | 504 | 266 | 0 | 238 |
172
+ | [Gmail](./progress/gmail.md) | 6 | 168 | 167 | 0 | 1 |
173
+ | [HTML](./progress/html.md) | 6 | 39 | 34 | 0 | 5 |
174
+ | [JDBC](./progress/jdbc.md) | 20 | 753 | 311 | 0 | 442 |
175
+ | [Lock](./progress/lock.md) | 2 | 7 | 7 | 0 | 0 |
176
+ | [Mail](./progress/mail.md) | 1 | 5 | 0 | 0 | 5 |
177
+ | [Properties](./progress/properties.md) | 4 | 11 | 6 | 5 | 0 |
178
+ | [Script](./progress/script.md) | 16 | 84 | 22 | 0 | 62 |
179
+ | [Slides](./progress/slides.md) | 76 | 1288 | 1005 | 0 | 283 |
180
+ | [Spreadsheet](./progress/spreadsheet.md) | 108 | 1771 | 1301 | 19 | 451 |
181
+ | [URL Fetch](./progress/urlfetch.md) | 2 | 13 | 12 | 0 | 1 |
182
+ | [Utilities](./progress/utilities.md) | 5 | 59 | 59 | 0 | 0 |
183
+ | [XML](./progress/xml.md) | 14 | 149 | 142 | 0 | 7 |
184
+ | **Total** | **420** | **6712** | **4762** | **50** | **1900** |
185
+
186
+ ## <img src="./pngs/logo.png" alt="gas-fakes logo" width="50" align="top"> Further Reading
161
187
 
162
188
 
163
189
 
package/exgcp.sh ADDED
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+
3
+ # This script reads the GCP_PROJECT_ID from a .env file
4
+ # and exports it as GOOGLE_CLOUD_PROJECT for the current shell session.
5
+ #
6
+ # Usage: source . ./exgcp.sh
7
+
8
+ # Define the path to your .env file relative to the script's location
9
+ ENV_FILE="./.env"
10
+
11
+ # Check if the .env file exists
12
+
13
+ if [ ! -f "$ENV_FILE" ]; then
14
+ echo "Error: .env file not found at path: $ENV_FILE"
15
+ # Use 'return' instead of 'exit' so it doesn't close the user's terminal when sourced
16
+ return 1
17
+ fi
18
+
19
+ # Read the GCP_PROJECT_ID, remove quotes, and handle potential carriage returns
20
+ GOOGLE_CLOUD_PROJECT_VALUE=$(grep -E '^GOOGLE_CLOUD_PROJECT=' "$ENV_FILE" | cut -d '=' -f2 | tr -d '"\r')
21
+ GEMINI_API_KEY_VALUE=$(grep -E '^GEMINI_API_KEY=' "$ENV_FILE" | cut -d '=' -f2 | tr -d '"\r')
22
+ ANTIGRAVITY_API_KEY_VALUE=$(grep -E '^ANTIGRAVITY_API_KEY=' "$ENV_FILE" | cut -d '=' -f2 | tr -d '"\r')
23
+ GEMINI_MODEL_VALUE=$(grep -E '^GEMINI_MODEL=' "$ENV_FILE" | cut -d '=' -f2 | tr -d '"\r')
24
+ OMDB_API_KEY_VALUE=$(grep -E '^OMDB_API_KEY=' "$ENV_FILE" | cut -d '=' -f2 | tr -d '"\r')
25
+ # Check if a value was extracted
26
+ if [ -z "$GOOGLE_CLOUD_PROJECT_VALUE" ]; then
27
+ echo "Error: GOOGLE_CLOUD_PROJECT not found or is empty in $ENV_FILE."
28
+ return 1
29
+ fi
30
+
31
+ if [ -z "GEMINI_API_KEY_VALUE" ]; then
32
+ echo "GEMINI_API_KEY not found or is empty in $ENV_FILE."
33
+ else
34
+ echo "exported: GEMINI_API_KEY"
35
+ export GEMINI_API_KEY="$GEMINI_API_KEY_VALUE"
36
+ fi
37
+
38
+ if [ -z "ANTIGRAVITY_API_KEY_VALUE" ]; then
39
+ echo "ANTIGRAVITY_API_KEY not found or is empty in $ENV_FILE."
40
+ else
41
+ echo "exported: ANTIGRAVITY_API_KEY"
42
+ export ANTIGRAVITY_API_KEY="$ANTIGRAVITY_API_KEY_VALUE"
43
+ fi
44
+
45
+ if [ -z "OMDB_API_KEY_VALUE" ]; then
46
+ echo "OMDB_API_KEY not found or is empty in $ENV_FILE."
47
+ else
48
+ echo "exported: OMDB_API_KEY"
49
+ export OMDB_API_KEY="$OMDB_API_KEY_VALUE"
50
+ fi
51
+
52
+
53
+ if [ -z "GEMINI_MODEL_VALUE" ]; then
54
+ echo "GEMINI_MODEL not found or is empty in $ENV_FILE."
55
+ else
56
+ echo "exported: GEMINI_MODEL=$GEMINI_MODEL_VALUE"
57
+ export GEMINI_MODEL="$GEMINI_MODEL_VALUE"
58
+ fi
59
+
60
+ # Export the variable for the current session
61
+ export GOOGLE_CLOUD_PROJECT="$GOOGLE_CLOUD_PROJECT_VALUE"
62
+
63
+ echo "exported: GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT"
package/package.json CHANGED
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "name": "@mcpher/gas-fakes",
41
41
  "author": "bruce mcpherson",
42
- "version": "2.5.4",
42
+ "version": "2.5.6",
43
43
  "license": "MIT",
44
44
  "main": "main.js",
45
45
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
@@ -4,13 +4,32 @@ import { gError } from '../../support/helpers.js';
4
4
  import { slidesCacher } from '../../support/slidescacher.js';
5
5
  import { Proxies } from '../../support/proxies.js';
6
6
 
7
+ /**
8
+ * @class FakeAdvSlidesPresentationsPages
9
+ */
10
+ class FakeAdvSlidesPresentationsPages extends FakeAdvResource {
11
+ constructor(mainService) {
12
+ super(mainService, 'presentations', Syncit.fxSlides);
13
+ }
14
+
15
+
16
+ //https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations.pages/getThumbnail
17
+ getThumbnail(presentationId, pageObjectId, options) {
18
+ ScriptApp.__behavior.isAccessible(presentationId, 'Slides', 'read');
19
+ const { response, data } = this._call('getThumbnail', { presentationId, pageObjectId, ...options }, {}, 'pages');
20
+ gError(response, 'slides.presentations.pages', 'getThumbnail');
21
+ return data;
22
+ }
23
+ }
24
+
7
25
  /**
8
26
  * @class FakeAdvSlidesPresentations
9
27
  */
10
28
  class FakeAdvSlidesPresentations extends FakeAdvResource {
11
29
  constructor(mainService) {
12
30
  super(mainService, 'presentations', Syncit.fxSlides);
13
- this.slides = mainService
31
+ this.slides = mainService;
32
+ this.Pages = Proxies.guard(new FakeAdvSlidesPresentationsPages(mainService));
14
33
  }
15
34
 
16
35
  // Override 'get' to use the caching-enabled function fxSlidesGet.
@@ -4,7 +4,7 @@ const { is, isBlob , stringCircular, lobify} = Utils
4
4
  import { getElementFactory } from './elementRegistry.js'
5
5
  import { signatureArgs, notYetImplemented } from '../../support/helpers.js';
6
6
  import { findItem } from './elementhelpers.js';
7
- import { paragraphOptions, pageBreakOptions, tableOptions, textOptions, listItemOptions, imageOptions, positionedImageOptions } from './elementoptions.js';
7
+ import { horizontalRuleOptions, paragraphOptions, pageBreakOptions, tableOptions, textOptions, listItemOptions, imageOptions, positionedImageOptions } from './elementoptions.js';
8
8
  import { deleteContentRange, createParagraphBullets, reverseUpdateContent, deleteParagraphBullets } from "./elementblasters.js";
9
9
 
10
10
  /**
@@ -430,10 +430,15 @@ export const createFootnote = (parent, text) => {
430
430
  };
431
431
 
432
432
 
433
- // THE API has no way of inserting a horizontal rule
434
- // parking this for now - it'll need to be resurrected if this issue ever gets resolved
433
+ // THE API has no way of inserting a horizontal rule natively.
435
434
  // https://issuetracker.google.com/issues/437825936
435
+ export const appendHorizontalRule = (self, horizontalRule) => {
436
+ return notYetImplemented('appendHorizontalRule');
437
+ };
436
438
 
439
+ export const insertHorizontalRule = (self, childIndex, horizontalRule) => {
440
+ return notYetImplemented('insertHorizontalRule');
441
+ };
437
442
 
438
443
  export const appendText = (self, textOrTextElement) => {
439
444
  return elementInserter(self, textOrTextElement, null, textOptions);
@@ -32,6 +32,9 @@ export const getElementProp = (se) => {
32
32
  if (se.horizontalRule) return { prop: null, type: 'HORIZONTAL_RULE' };
33
33
  if (se.footnoteReference) return { prop: null, type: 'FOOTNOTE_REFERENCE' };
34
34
  if (se.inlineObjectElement) return { prop: null, type: 'INLINE_IMAGE' };
35
+ if (se.person) return { prop: null, type: 'PERSON' };
36
+ if (se.richLink) return { prop: null, type: 'RICH_LINK' };
37
+ if (se.date) return { prop: null, type: 'DATE' };
35
38
  if (se.positionedObjectElement) return { prop: null, type: 'POSITIONED_IMAGE' };
36
39
 
37
40
  if (se.body) {
@@ -53,18 +56,24 @@ const getTextRecursive = (twig, structure) => {
53
56
  const item = structure.elementMap.get(twig.name);
54
57
  if (!item) return '';
55
58
 
56
- // Base case: Text element. In the API, text is inside a paragraph's elements array.
57
- if (item.paragraph) {
58
- return item.paragraph.elements.map(e => e.textRun?.content || '').join('');
59
+ // Base case: Text element.
60
+ if (item.textRun) {
61
+ return item.textRun.content || '';
59
62
  }
60
63
 
61
64
  // Recursive case: Container element.
62
- if (item.__twig && item.__twig.children) {
65
+ // Note: For Paragraph, item.paragraph.elements will be processed as children via __twig.children
66
+ if (item.__twig && item.__twig.children && item.__twig.children.length > 0) {
63
67
  return item.__twig.children
64
68
  .map(childTwig => getTextRecursive(childTwig, structure))
65
69
  .join('');
66
70
  }
67
71
 
72
+ // Fallback for elements that might not have children in twig yet (e.g. Paragraph during initial map)
73
+ if (item.paragraph) {
74
+ return item.paragraph.elements.map(e => e.textRun?.content || '').join('');
75
+ }
76
+
68
77
  return '';
69
78
  };
70
79
 
@@ -74,11 +83,16 @@ const getTextRecursive = (twig, structure) => {
74
83
  * @returns {string} The text content.
75
84
  */
76
85
  export const getText = (element) => {
77
- if (!element || element.__isDetached) {
78
- const item = element.__elementMapItem;
86
+ if (!element) return '';
87
+ const item = element.__elementMapItem;
88
+ if (!item) return '';
89
+
90
+ if (element.__isDetached) {
79
91
  let text = '';
80
92
 
81
- if (item.paragraph) { // It's a Paragraph
93
+ if (item.textRun) {
94
+ text = item.textRun.content || '';
95
+ } else if (item.paragraph) { // It's a Paragraph
82
96
  text = item.paragraph.elements?.map(e => e.textRun?.content || '').join('') || '';
83
97
  } else if (item.content) { // It's a TableCell
84
98
  text = item.content.map(structuralElement =>
@@ -197,6 +211,133 @@ export const getAttributes = (element) => {
197
211
  return attributes;
198
212
  };
199
213
 
214
+ /**
215
+ * Converts a DocumentApp.Attribute map to Docs API style objects.
216
+ * @param {object} attributes The attributes to convert.
217
+ * @returns {{paragraphStyle: object, textStyle: object, paraFields: string, textFields: string}}
218
+ */
219
+ export const attributesToStyle = (attributes) => {
220
+ const paragraphStyle = {};
221
+ const textStyle = {};
222
+ const paraFields = [];
223
+ const textFields = [];
224
+
225
+ const Attribute = DocumentApp.Attribute;
226
+
227
+ const hexToRgb = (hex) => {
228
+ if (!hex || !is.string(hex) || !hex.startsWith('#')) return null;
229
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
230
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
231
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
232
+ return { color: { rgbColor: { red: r, green: g, blue: b } } };
233
+ };
234
+
235
+ const alignmentMap = {
236
+ [DocumentApp.HorizontalAlignment.LEFT]: 'START',
237
+ [DocumentApp.HorizontalAlignment.CENTER]: 'CENTER',
238
+ [DocumentApp.HorizontalAlignment.RIGHT]: 'END',
239
+ [DocumentApp.HorizontalAlignment.JUSTIFIED]: 'JUSTIFY',
240
+ };
241
+
242
+ for (const [key, value] of Object.entries(attributes)) {
243
+ // Note: keys are likely strings like "BOLD", "HORIZONTAL_ALIGNMENT"
244
+ switch (key) {
245
+ case 'HORIZONTAL_ALIGNMENT':
246
+ case Attribute.HORIZONTAL_ALIGNMENT.toString():
247
+ paragraphStyle.alignment = alignmentMap[value] || value;
248
+ paraFields.push('alignment');
249
+ break;
250
+ case 'LEFT_TO_RIGHT':
251
+ case Attribute.LEFT_TO_RIGHT.toString():
252
+ paragraphStyle.direction = value ? 'LEFT_TO_RIGHT' : 'RIGHT_TO_LEFT';
253
+ paraFields.push('direction');
254
+ break;
255
+ case 'LINE_SPACING':
256
+ case Attribute.LINE_SPACING.toString():
257
+ paragraphStyle.lineSpacing = Math.round(value * 100);
258
+ paraFields.push('lineSpacing');
259
+ break;
260
+ case 'INDENT_START':
261
+ case Attribute.INDENT_START.toString():
262
+ paragraphStyle.indentStart = { magnitude: value, unit: 'PT' };
263
+ paraFields.push('indentStart');
264
+ break;
265
+ case 'INDENT_END':
266
+ case Attribute.INDENT_END.toString():
267
+ paragraphStyle.indentEnd = { magnitude: value, unit: 'PT' };
268
+ paraFields.push('indentEnd');
269
+ break;
270
+ case 'INDENT_FIRST_LINE':
271
+ case Attribute.INDENT_FIRST_LINE.toString():
272
+ paragraphStyle.indentFirstLine = { magnitude: value, unit: 'PT' };
273
+ paraFields.push('indentFirstLine');
274
+ break;
275
+ case 'SPACING_BEFORE':
276
+ case Attribute.SPACING_BEFORE.toString():
277
+ paragraphStyle.spaceAbove = { magnitude: value, unit: 'PT' };
278
+ paraFields.push('spaceAbove');
279
+ break;
280
+ case 'SPACING_AFTER':
281
+ case Attribute.SPACING_AFTER.toString():
282
+ paragraphStyle.spaceBelow = { magnitude: value, unit: 'PT' };
283
+ paraFields.push('spaceBelow');
284
+ break;
285
+ case 'BACKGROUND_COLOR':
286
+ case Attribute.BACKGROUND_COLOR.toString():
287
+ textStyle.backgroundColor = hexToRgb(value);
288
+ textFields.push('backgroundColor');
289
+ break;
290
+ case 'BOLD':
291
+ case Attribute.BOLD.toString():
292
+ textStyle.bold = value;
293
+ textFields.push('bold');
294
+ break;
295
+ case 'FONT_FAMILY':
296
+ case Attribute.FONT_FAMILY.toString():
297
+ textStyle.weightedFontFamily = { fontFamily: value };
298
+ textFields.push('weightedFontFamily');
299
+ break;
300
+ case 'FONT_SIZE':
301
+ case Attribute.FONT_SIZE.toString():
302
+ textStyle.fontSize = { magnitude: value, unit: 'PT' };
303
+ textFields.push('fontSize');
304
+ break;
305
+ case 'FOREGROUND_COLOR':
306
+ case Attribute.FOREGROUND_COLOR.toString():
307
+ textStyle.foregroundColor = hexToRgb(value);
308
+ textFields.push('foregroundColor');
309
+ break;
310
+ case 'ITALIC':
311
+ case Attribute.ITALIC.toString():
312
+ textStyle.italic = value;
313
+ textFields.push('italic');
314
+ break;
315
+ case 'STRIKETHROUGH':
316
+ case Attribute.STRIKETHROUGH.toString():
317
+ textStyle.strikethrough = value;
318
+ textFields.push('strikethrough');
319
+ break;
320
+ case 'UNDERLINE':
321
+ case Attribute.UNDERLINE.toString():
322
+ textStyle.underline = value;
323
+ textFields.push('underline');
324
+ break;
325
+ case 'LINK_URL':
326
+ case Attribute.LINK_URL.toString():
327
+ textStyle.link = { url: value };
328
+ textFields.push('link');
329
+ break;
330
+ }
331
+ }
332
+
333
+ return {
334
+ paragraphStyle,
335
+ textStyle,
336
+ paraFields: paraFields.join(','),
337
+ textFields: textFields.join(',')
338
+ };
339
+ };
340
+
200
341
  export const findItem = (elementMap, type, startIndex, segmentId) => {
201
342
  const item = Array.from(elementMap.values()).find(f => {
202
343
  // segmentId from API is empty string for body, but we might pass null. Normalize.
@@ -58,6 +58,34 @@ const handleTextless = (loc, isAppend, self, type, extras = {}) => {
58
58
 
59
59
  break;
60
60
 
61
+ case 'HORIZONTAL_RULE':
62
+ reqs.push({
63
+ insertText: {
64
+ location,
65
+ text: '\n'
66
+ }
67
+ });
68
+ reqs.push({
69
+ updateParagraphStyle: {
70
+ range: {
71
+ startIndex: loc.index,
72
+ endIndex: loc.index + 1,
73
+ segmentId: loc.segmentId,
74
+ tabId: loc.tabId
75
+ },
76
+ paragraphStyle: {
77
+ borderBottom: {
78
+ color: { color: { rgbColor: { blue: 0, green: 0, red: 0 } } },
79
+ width: { magnitude: 1, unit: 'PT' },
80
+ padding: { magnitude: 0, unit: 'PT' },
81
+ dashStyle: 'SOLID'
82
+ }
83
+ },
84
+ fields: 'borderBottom'
85
+ }
86
+ });
87
+ break;
88
+
61
89
  default:
62
90
  throw new Error(`unknown type ${type} in handleTextless `)
63
91
  }
@@ -263,6 +291,18 @@ export const pageBreakOptions = {
263
291
 
264
292
  };
265
293
 
294
+ export const horizontalRuleOptions = {
295
+ elementType: ElementType.HORIZONTAL_RULE,
296
+ insertMethodSignature: 'DocumentApp.Body.horizontalRule',
297
+ packCanBeNull: true,
298
+ canAcceptText: false,
299
+ getMainRequest: ({ location: loc, isAppend, self, leading }) => {
300
+ return handleTextless(loc, isAppend, self, 'HORIZONTAL_RULE')
301
+ },
302
+ getStyleRequests: null,
303
+ findType: ElementType.PARAGRAPH.toString()
304
+ };
305
+
266
306
  export const tableOptions = {
267
307
  elementType: ElementType.TABLE,
268
308
  packCanBeNull: true,
@@ -17,5 +17,12 @@ import './fakefootnote.js';
17
17
  import './fakeinlineimage.js';
18
18
  import './fakefootnotesection.js';
19
19
  import './fakepositionedimage.js';
20
+ import './fakedate.js';
21
+ import './fakeperson.js';
22
+ import './fakerichlink.js';
23
+ import './fakeequation.js';
24
+ import './fakeequationfunction.js';
25
+ import './fakeequationfunctionargumentseparator.js';
26
+ import './fakeequationsymbol.js';
20
27
  // As you create more element types (e.g., Table, ListItem), import them here.
21
28
  // import './faketable.js';
@@ -41,8 +41,6 @@ export class FakeBookmark extends FakeContainerElement {
41
41
  */
42
42
  getId() {
43
43
  const item = this.__elementMapItem;
44
- // The name is the full named range name, e.g., "kix.abcdef123".
45
- // The ID is the part after "kix.".
46
44
  return item.name.startsWith(BOOKMARK_PREFIX) ? item.name.substring(BOOKMARK_PREFIX.length) : item.name;
47
45
  }
48
46
 
@@ -67,7 +65,7 @@ export class FakeBookmark extends FakeContainerElement {
67
65
  }
68
66
 
69
67
  const factory = getElementFactory(containingElementItem.__type);
70
- const element = factory(shadow.structure, containingElementItem.__name);
68
+ const element = factory(shadow, containingElementItem.__name);
71
69
  const offset = startIndex - containingElementItem.startIndex;
72
70
 
73
71
  return newFakePosition(element, offset);
@@ -10,6 +10,7 @@ const { is } = Utils;
10
10
  import { getElementProp } from './elementhelpers.js';
11
11
  import { FakeElement } from './fakeelement.js';
12
12
  import { createFootnote } from './appenderhelpers.js';
13
+ import { newFakeRangeElement } from './fakerangeelement.js';
13
14
 
14
15
  /**
15
16
  * Creates a new proxied FakeContainerElement instance.
@@ -183,6 +184,133 @@ export class FakeContainerElement extends FakeElement {
183
184
  return elements;
184
185
  }
185
186
 
187
+ /**
188
+ * Clears the contents of the element.
189
+ * @returns {GoogleAppsScript.Document.ContainerElement} The current element.
190
+ * @see https://developers.google.com/apps-script/reference/document/container-element#clear()
191
+ */
192
+ clear() {
193
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.clear');
194
+ if (nargs !== 0) matchThrow();
195
+
196
+ // Snapshot the children names to avoid issues with index shifting during removal
197
+ const childrenNames = this.__twig.children.map(c => c.name);
198
+ childrenNames.forEach(name => {
199
+ newFakeElement(this.shadowDocument, name).removeFromParent();
200
+ });
201
+
202
+ return this;
203
+ }
204
+
205
+ /**
206
+ * Obtains a Text version of the current element, for editing.
207
+ * @returns {GoogleAppsScript.Document.Text} a text version of the current element
208
+ * @see https://developers.google.com/apps-script/reference/document/container-element#editAsText()
209
+ */
210
+ editAsText() {
211
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.editAsText');
212
+ if (nargs !== 0) matchThrow();
213
+ // For now, we'll mark it as not implemented but mark as draft.
214
+ throw new Error('editAsText() is not yet implemented in gas-fakes');
215
+ }
216
+
217
+ /**
218
+ * Searches the contents of the element for a descendant of the specified type.
219
+ * @param {GoogleAppsScript.Document.ElementType} elementType The type of element to search for.
220
+ * @param {GoogleAppsScript.Document.RangeElement} [from] The element to start searching from.
221
+ * @returns {GoogleAppsScript.Document.RangeElement | null} A search result indicating the position of the search element.
222
+ * @see https://developers.google.com/apps-script/reference/document/container-element#findElement(ElementType,RangeElement)
223
+ */
224
+ findElement(elementType, from = null) {
225
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.findElement');
226
+ if (nargs < 1 || nargs > 2) matchThrow();
227
+
228
+ const searchFromElement = from ? from.getElement() : null;
229
+ let foundStart = !searchFromElement;
230
+
231
+ const findRecursive = (container) => {
232
+ const numChildren = container.getNumChildren();
233
+ for (let i = 0; i < numChildren; i++) {
234
+ const child = container.getChild(i);
235
+
236
+ if (!foundStart) {
237
+ if (child.__name === searchFromElement.__name) {
238
+ foundStart = true;
239
+ } else if (child.getNumChildren) {
240
+ const result = findRecursive(child);
241
+ if (result) return result;
242
+ }
243
+ continue;
244
+ }
245
+
246
+ if (child.getType().toString() === elementType.toString()) {
247
+ return newFakeRangeElement({ element: child });
248
+ }
249
+
250
+ if (child.getNumChildren) {
251
+ const result = findRecursive(child);
252
+ if (result) return result;
253
+ }
254
+ }
255
+ return null;
256
+ };
257
+
258
+ return findRecursive(this);
259
+ }
260
+
261
+ /**
262
+ * Searches the contents of the element for the specified text pattern using regular expressions.
263
+ * @param {string} searchPattern The text pattern to search for.
264
+ * @param {GoogleAppsScript.Document.RangeElement} [from] The element to start searching from.
265
+ * @returns {GoogleAppsScript.Document.RangeElement | null} a search result indicating the position of the search text, or null if there is no match
266
+ * @see https://developers.google.com/apps-script/reference/document/container-element#findText(String,RangeElement)
267
+ */
268
+ findText(searchPattern, from = null) {
269
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.findText');
270
+ if (nargs < 1 || nargs > 2) matchThrow();
271
+
272
+ const searchFromElement = from ? from.getElement() : null;
273
+ let foundStart = !searchFromElement;
274
+ const regex = new RegExp(searchPattern);
275
+
276
+ const findRecursive = (container) => {
277
+ const numChildren = container.getNumChildren();
278
+ for (let i = 0; i < numChildren; i++) {
279
+ const child = container.getChild(i);
280
+
281
+ if (!foundStart) {
282
+ if (child.__name === searchFromElement.__name) {
283
+ foundStart = true;
284
+ } else if (child.getNumChildren) {
285
+ const result = findRecursive(child);
286
+ if (result) return result;
287
+ }
288
+ continue;
289
+ }
290
+
291
+ if (child.getType().toString() === 'TEXT') {
292
+ const text = child.getText();
293
+ const match = regex.exec(text);
294
+ if (match) {
295
+ return newFakeRangeElement({
296
+ element: child,
297
+ startOffset: match.index,
298
+ endOffsetInclusive: match.index + match[0].length - 1
299
+ });
300
+ }
301
+ }
302
+
303
+ if (child.getNumChildren) {
304
+ const result = findRecursive(child);
305
+ if (result) return result;
306
+ }
307
+ }
308
+ return null;
309
+ };
310
+
311
+ return findRecursive(this);
312
+ }
313
+
186
314
  /**
187
315
  * Retrieves the child element at the specified index.
188
316
  * @param {number} childIndex The zero-based index of the child element to retrieve.
@@ -250,6 +378,66 @@ export class FakeContainerElement extends FakeElement {
250
378
  return newFakeElement(this.shadowDocument, childTwig.name).__cast();
251
379
  }
252
380
 
381
+ /**
382
+ * Retrieves the contents of the element as a text string.
383
+ * @returns {string} the contents of the element as text string
384
+ * @see https://developers.google.com/apps-script/reference/document/container-element#getText()
385
+ */
386
+ getText() {
387
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.getText');
388
+ if (nargs !== 0) matchThrow();
389
+
390
+ return getText(this);
391
+ }
392
+
393
+ /**
394
+ * Gets the text alignment.
395
+ * @returns {GoogleAppsScript.Document.TextAlignment | null} the type of text alignment
396
+ * @see https://developers.google.com/apps-script/reference/document/container-element#getTextAlignment()
397
+ */
398
+ getTextAlignment() {
399
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.getTextAlignment');
400
+ if (nargs !== 0) matchThrow();
401
+
402
+ const attrs = this.getAttributes();
403
+ // ContainerElement doesn't have a single text alignment, it's usually paragraph-level.
404
+ // However, if we are a Paragraph, this should work.
405
+ return attrs[DocumentApp.Attribute.TEXT_ALIGNMENT] || null;
406
+ }
407
+
408
+ /**
409
+ * Sets the text alignment.
410
+ * @param {GoogleAppsScript.Document.TextAlignment} textAlignment The text alignment to set.
411
+ * @returns {GoogleAppsScript.Document.ContainerElement} the current element
412
+ * @see https://developers.google.com/apps-script/reference/document/container-element#setTextAlignment(TextAlignment)
413
+ */
414
+ setTextAlignment(textAlignment) {
415
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.setTextAlignment');
416
+ if (nargs !== 1) matchThrow();
417
+
418
+ this.setAttributes({
419
+ [DocumentApp.Attribute.TEXT_ALIGNMENT]: textAlignment
420
+ });
421
+ return this;
422
+ }
423
+
424
+ /**
425
+ * Replaces all occurrences of a search pattern with a replacement string.
426
+ * @param {string} searchPattern The text pattern to search for.
427
+ * @param {string} replacement The replacement string.
428
+ * @returns {GoogleAppsScript.Document.ContainerElement} the current element
429
+ * @see https://developers.google.com/apps-script/reference/document/container-element#replaceText(String,String)
430
+ */
431
+ replaceText(searchPattern, replacement) {
432
+ const { nargs, matchThrow } = signatureArgs(arguments, 'ContainerElement.replaceText');
433
+ if (nargs !== 2 || !is.string(searchPattern) || !is.string(replacement)) matchThrow();
434
+
435
+ // replaceText in GAS is global if called on Body, or scoped if called on other containers.
436
+ // Implementing scoped replaceText via API is complex (requires finding and replacing chunks).
437
+ // For now, let's mark it.
438
+ throw new Error('replaceText() is not yet implemented in gas-fakes');
439
+ }
440
+
253
441
  /**
254
442
  * Gets the index of a given child element.
255
443
  * @param {GoogleAppsScript.Document.Element} child The child element to find.