@mcpher/gas-fakes 1.0.15 → 1.0.17

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 (28) hide show
  1. package/fakepositionedimage.js +0 -0
  2. package/ghissues/issue-positioned-image.sh +25 -0
  3. package/ghissues/post-issue.sh +53 -0
  4. package/ghissues/setup-under-construction.sh +107 -0
  5. package/package.json +7 -2
  6. package/src/services/advdocs/fakeadvdocuments.js +4 -5
  7. package/src/services/documentapp/appenderhelpers.js +184 -54
  8. package/src/services/documentapp/elementhelpers.js +18 -2
  9. package/src/services/documentapp/elementoptions.js +236 -1
  10. package/src/services/documentapp/elements.js +4 -0
  11. package/src/services/documentapp/fakebody.js +40 -3
  12. package/src/services/documentapp/fakecontainerelement.js +32 -3
  13. package/src/services/documentapp/fakedocument.js +78 -3
  14. package/src/services/documentapp/fakedocumenttab.js +8 -0
  15. package/src/services/documentapp/fakeelement.js +10 -6
  16. package/src/services/documentapp/fakefootnote.js +189 -0
  17. package/src/services/documentapp/fakefootnotereference.js +48 -0
  18. package/src/services/documentapp/fakefootnotesection.js +127 -0
  19. package/src/services/documentapp/fakeinlineimage.js +138 -0
  20. package/src/services/documentapp/fakeparagraph.js +48 -6
  21. package/src/services/documentapp/fakepositionedimage.js +86 -0
  22. package/src/services/documentapp/fakesectionelement.js +55 -2
  23. package/src/services/documentapp/nrhelpers.js +1 -1
  24. package/src/services/documentapp/shadowdocument.js +127 -44
  25. package/src/services/urlfetchapp/app.js +5 -4
  26. package/src/support/helpers.js +10 -2
  27. package/src/support/sxfetch.js +15 -2
  28. package/src/support/utils.js +11 -4
