@mcpher/gas-fakes 1.0.15 → 1.0.16
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/fakepositionedimage.js +0 -0
- package/ghissues/issue-positioned-image.sh +25 -0
- package/ghissues/post-issue.sh +53 -0
- package/ghissues/setup-under-construction.sh +107 -0
- package/package.json +7 -2
- package/src/services/advdocs/fakeadvdocuments.js +4 -5
- package/src/services/documentapp/appenderhelpers.js +184 -54
- package/src/services/documentapp/elementhelpers.js +12 -2
- package/src/services/documentapp/elementoptions.js +236 -1
- package/src/services/documentapp/elements.js +4 -0
- package/src/services/documentapp/fakebody.js +40 -3
- package/src/services/documentapp/fakecontainerelement.js +32 -3
- package/src/services/documentapp/fakedocument.js +78 -3
- package/src/services/documentapp/fakedocumenttab.js +8 -0
- package/src/services/documentapp/fakeelement.js +10 -6
- package/src/services/documentapp/fakefootnote.js +189 -0
- package/src/services/documentapp/fakefootnotereference.js +48 -0
- package/src/services/documentapp/fakefootnotesection.js +127 -0
- package/src/services/documentapp/fakeinlineimage.js +138 -0
- package/src/services/documentapp/fakeparagraph.js +48 -6
- package/src/services/documentapp/fakepositionedimage.js +86 -0
- package/src/services/documentapp/fakesectionelement.js +55 -2
- package/src/services/documentapp/nrhelpers.js +1 -1
- package/src/services/documentapp/shadowdocument.js +125 -44
- package/src/services/urlfetchapp/app.js +5 -4
- package/src/support/helpers.js +10 -2
- package/src/support/sxfetch.js +15 -2
- 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.
|
|
60
|
+
"version": "1.0.16",
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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,15 @@ 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
|
+
|
|
95
105
|
// A ListItem is a specialized Paragraph. A search for a PARAGRAPH should also find a LIST_ITEM
|
|
96
106
|
// at the given location.
|
|
97
107
|
if (type === 'PARAGRAPH') {
|
|
@@ -101,7 +111,7 @@ export const findItem = (elementMap, type, startIndex) => {
|
|
|
101
111
|
});
|
|
102
112
|
if (!item) {
|
|
103
113
|
console.log(elementMap.values())
|
|
104
|
-
throw new Error(`Couldnt find element ${type} at ${startIndex}`)
|
|
114
|
+
throw new Error(`Couldnt find element ${type} at ${startIndex} in segment ${segmentId || 'body'}`)
|
|
105
115
|
}
|
|
106
116
|
return item
|
|
107
117
|
}
|