File without changes
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+
3
+ # A script to create a GitHub issue for the PositionedImage API limitation.
4
+
5
+ # Issue details
6
+ TITLE="Cannot emulate Paragraph.addPositionedImage() due to missing public API"
7
+ BODY=$(cat <<'EOF'
8
+ The `DocumentApp.Paragraph.addPositionedImage()` method exists in the live Google Apps Script environment, allowing for the creation of positioned images anchored to a paragraph.
9
+
10
+ However, as of May 2024, there is no corresponding public endpoint in the Google Docs API v1 to programmatically create a `PositionedObject`. The `createPositionedObject` request does not exist in the public API.
11
+
12
+ This has been a requested feature, but it appears that the Apps Script service uses a private, non-public API to achieve this functionality.
13
+
14
+ Because `gas-fakes` relies exclusively on the public Google Workspace APIs, it is not possible to emulate this method. Tests that use `addPositionedImage` are skipped when run in the fake environment.
15
+
16
+ This issue is to track this limitation and can be closed if Google exposes a public API for this feature.
17
+
18
+ See this related issue tracker for the API feature request: https://issuetracker.google.com/issues/183845501 (Note: This is a guess at a relevant issue, a real one might need to be found or created).
19
+ EOF
20
+ )
21
+ LABELS="emulation-accuracy,document-app"
22
+
23
+ # Create the issue using the GitHub CLI
24
+ gh issue create --title "$TITLE" --body "$BODY" --label "$LABELS"
25
+
@@ -0,0 +1,53 @@
1
+ #!/bin/bash
2
+
3
+ # A shell script to post a markdown file from ./ghissues as a GitHub issue.
4
+ # It automatically extracts the title, labels, and body from the file.
5
+ #
6
+ # Usage: ./ghissues/post-issue.sh <filename>
7
+ # Example: ./ghissues/post-issue.sh document-clear-behavior.md
8
+
9
+ set -e
10
+
11
+ # The directory where issue files are stored, relative to the script's location.
12
+ SCRIPT_DIR=$(dirname "$0")
13
+
14
+ # Check for filename argument
15
+ if [ -z "$1" ]; then
16
+ echo "Usage: $0 <filename_in_ghissues_dir>"
17
+ echo "Example: $0 document-clear-behavior.md"
18
+ exit 1
19
+ fi
20
+
21
+ ISSUE_FILE="$SCRIPT_DIR/$1"
22
+
23
+ # Check if file exists
24
+ if [ ! -f "$ISSUE_FILE" ]; then
25
+ echo "Error: Issue file not found at $ISSUE_FILE"
26
+ exit 1
27
+ fi
28
+
29
+ echo "Reading issue from $ISSUE_FILE..."
30
+
31
+ # Extract the title (first line, remove markdown heading)
32
+ TITLE=$(head -n 1 "$ISSUE_FILE" | sed 's/^# //')
33
+
34
+ # Extract labels from the second line (e.g., **Labels**: `label1`, `label2`)
35
+ LABELS_LINE=$(sed -n '2p' "$ISSUE_FILE")
36
+ LABELS_STRING=$(echo "$LABELS_LINE" | grep -o '`.*`' | sed 's/`//g' || echo "")
37
+
38
+ LABEL_ARGS=()
39
+ if [ -n "$LABELS_STRING" ]; then
40
+ IFS=',' read -ra ADDR <<< "$LABELS_STRING"
41
+ for label in "${ADDR[@]}"; do
42
+ trimmed_label=$(echo "$label" | xargs) # trim whitespace
43
+ if [ -n "$trimmed_label" ]; then
44
+ LABEL_ARGS+=("--label" "$trimmed_label")
45
+ fi
46
+ done
47
+ fi
48
+
49
+ # Use the rest of the file (from line 3) as the body
50
+ gh issue create --title "$TITLE" --body-file <(tail -n +3 "$ISSUE_FILE") "${LABEL_ARGS[@]}"
51
+
52
+ echo "Issue '$TITLE' created successfully."
53
+
@@ -0,0 +1,107 @@
1
+ #!/bin/bash
2
+
3
+ echo "Welcome to the gas-fakes configuration tool!"
4
+ echo "This script will help you set up your .env and gasfakes.json files."
5
+
6
+ # --- Check for existing .env file ---
7
+ if [ -f ".env" ]; then
8
+ echo ""
9
+ echo "An existing .env file was found. We will update the values."
10
+ UPDATE_MODE=true
11
+ else
12
+ echo ""
13
+ echo "No .env file found. We will create a new one."
14
+ UPDATE_MODE=false
15
+ fi
16
+
17
+ # --- Get .env file details ---
18
+ echo ""
19
+ echo "First, let's configure your .env file for Application Default Credentials (ADC)."
20
+ echo "These are necessary for gas-fakes to access Google Workspace APIs on your behalf."
21
+
22
+ read -p "Enter your Google Cloud Project ID: " gcp_project_id
23
+ read -p "Enter the ID of a Google Drive file you have access to: " drive_test_file_id
24
+ read -p "Enter the default scopes (e.g., \"https://www.googleapis.com/auth/userinfo.email,openid,https://www.googleapis.com/auth/cloud-platform\"): " default_scopes
25
+ read -p "Enter any EXTRA_SCOPES (e.g., ,https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/spreadsheets): " extra_scopes
26
+
27
+ # --- Update or create .env file ---
28
+ if [ "$UPDATE_MODE" = true ]; then
29
+ # Use sed to replace the values in the existing file
30
+ sed -i "s|^GCP_PROJECT_ID=.*|GCP_PROJECT_ID=$gcp_project_id|" .env
31
+ sed -i "s|^DRIVE_TEST_FILE_ID=.*|DRIVE_TEST_FILE_ID=$drive_test_file_id|" .env
32
+ sed -i "s|^DEFAULT_SCOPES=.*|DEFAULT_SCOPES=\"$default_scopes\"|" .env
33
+ sed -i "s|^EXTRA_SCOPES=.*|EXTRA_SCOPES=\"$extra_scopes\"|" .env
34
+ echo ""
35
+ echo "Successfully updated the .env file."
36
+ else
37
+ # Write a new .env file
38
+ cat <<EOF > .env
39
+ # Required for gas-fakes to use Application Default Credentials
40
+ GCP_PROJECT_ID="$gcp_project_id"
41
+ DRIVE_TEST_FILE_ID="$drive_test_file_id"
42
+
43
+ # This should generally be left as default for ADC
44
+ AC=default
45
+
46
+ # These are the scopes set by default - customize as needed
47
+ DEFAULT_SCOPES="$default_scopes"
48
+
49
+ # Add additional scopes here
50
+ EXTRA_SCOPES="$extra_scopes"
51
+ EOF
52
+ echo ""
53
+ echo "Successfully created the .env file."
54
+ fi
55
+
56
+ # --- Get gasfakes.json details ---
57
+ echo ""
58
+ echo "Next, let's configure your gasfakes.json file."
59
+ echo "This file informs gas-fakes about your local environment."
60
+
61
+ # Using default values as suggested by the repository
62
+ DEFAULT_MANIFEST="./appsscript.json"
63
+ DEFAULT_CLASP="./.clasp.json"
64
+ DEFAULT_CACHE="/tmp/gas-fakes/cache"
65
+ DEFAULT_PROPERTIES="/tmp/gas-fakes/properties"
66
+ DEFAULT_SCRIPT_ID=$(uuidgen) # A fake script ID if clasp file is not used
67
+
68
+ read -p "Enter the path to your manifest file (default: $DEFAULT_MANIFEST): " manifest_path
69
+ manifest_path=${manifest_path:-$DEFAULT_MANIFEST}
70
+
71
+ read -p "Enter the path to your clasp file (default: $DEFAULT_CLASP): " clasp_path
72
+ clasp_path=${clasp_path:-$DEFAULT_CLASP}
73
+
74
+ read -p "Enter a bound document ID if applicable (leave blank for none): " document_id
75
+ if [ -z "$document_id" ]; then
76
+ document_id="null"
77
+ else
78
+ document_id="\"$document_id\""
79
+ fi
80
+
81
+ read -p "Enter the local cache directory (default: $DEFAULT_CACHE): " cache_path
82
+ cache_path=${cache_path:-$DEFAULT_CACHE}
83
+
84
+ read -p "Enter the local properties directory (default: $DEFAULT_PROPERTIES): " properties_path
85
+ properties_path=${properties_path:-$DEFAULT_PROPERTIES}
86
+
87
+ read -p "Enter the script ID (default: a new random ID will be created): " script_id
88
+ script_id=${script_id:-$DEFAULT_SCRIPT_ID}
89
+
90
+ # --- Write gasfakes.json file ---
91
+ cat <<EOF > gasfakes.json
92
+ {
93
+ "manifest": "$manifest_path",
94
+ "clasp": "$clasp_path",
95
+ "documentId": $document_id,
96
+ "cache": "$cache_path",
97
+ "properties": "$properties_path",
98
+ "scriptId": "$script_id"
99
+ }
100
+ EOF
101
+
102
+ echo ""
103
+ echo "Successfully created the gasfakes.json file."
104
+
105
+ echo ""
106
+ echo "Setup complete! You can now run your gas-fakes project locally."
107
+ echo "Be sure to follow the remaining setup instructions in the gas-fakes repository."
package/package.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "googleapis": "^157.0.0",
13
13
  "got": "^14.4.7",
14
14
  "into-stream": "^8.0.1",
15
+ "is": "^3.3.2",
15
16
  "keyv": "^5.5.0",
16
17
  "keyv-file": "^5.1.3",
17
18
  "mime": "^4.0.7",
@@ -49,13 +50,17 @@
49
50
  "testsheetsrange": "cp mainlocal.js main.js && node --env-file=.env ./test/testsheetsrange.js execute",
50
51
  "testdocslistitems": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocslistitems.js execute",
51
52
  "testdocsall": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocsall.js",
53
+ "testdocsheaders": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocsheaders.js execute",
54
+ "testdocsfooters": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocsfooters.js execute",
55
+ "testdocsfootnotes": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocsfootnotes.js execute",
56
+ "testdocsimages": "cp mainlocal.js main.js && node --env-file=.env ./test/testdocsimages.js execute",
52
57
  "pub": "cp mainlocal.js main.js && npm publish --access public"
53
58
  },
54
59
  "name": "@mcpher/gas-fakes",
55
- "version": "1.0.15",
60
+ "version": "1.0.17",
56
61
  "license": "MIT",
57
62
  "main": "main.js",
58
63
  "description": "A proof of concept implementation of Apps Script Environment on Node",
59
64
  "repository": "github:brucemcpherson/gas-fakes",
60
65
  "homepage": "https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/"
61
- }
66
+ }
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Advanced docs service
3
3
  */
4
-
5
4
  import { Proxies } from '../../support/proxies.js'
6
- import { signatureArgs, ssError } from '../../support/helpers.js'
5
+ import { signatureArgs, gError } from '../../support/helpers.js'
7
6
  import { docsCacher } from '../../support/docscacher.js';
8
7
  import { Syncit } from '../../support/syncit.js'
9
8
  import is from '@sindresorhus/is'
@@ -44,7 +43,7 @@ class FakeAdvDocuments extends FakeAdvResource {
44
43
  }, options);
45
44
 
46
45
  // maybe we need to throw an error
47
- ssError(response, "create")
46
+ gError(response, 'docs.documents', "create")
48
47
  this.__addAllowed(data.documentId);
49
48
  return data
50
49
  }
@@ -65,7 +64,7 @@ class FakeAdvDocuments extends FakeAdvResource {
65
64
  requestBody: requests
66
65
  });
67
66
 
68
- ssError(response, "batchUpdate");
67
+ gError(response, 'docs.documents', "batchUpdate");
69
68
 
70
69
  return data;
71
70
  }
@@ -105,7 +104,7 @@ class FakeAdvDocuments extends FakeAdvResource {
105
104
  const params = {documentId: this.__allowed(documentId), ...(options || {}) };
106
105
 
107
106
  const { response, data } = this._call("get", params);
108
- ssError(response, 'get');
107
+ gError(response, 'docs.documents', 'get');
109
108
  return {
110
109
  data,
111
110
  response
@@ -1,10 +1,10 @@
1
1
  import { Utils } from "../../support/utils.js";
2
2
  import { ElementType } from '../enums/docsenums.js';
3
- const { is } = Utils
3
+ const { is, isBlob } = Utils
4
4
  import { getElementFactory } from './elementRegistry.js'
5
- import { signatureArgs } from '../../support/helpers.js';
5
+ import { signatureArgs, notYetImplemented } from '../../support/helpers.js';
6
6
  import { findItem } from './elementhelpers.js';
7
- import { paragraphOptions, pageBreakOptions, tableOptions, textOptions, listItemOptions } from './elementoptions.js';
7
+ import { paragraphOptions, pageBreakOptions, tableOptions, textOptions, listItemOptions, imageOptions, positionedImageOptions } from './elementoptions.js';
8
8
  import { deleteContentRange, createParagraphBullets, reverseUpdateContent, deleteParagraphBullets } from "./elementblasters.js";
9
9
 
10
10
  /**
@@ -34,15 +34,21 @@ const validateInserterArgs = (elementOrText, childIndex, options) => {
34
34
  if (!packCanBeNull && is.nullOrUndefined(elementOrText)) matchThrow();
35
35
 
36
36
  if (isObject) {
37
- // For element objects, the live API checks type first, then attached status.
38
- const typeMatches = elementOrText.getType() === elementType;
39
- if (!typeMatches) {
40
- matchThrow();
41
- }
37
+ // A blob is an object, but doesn't have getType().
38
+ // Other elements (Paragraph, Table, etc.) do.
39
+ if (is.function(elementOrText.getType)) {
40
+ const typeMatches = elementOrText.getType() === elementType;
41
+ if (!typeMatches) {
42
+ matchThrow();
43
+ }
42
44
 
43
- isDetached = !!elementOrText.__isDetached;
44
- if (!isDetached) {
45
- throw new Error('Element must be detached.');
45
+ isDetached = !!elementOrText.__isDetached;
46
+ if (!isDetached) {
47
+ throw new Error('Element must be detached.');
48
+ }
49
+ } else if (!isBlob(elementOrText)) {
50
+ // It's an object, but not a blob and doesn't have getType(). Invalid.
51
+ matchThrow();
46
52
  }
47
53
  } else {
48
54
  // Handle non-object arguments (arrays, strings, null)
@@ -110,33 +116,46 @@ const calculateInsertionPointsAndInitialRequests = (self, childIndex, isAppend,
110
116
  // The shadow.refresh() call at the end of elementInserter handles all named range updates correctly.
111
117
  }
112
118
  } else {
113
- // It's an insert operation, creating a new element.
114
- // The equality case (childIndex === children.length) is handled by the caller (e.g., FakeBody) by converting to an append.
115
- if (childIndex < 0 || childIndex >= children.length) {
116
- throw new Error(`Child index (${childIndex}) must be less than or equal to the number of child elements (${children.length}).`);
117
- }
118
- const targetChildTwig = children[childIndex];
119
- const targetChildItem = elementMap.get(targetChildTwig.name);
120
-
121
- // rules with tables mean we have to insert before preceding paragrap
122
- if (targetChildItem.__type === "TABLE") {
123
- insertIndex = targetChildItem.startIndex - 1; // Insert before the table.
124
- if (childIndex < 1) {
125
- // because a table cant ever be the first child
126
- throw new Error(`Cannot insert before the first child element table (${targetChildItem.name})`);
119
+ // It's an insert operation.
120
+ const isParaInsert = self.getType() === ElementType.PARAGRAPH;
121
+ if (isParaInsert) {
122
+ // Inserting into an existing paragraph. No newlines needed.
123
+ if (childIndex < 0 || childIndex > children.length) {
124
+ throw new Error(`Child index (${childIndex}) must be between 0 and the number of child elements (${children.length}).`);
127
125
  }
128
- leading = '\n'
129
- // in this case the new element startindex will be +1 to the insertIndex
130
- newElementStartIndex = insertIndex + 1;
126
+ if (children.length === 0 || childIndex === children.length) {
127
+ // Inserting into an empty paragraph or at the end.
128
+ insertIndex = item.endIndex - 1;
129
+ } else {
130
+ // Inserting before an existing child.
131
+ const targetChildTwig = children[childIndex];
132
+ const targetChildItem = elementMap.get(targetChildTwig.name);
133
+ insertIndex = targetChildItem.startIndex;
134
+ }
135
+ newElementStartIndex = item.startIndex; // The container is the paragraph itself.
136
+ childStartIndex = insertIndex; // The new child will start where we insert it.
131
137
  } else {
132
- insertIndex = targetChildItem.startIndex
133
- trailing = '\n'
134
- newElementStartIndex = insertIndex;
138
+ // It's an insert operation, creating a new element in a Body/Header/etc.
139
+ if (childIndex < 0 || childIndex >= children.length) {
140
+ throw new Error(`Child index (${childIndex}) must be less than or equal to the number of child elements (${children.length}).`);
141
+ }
142
+ const targetChildTwig = children[childIndex];
143
+ const targetChildItem = elementMap.get(targetChildTwig.name);
144
+
145
+ if (targetChildItem.__type === "TABLE") {
146
+ insertIndex = targetChildItem.startIndex - 1; // Insert before the table.
147
+ if (childIndex < 1) {
148
+ throw new Error(`Cannot insert before the first child element table (${targetChildItem.name})`);
149
+ }
150
+ leading = '\n'
151
+ newElementStartIndex = insertIndex + 1;
152
+ } else {
153
+ insertIndex = targetChildItem.startIndex
154
+ trailing = '\n'
155
+ newElementStartIndex = insertIndex;
156
+ }
157
+ childStartIndex = null; // Child start index is unknown, must be found within container.
135
158
  }
136
-
137
-
138
- // TODO validate what this is used for
139
- childStartIndex = null; // Child start index is unknown, must be found within container.
140
159
  }
141
160
 
142
161
  return { insertIndex, newElementStartIndex, childStartIndex, requests, leading, trailing };
@@ -146,7 +165,7 @@ const calculateInsertionPointsAndInitialRequests = (self, childIndex, isAppend,
146
165
  * Finds and creates the new element instance after a batch update.
147
166
  * @private
148
167
  */
149
- const findAndReturnNewElement = (shadow, newElementStartIndex, childStartIndex, isAppend, options) => {
168
+ const findAndReturnNewElement = (shadow, newElementStartIndex, childStartIndex, isAppend, options, segmentId) => {
150
169
  const { elementType, findType, findChildType } = options;
151
170
 
152
171
  const findContainerType = (is.function(findType) ? findType(isAppend) : findType) || elementType.toString();
@@ -157,10 +176,10 @@ const findAndReturnNewElement = (shadow, newElementStartIndex, childStartIndex,
157
176
  if (childTypeToFind) {
158
177
  if (childStartIndex !== null) {
159
178
  // The child's start index was predictable (e.g., appending to an existing paragraph).
160
- finalItem = findItem(shadow.elementMap, childTypeToFind, childStartIndex);
179
+ finalItem = findItem(shadow.elementMap, childTypeToFind, childStartIndex, segmentId);
161
180
  } else {
162
181
  // A new container was created. Find it, then find the child within it.
163
- const containerItem = findItem(shadow.elementMap, findContainerType, newElementStartIndex);
182
+ const containerItem = findItem(shadow.elementMap, findContainerType, newElementStartIndex, segmentId);
164
183
  const childTwig = (containerItem.__twig.children || []).find(twig => {
165
184
  const childItem = shadow.elementMap.get(twig.name);
166
185
  return childItem && childItem.__type === childTypeToFind;
@@ -172,7 +191,7 @@ const findAndReturnNewElement = (shadow, newElementStartIndex, childStartIndex,
172
191
  }
173
192
  } else {
174
193
  // For operations that return the container element itself.
175
- finalItem = findItem(shadow.elementMap, findContainerType, newElementStartIndex);
194
+ finalItem = findItem(shadow.elementMap, findContainerType, newElementStartIndex, segmentId);
176
195
  }
177
196
 
178
197
  const factory = getElementFactory(finalItem.__type);
@@ -192,6 +211,11 @@ const findAndReturnNewElement = (shadow, newElementStartIndex, childStartIndex,
192
211
  * @private
193
212
  */
194
213
  const elementInserter = (self, elementOrText, childIndex, options) => {
214
+ // Before the mutation, record the container's identity.
215
+ const selfName = self.__name;
216
+ const selfStartIndex = self.__elementMapItem.startIndex;
217
+ const selfType = self.getType().toString();
218
+
195
219
  // 1. Validate arguments and determine operation type.
196
220
  const { isAppend, isDetached } = validateInserterArgs(elementOrText, childIndex, options);
197
221
 
@@ -208,7 +232,7 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
208
232
 
209
233
  // 4. Build the main batch update request list.
210
234
  const { getMainRequest, getStyleRequests } = options;
211
- const mainRequests = [].concat(getMainRequest({
235
+ const mainRequestResult = getMainRequest({
212
236
  content: elementOrText,
213
237
  location: { index: insertIndex, segmentId, tabId },
214
238
  isAppend,
@@ -216,7 +240,17 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
216
240
  structure,
217
241
  leading,
218
242
  trailing
219
- }));
243
+ });
244
+
245
+ let mainRequests;
246
+ let cleanup = null;
247
+
248
+ if (is.plainObject(mainRequestResult) && mainRequestResult.requests) {
249
+ mainRequests = [].concat(mainRequestResult.requests);
250
+ cleanup = mainRequestResult.cleanup;
251
+ } else {
252
+ mainRequests = [].concat(mainRequestResult);
253
+ }
220
254
  // if we were inserting a table then there;ll be an unwanted \n to remove - this should be -2 from the insertIndex
221
255
  // TODO we need to check if that index is actually a paragraph or not otherwise this will fail/screw up
222
256
  if (!isAppend && options.elementType === ElementType.TABLE && insertIndex > 1) {
@@ -237,10 +271,24 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
237
271
  }
238
272
 
239
273
  // 5. Execute the update and refresh the document state.
240
- if (requests.length > 0) {
241
- Docs.Documents.batchUpdate({ requests }, shadow.getId());
274
+ try {
275
+ if (requests.length > 0) {
276
+ Docs.Documents.batchUpdate({ requests }, shadow.getId());
277
+ }
278
+ shadow.refresh(); // must always refresh, as getMainRequest might have side effects
279
+ } finally {
280
+ if (cleanup) {
281
+ cleanup();
282
+ }
283
+ }
284
+
285
+ // After the refresh, the 'self' object is stale. We need to find its new representation
286
+ // in the updated element map and update its internal name. This "revives" the object
287
+ // for subsequent method calls in the user's script.
288
+ const newSelfItem = findItem(shadow.elementMap, selfType, selfStartIndex, segmentId);
289
+ if (newSelfItem && newSelfItem.__name !== selfName) {
290
+ self.__name = newSelfItem.__name;
242
291
  }
243
- shadow.refresh(); // must always refresh, as getMainRequest might have side effects
244
292
 
245
293
  // 6. Handle table content population if necessary. This is a two-phase update
246
294
  // because we need the table to exist before we can get the indices to populate its cells.
@@ -249,13 +297,27 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
249
297
 
250
298
  if (cells && cells.length > 0 && cells[0].length > 0) {
251
299
  // The table was created at newElementStartIndex
252
- const populateRequests = reverseUpdateContent(
253
- shadow.__unpackDocumentTab(shadow.resource).body.content,
254
- newElementStartIndex,
255
- cells,
256
- segmentId,
257
- tabId
258
- );
300
+ const { body, headers, footers } = shadow.__unpackDocumentTab(shadow.resource);
301
+ let containerContent;
302
+
303
+ // Determine the correct content array based on the container type and segmentId
304
+ if (self.getType() === ElementType.BODY_SECTION) {
305
+ containerContent = body.content;
306
+ } else if (self.getType() === ElementType.HEADER_SECTION) {
307
+ containerContent = headers[segmentId]?.content;
308
+ } else if (self.getType() === ElementType.FOOTER_SECTION) {
309
+ containerContent = footers[segmentId]?.content;
310
+ } else {
311
+ // This logic is for top-level containers. TableCell would need a different approach.
312
+ // For now, this covers the failing case.
313
+ throw new Error(`Table population not supported in container of type: ${self.getType()}`);
314
+ }
315
+
316
+ if (!containerContent) {
317
+ throw new Error(`Could not find content for segmentId: ${segmentId}`);
318
+ }
319
+
320
+ const populateRequests = reverseUpdateContent(containerContent, newElementStartIndex, cells, segmentId, tabId);
259
321
  if (populateRequests.length > 0) {
260
322
  Docs.Documents.batchUpdate({ requests: populateRequests }, shadow.getId());
261
323
  shadow.refresh();
@@ -265,10 +327,10 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
265
327
  // When the API inserts a table, it automatically adds a paragraph after it.
266
328
  // This new paragraph can sometimes inherit the list style of the element
267
329
  // preceding the table. We need to explicitly remove this bullet formatting.
268
- const tableItem = findItem(shadow.elementMap, 'TABLE', newElementStartIndex);
330
+ const tableItem = findItem(shadow.elementMap, 'TABLE', newElementStartIndex, segmentId);
269
331
  if (tableItem) {
270
332
  const paragraphAfterTableIndex = tableItem.endIndex;
271
- const paraItem = findItem(shadow.elementMap, 'PARAGRAPH', paragraphAfterTableIndex);
333
+ const paraItem = findItem(shadow.elementMap, 'PARAGRAPH', paragraphAfterTableIndex, segmentId);
272
334
 
273
335
  // findItem for PARAGRAPH will also find LIST_ITEM.
274
336
  // We check if the found item has a bullet, which indicates it wrongly inherited list properties.
@@ -280,9 +342,64 @@ const elementInserter = (self, elementOrText, childIndex, options) => {
280
342
  }
281
343
 
282
344
  // 7. Find and return the newly created element instance.
283
- return findAndReturnNewElement(shadow, newElementStartIndex, childStartIndex, isAppend, options);
345
+ return findAndReturnNewElement(shadow, newElementStartIndex, childStartIndex, isAppend, options, segmentId);
284
346
  };
285
347
 
348
+ /**
349
+ * Creates a footnote and a reference to it.
350
+ * @param {FakeContainerElement} parent The parent container.
351
+ * @param {string} text The text for the footnote.
352
+ * @returns {GoogleAppsScript.Document.Footnote} The new footnote element.
353
+ */
354
+ export const createFootnote = (parent, text) => {
355
+ if (parent.__isDetached) {
356
+ throw new Error('Cannot append to a detached element.');
357
+ }
358
+ const shadow = parent.shadowDocument;
359
+ const segmentId = parent.__segmentId;
360
+ const tabId = parent.__tabId;
361
+
362
+ // Find the insertion point at the end of the parent container.
363
+ const parentItem = shadow.getElement(parent.__name);
364
+ const children = parentItem.__twig.children;
365
+ const lastChild = children.length > 0 ? shadow.getElement(children[children.length - 1].name) : parentItem;
366
+ const insertIndex = lastChild.endIndex - 1;
367
+
368
+ const requests = [{
369
+ // Create a new paragraph for the footnote reference by inserting a newline at the end of the parent.
370
+ insertText: {
371
+ text: '\n',
372
+ location: { index: insertIndex, segmentId, tabId },
373
+ },
374
+ }, {
375
+ // Create the footnote and its reference at the start of the new paragraph.
376
+ createFootnote: {
377
+ location: {
378
+ index: insertIndex + 1,
379
+ segmentId,
380
+ tabId,
381
+ },
382
+ },
383
+ }];
384
+
385
+ const response = Docs.Documents.batchUpdate({ requests }, shadow.getId());
386
+ const footnoteId = response.replies[1].createFootnote.footnoteId;
387
+
388
+ if (text) {
389
+ const textRequest = {
390
+ insertText: {
391
+ text,
392
+ location: {
393
+ segmentId: footnoteId, // The footnote content is its own segment
394
+ index: 1, // Insert at the beginning of the footnote content
395
+ },
396
+ },
397
+ };
398
+ Docs.Documents.batchUpdate({ requests: [textRequest] }, shadow.getId());
399
+ }
400
+ shadow.refresh();
401
+ return shadow.getFootnoteById(footnoteId);
402
+ };
286
403
 
287
404
 
288
405
  // THE API has no way of inserting a horizontal rule
@@ -324,4 +441,17 @@ export const appendListItem = (self, listItemOrText) => {
324
441
 
325
442
  export const insertListItem = (self, childIndex, listItemOrText) => {
326
443
  return elementInserter(self, listItemOrText, childIndex, listItemOptions);
444
+ };
445
+
446
+ export const addPositionedImage = (self, image) => {
447
+ // Per the docs, this anchors the image to the beginning of the paragraph.
448
+ return elementInserter(self, image, 0, positionedImageOptions);
449
+ };
450
+
451
+ export const appendImage = (self, image) => {
452
+ return elementInserter(self, image, null, imageOptions);
453
+ };
454
+
455
+ export const insertImage = (self, childIndex, image) => {
456
+ return elementInserter(self, image, childIndex, imageOptions);
327
457
  };
@@ -30,6 +30,9 @@ export const getElementProp = (se) => {
30
30
  if (se.textRun) return { prop: null, type: 'TEXT' };
31
31
  if (se.pageBreak) return { prop: null, type: 'PAGE_BREAK' };
32
32
  if (se.horizontalRule) return { prop: null, type: 'HORIZONTAL_RULE' };
33
+ if (se.footnoteReference) return { prop: null, type: 'FOOTNOTE_REFERENCE' };
34
+ if (se.inlineObjectElement) return { prop: null, type: 'INLINE_IMAGE' };
35
+ if (se.positionedObjectElement) return { prop: null, type: 'POSITIONED_IMAGE' };
33
36
 
34
37
  if (se.body) {
35
38
  return { prop: 'body', type: 'BODY_SECTION' };
@@ -90,8 +93,21 @@ export const getText = (element) => {
90
93
  // Paragraphs in the Docs API have a trailing newline. The Apps Script getText() method removes it.
91
94
  return text.replace(/\n$/, '');
92
95
  };
93
- export const findItem = (elementMap, type, startIndex) => {
96
+ export const findItem = (elementMap, type, startIndex, segmentId) => {
94
97
  const item = Array.from(elementMap.values()).find(f => {
98
+ // segmentId from API is empty string for body, but we might pass null. Normalize.
99
+ const itemSegmentId = f.__segmentId || '';
100
+ const searchSegmentId = segmentId || '';
101
+ if (itemSegmentId !== searchSegmentId) {
102
+ return false;
103
+ }
104
+
105
+ // For container elements that don't have a startIndex (like Header, Footer, Footnote),
106
+ // we can find them by type and segmentId alone.
107
+ if (is.undefined(startIndex)) {
108
+ return f.__type === type;
109
+ }
110
+
95
111
  // A ListItem is a specialized Paragraph. A search for a PARAGRAPH should also find a LIST_ITEM
96
112
  // at the given location.
97
113
  if (type === 'PARAGRAPH') {
@@ -101,7 +117,7 @@ export const findItem = (elementMap, type, startIndex) => {
101
117
  });
102
118
  if (!item) {
103
119
  console.log(elementMap.values())
104
- throw new Error(`Couldnt find element ${type} at ${startIndex}`)
120
+ throw new Error(`Couldnt find element ${type} at ${startIndex} in segment ${segmentId || 'body'}`)
105
121
  }
106
122
  return item
107
123
  }