@mcpher/gas-fakes 2.3.11 → 2.3.13

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 (46) hide show
  1. package/README.md +14 -14
  2. package/gas-fakes.js +1 -0
  3. package/gf_agent/scripts/builder.js +26 -1
  4. package/package.json +1 -1
  5. package/src/cli/lib-manager.js +14 -4
  6. package/src/cli/setup.js +17 -4
  7. package/src/services/chartsapp/fakechartsapp.js +6 -1
  8. package/src/services/driveapp/driveiterators.js +53 -8
  9. package/src/services/driveapp/fakedriveapp.js +49 -15
  10. package/src/services/driveapp/fakedrivefile.js +105 -0
  11. package/src/services/driveapp/fakedrivefolder.js +8 -0
  12. package/src/services/driveapp/fakedrivemeta.js +68 -16
  13. package/src/services/driveapp/fakefolderapp.js +19 -6
  14. package/src/services/enums/chartsenums.js +53 -20
  15. package/src/services/enums/driveenums.js +1 -0
  16. package/src/services/slidesapp/fakeautofit.js +23 -15
  17. package/src/services/slidesapp/fakecolorscheme.js +160 -0
  18. package/src/services/slidesapp/fakelayout.js +11 -1
  19. package/src/services/slidesapp/fakemaster.js +10 -0
  20. package/src/services/slidesapp/fakepresentation.js +27 -0
  21. package/src/services/slidesapp/fakeslide.js +9 -0
  22. package/src/services/slidesapp/faketextrange.js +6 -11
  23. package/src/services/spreadsheetapp/chartenummapping.js +15 -0
  24. package/src/services/spreadsheetapp/fakebooleancondition.js +119 -0
  25. package/src/services/spreadsheetapp/fakecellimage.js +42 -0
  26. package/src/services/spreadsheetapp/fakecellimagebuilder.js +59 -0
  27. package/src/services/spreadsheetapp/fakeconditionalformatrule.js +55 -0
  28. package/src/services/spreadsheetapp/fakeconditionalformatrulebuilder.js +330 -0
  29. package/src/services/spreadsheetapp/fakedevelopermetadata.js +32 -4
  30. package/src/services/spreadsheetapp/fakedevelopermetadatalocation.js +27 -5
  31. package/src/services/spreadsheetapp/fakeembeddedchartbuilder.js +155 -21
  32. package/src/services/spreadsheetapp/fakegradientcondition.js +71 -0
  33. package/src/services/spreadsheetapp/fakesheet.js +63 -0
  34. package/src/services/spreadsheetapp/fakesheetrange.js +12 -1
  35. package/src/services/spreadsheetapp/fakespreadsheet.js +30 -11
  36. package/src/services/spreadsheetapp/fakespreadsheetapp.js +21 -3
  37. package/src/services/urlfetchapp/app.js +33 -1
  38. package/src/support/fileiterators.js +3 -1
  39. package/src/support/filesharers.js +7 -3
  40. package/src/support/peeker.js +8 -2
  41. package/src/support/sheetutils.js +1 -0
  42. package/src/support/sxdrive.js +26 -15
  43. package/src/support/syncit.js +4 -6
  44. package/src/support/workersync/synchronizer.js +24 -4
  45. package/src/support/workersync/worker.js +13 -2
  46. package/summarize_advanced.js +0 -69
package/README.md CHANGED
@@ -35,7 +35,7 @@ Now you can run apps script code directly from your console - for example
35
35
  gas-fakes -s "const files=DriveApp.getRootFolder().searchFiles('title contains \"Untitled\"');while (files.hasNext()) {console.log(files.next().getName())};"
36
36
  ```
37
37
 
38
- For details see [gas fakes cli](gas-fakes-cli.md)
38
+ For details see [gas fakes cli](notes/gas-fakes-cli.md)
39
39
 
40
40
  ### Configuration
41
41
 
@@ -168,7 +168,7 @@ There are a couple of syntactical differences between Node and Apps Script. Not
168
168
  // this required on Node but not on Apps Script
169
169
  if (ScriptApp.isFake) testFakes()
170
170
  ````
171
- For inspiration on pushing modified files to the IDE, see the togas.sh bash script I use for the test suite. There's also a complete push pull workflow available - see - [push test pull](pull-test-push.md)
171
+ For inspiration on pushing modified files to the IDE, see the togas.sh bash script I use for the test suite. There's also a complete push pull workflow available - see - [push test pull](notes/pull-test-push.md)
172
172
 
173
173
 
174
174
  ## Help
@@ -188,11 +188,11 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
188
188
  - [readme](README.md)
189
189
  - [Natural Language Automation with Gemini Skills & MCP Server](gemini-skills-mcp.md) - new skills-based agent approach.
190
190
  - [gf_agent documentation](../gf_agent/README.md) - instructions for the Gemini CLI automation agent and MCP server.
191
- - [gas fakes cli](gas-fakes-cli.md)
191
+ - [gas fakes cli](notes/gas-fakes-cli.md)
192
192
  - [github actions using adc](https://github.com/brucemcpherson/gas-fakes-actions-adc)
193
193
  - [github actions using dwd and wif](https://github.com/brucemcpherson/gas-fakes-actions-dwd)
194
- - [ksuite as a back end](ksuite_poc.md)
195
- - [msgraph as a back end](msgraph.md)
194
+ - [ksuite as a back end](notes/ksuite_poc.md)
195
+ - [msgraph as a back end](notes/msgraph.md)
196
196
  - [resurrecting scriptDb repo](https://github.com/brucemcpherson/scriptdb-redux)
197
197
  - [Resurrecting ScriptDb – nosql database for Apps Script](https://ramblings.mcpher.com/resurrecting-scriptdb-nosql-database-for-apps-script/)
198
198
  - [gas-fakes in serverless containers](https://docs.google.com/presentation/d/1JlXF9T--DD4ERHopyP3WyAMhjRCxxHblgCP5ynxaJ3k/edit?usp=sharing)
@@ -203,7 +203,7 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
203
203
  - [running gas-fakes on Amazon AWS lambda](https://github.com/brucemcpherson/gas-fakes-containers)
204
204
  - [running gas-fakes on Azure ACA](https://github.com/brucemcpherson/gas-fakes-containers)
205
205
  - [running gas-fakes on Github actions](https://github.com/brucemcpherson/gas-fakes-containers)
206
- - [jdbc notes](jdbc-notes.md)
206
+ - [jdbc notes](notes/jdbc-notes.md)
207
207
  - [Yes – you can run native apps script code on Azure ACA as well!](https://ramblings.mcpher.com/yes-you-can-run-native-apps-script-code-on-azure-aca-as-well/)
208
208
  - [Yes – you can run native apps script code on AWS Lambda!](https://ramblings.mcpher.com/apps-script-on-aws-lambda/)
209
209
  - [initial idea and thoughts](https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/)
@@ -213,16 +213,16 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
213
213
  - [Turning async into synch on Node using workers](https://ramblings.mcpher.com/turning-async-into-synch-on-node-using-workers/)
214
214
  - [All about Apps Script Enums and how to fake them](https://ramblings.mcpher.com/all-about-apps-script-enums-and-how-to-fake-them/)
215
215
  - [colaborators](collaborators.md) - additional information for collaborators
216
- - [oddities](oddities.md) - a collection of oddities uncovered during this project
217
- - [named colors](named-colors.md)
218
- - [sandbox](sandbox.md)
219
- - [senstive scopes](workspace_scopes.md)
220
- - [using apps script libraries with gas-fakes](libraries.md)
216
+ - [oddities](notes/oddities.md) - a collection of oddities uncovered during this project
217
+ - [named colors](notes/named-colors.md)
218
+ - [sandbox](notes/sandbox.md)
219
+ - [senstive scopes](notes/workspace_scopes.md)
220
+ - [using apps script libraries with gas-fakes](notes/libraries.md)
221
221
  - [how libhandler works](libhandler.md)
222
222
  - [article:using apps script libraries with gas-fakes](https://ramblings.mcpher.com/how-to-use-apps-script-libraries-directly-from-node/)
223
- - [named range identity](named-range-identity.md)
224
- - [Workspace scopes with local authentication](workspace_scopes.md)
225
- - [push test pull](pull-test-push.md)
223
+ - [named range identity](notes/named-range-identity.md)
224
+ - [Workspace scopes with local authentication](notes/workspace_scopes.md)
225
+ - [push test pull](notes/pull-test-push.md)
226
226
  - [sharing cache and properties between gas-fakes and live apps script](https://ramblings.mcpher.com/sharing-cache-and-properties-between-gas-fakes-and-live-apps-script/)
227
227
  - [gas-fakes-cli now has built in mcp server and gemini extension](https://ramblings.mcpher.com/gas-fakes-cli-now-has-built-in-mcp-server-and-gemini-extension/)
228
228
  - [gas-fakes CLI: Run apps script code directly from your terminal](https://ramblings.mcpher.com/gas-fakes-cli-run-apps-script-code-directly-from-your-terminal/)
package/gas-fakes.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ process.env.GF_CLI_INTERACTIVE = "true";
3
4
  import { main } from "./src/cli/app.js";
4
5
 
5
6
  main().catch((error) => {
@@ -56,7 +56,32 @@ async function build() {
56
56
  }
57
57
 
58
58
  await fs.writeFile(INDEX_FILE, masterIndex);
59
- console.log('Build complete! Skills and Index generated.');
59
+
60
+ // Aggregate knowledge files into SKILL.md
61
+ const TEMPLATE_FILE = './gf_agent/scripts/SKILL.template.md';
62
+ const KNOWLEDGE_DIR = './gf_agent/knowledge';
63
+ const SKILL_OUTPUT = './gf_agent/SKILL.md';
64
+
65
+ let skillMarkdown = await fs.readFile(TEMPLATE_FILE, 'utf-8');
66
+
67
+ try {
68
+ const knowledgeFiles = await fs.readdir(KNOWLEDGE_DIR);
69
+ // Sort files to ensure deterministic aggregation (e.g., 01-drive.md, 02-syntax.md)
70
+ knowledgeFiles.sort();
71
+
72
+ for (const kFile of knowledgeFiles) {
73
+ if (kFile.endsWith('.md')) {
74
+ const kContent = await fs.readFile(path.join(KNOWLEDGE_DIR, kFile), 'utf-8');
75
+ skillMarkdown += `\n${kContent}\n`;
76
+ }
77
+ }
78
+ } catch (err) {
79
+ console.log("No knowledge directory found or error reading it:", err.message);
80
+ }
81
+
82
+ await fs.writeFile(SKILL_OUTPUT, skillMarkdown);
83
+
84
+ console.log('Build complete! Skills, Index, and monolithic SKILL.md generated.');
60
85
  }
61
86
 
62
87
  build().catch(console.error);
package/package.json CHANGED
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "name": "@mcpher/gas-fakes",
42
42
  "author": "bruce mcpherson",
43
- "version": "2.3.11",
43
+ "version": "2.3.13",
44
44
  "license": "MIT",
45
45
  "main": "main.js",
46
46
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
@@ -1,6 +1,6 @@
1
1
  import fs from "fs";
2
2
  import { parse } from "acorn";
3
- import { Auth } from "../support/auth.js"; // Relative to gas-fakes-cli directory
3
+ import { Auth, _identities } from "../support/auth.js"; // Relative to gas-fakes-cli directory
4
4
  import { checkForGcloudCli, spawnCommand } from "./utils.js";
5
5
 
6
6
  async function getAccessToken(pattern) {
@@ -8,12 +8,22 @@ async function getAccessToken(pattern) {
8
8
  // Authorization pattern 1
9
9
  // We use cloud-platform as it provides sufficient access for Drive API fetching
10
10
  // while avoiding the 'well-known client ID' block that targets Workspace scopes like drive.readonly.
11
- const auth = await Auth.setAuth(
11
+ const client = await Auth.setAuth(
12
12
  ["https://www.googleapis.com/auth/cloud-platform"],
13
13
  true
14
14
  );
15
- auth.cachedCredential = null;
16
- return await auth.getAccessToken();
15
+ if (client.cachedCredential !== undefined) {
16
+ client.cachedCredential = null;
17
+ }
18
+ const tokenResponse = await client.getAccessToken();
19
+ const token = tokenResponse.token || tokenResponse;
20
+
21
+ // CRITICAL: We MUST clear the global identities map so that this temporary
22
+ // library-fetching auth does not trick the downstream executor.js and syncit.js
23
+ // into thinking the full environment (and the background worker) has been initialized.
24
+ _identities.clear();
25
+
26
+ return token;
17
27
  } else {
18
28
  // Authorization pattern 2
19
29
  await checkForGcloudCli();
package/src/cli/setup.js CHANGED
@@ -622,9 +622,22 @@ export async function initializeConfiguration(options = {}) {
622
622
 
623
623
  if (skillResponse.installSkills) {
624
624
  console.log("Installing Gemini CLI skills and MCP server...");
625
+ let manualSkillCmd;
625
626
  try {
626
- // 1. Install the agent skill
627
- const skillCmd = "gemini skills install https://github.com/brucemcpherson/gas-fakes.git --path gf_agent";
627
+ // 1. Install or link the agent skill
628
+ let skillCmd;
629
+ const localSkillPath = path.resolve(process.cwd(), "gf_agent", "SKILL.md");
630
+ const isLocalClone = fs.existsSync(localSkillPath);
631
+
632
+ if (isLocalClone) {
633
+ console.log("Detected local gas-fakes repository. Linking local skill for development...");
634
+ skillCmd = "gemini skills link ./gf_agent";
635
+ manualSkillCmd = "1. gemini skills link ./gf_agent";
636
+ } else {
637
+ skillCmd = "gemini skills install https://github.com/brucemcpherson/gas-fakes.git --path gf_agent";
638
+ manualSkillCmd = "1. gemini skills install https://github.com/brucemcpherson/gas-fakes.git --path gf_agent";
639
+ }
640
+
628
641
  console.log(`Executing: ${skillCmd}`);
629
642
  execSync(skillCmd, { stdio: "inherit" });
630
643
 
@@ -635,11 +648,11 @@ export async function initializeConfiguration(options = {}) {
635
648
 
636
649
  console.log("\x1b[1;32mInstallation complete!\x1b[0m");
637
650
  console.log("\nYou can now use natural language to automate tasks:");
638
- console.log(" \x1b[1;33m\"Create a spreadsheet of my recent Drive files\"\x1b[0m");
651
+ console.log(" \x1b[1;33m\“Create a sheet called ‘Todays drive files' and add any files on Drive modified today to it\"\x1b[0m");
639
652
  } catch (err) {
640
653
  console.error(`\x1b[1;31mError during Gemini installation: ${err.message}\x1b[0m`);
641
654
  console.log("You may need to install them manually:");
642
- console.log("1. gemini skills install https://github.com/brucemcpherson/gas-fakes.git --path gf_agent");
655
+ console.log(manualSkillCmd);
643
656
  console.log("2. gemini mcp add --scope project gas-fakes-mcp gas-fakes mcp");
644
657
  }
645
658
  } else {
@@ -19,7 +19,12 @@ export const newFakeChartsApp = (...args) => {
19
19
  export class FakeChartsApp {
20
20
  constructor() {
21
21
  const enumProps = [
22
- "ChartType", // ChartType An enumeration of the possible chart types.
22
+ "ChartType", // ChartType An enumeration of the possible chart types.
23
+ "ChartHiddenDimensionStrategy",
24
+ "ChartMergeStrategy",
25
+ "CurveStyle",
26
+ "PointStyle",
27
+ "Position"
23
28
  ];
24
29
 
25
30
  // import all known enums as props of chartsapp
@@ -14,14 +14,33 @@ export const getFilesIterator = ({
14
14
  qob,
15
15
  parentId = null,
16
16
  folderTypes,
17
- fileTypes
17
+ fileTypes,
18
+ token
18
19
  }) => {
19
20
 
20
21
 
21
22
  // parentId can be null to search everywhere
22
23
  if (!is.null(parentId)) assert.nonEmptyString(parentId)
23
- assert.boolean(folderTypes)
24
- assert.boolean(fileTypes)
24
+ assert.boolean(folderTypes || !!token)
25
+ assert.boolean(fileTypes || !!token)
26
+
27
+ let state = {
28
+ qob,
29
+ parentId,
30
+ folderTypes,
31
+ fileTypes,
32
+ pageToken: null,
33
+ tank: []
34
+ }
35
+
36
+ if (token) {
37
+ const saved = JSON.parse(Buffer.from(token, 'base64').toString())
38
+ state = { ...state, ...saved }
39
+ qob = state.qob
40
+ parentId = state.parentId
41
+ folderTypes = state.folderTypes
42
+ fileTypes = state.fileTypes
43
+ }
25
44
 
26
45
  // DriveApp doesnt give option to specify these so this will be fixed
27
46
  const fields = `files(${minFields}),nextPageToken`
@@ -49,9 +68,9 @@ export const getFilesIterator = ({
49
68
  */
50
69
  function* filesink() {
51
70
  // the result tank
52
- let tank = []
71
+ let tank = [...state.tank]
53
72
  // the next page token
54
- let pageToken = null
73
+ let pageToken = state.pageToken
55
74
 
56
75
  do {
57
76
  // if nothing in the tank, fill it up
@@ -63,10 +82,14 @@ export const getFilesIterator = ({
63
82
  // format the results into the folder or file object
64
83
  assert.array(data.files)
65
84
  assert.function(DriveApp.__settleClass)
66
- tank = data.files.map(DriveApp.__settleClass)
85
+
86
+ // we store raw data in state for continuation
87
+ state.tank = data.files
88
+ tank = [...state.tank]
67
89
 
68
90
  // the presence of a nextPageToken is the signal that there's more to come
69
91
  pageToken = data.nextPageToken
92
+ state.pageToken = pageToken
70
93
 
71
94
  // if we still have nothing in the tank but there's a page token, keep going
72
95
  if (!tank.length && !pageToken) break;
@@ -74,7 +97,12 @@ export const getFilesIterator = ({
74
97
 
75
98
  // if we've got anything in the tank send back the oldest one
76
99
  if (tank.length) {
77
- yield tank.splice(0, 1)[0]
100
+ const raw = tank.splice(0, 1)[0]
101
+ state.tank.splice(0, 1)
102
+ yield {
103
+ __fakeResolved: DriveApp.__settleClass(raw),
104
+ __fakeRaw: raw
105
+ }
78
106
  }
79
107
 
80
108
  // if there's still anything left in the tank,
@@ -85,9 +113,26 @@ export const getFilesIterator = ({
85
113
  // create the iterator
86
114
  const fileit = filesink()
87
115
 
116
+ const continuationHandler = (peeked) => {
117
+ // if we've peeked, we need to make sure the peeked item is included in the tank
118
+ // because GAS continuation tokens should represent the state AFTER the last next() call.
119
+ // Peeker already called generator.next(), so the item to be returned by the NEXT next() call
120
+ // is in peeked.value.
121
+ const tank = [...state.tank]
122
+ if (peeked && !peeked.done) {
123
+ // Put the pre-fetched item back at the start of the tank for the token
124
+ tank.unshift(peeked.value.__fakeRaw || peeked.value)
125
+ }
126
+ const tokenState = {
127
+ ...state,
128
+ tank
129
+ }
130
+ return Buffer.from(JSON.stringify(tokenState)).toString('base64')
131
+ }
132
+
88
133
  // a regular iterator doesnt support the same methods
89
134
  // as Apps Script so we'll fake that too
90
- return newPeeker(fileit)
135
+ return newPeeker(fileit, continuationHandler)
91
136
 
92
137
  }
93
138
 
@@ -5,6 +5,8 @@ import { notYetImplemented, isFolder } from '../../support/helpers.js'
5
5
  import { Proxies } from '../../support/proxies.js'
6
6
  import { Utils } from '../../support/utils.js'
7
7
  import { Access, Permission } from '../enums/driveenums.js'
8
+ import { getFilesIterator } from './driveiterators.js'
9
+ import { Syncit } from '../../support/syncit.js'
8
10
  const { is } = Utils
9
11
 
10
12
 
@@ -19,6 +21,7 @@ export class FakeDriveApp {
19
21
  this.__rootFolder = null
20
22
  this.folderApp = newFakeFolderApp()
21
23
  this.__settleClass = (file) => isFolder(file) ? newFakeDriveFolder(file) : newFakeDriveFile(file)
24
+ this.__enforceSingleParent = true
22
25
  }
23
26
 
24
27
 
@@ -133,37 +136,68 @@ export class FakeDriveApp {
133
136
  return this.getRootFolder().createFolder(name)
134
137
  }
135
138
 
136
- //-- TODO ---
139
+ createShortcut(targetId, resourceKey) {
140
+ return this.getRootFolder().createShortcut(targetId, resourceKey)
141
+ }
137
142
 
138
- getFolderByIdAndResourceKey() {
139
- return notYetImplemented('getFolderByIdAndResourceKey')
143
+ createShortcutForTargetIdAndResourceKey(targetId, resourceKey) {
144
+ return this.getRootFolder().createShortcutForTargetIdAndResourceKey(targetId, resourceKey)
140
145
  }
141
- getFileByIdAndResourceKey() {
142
- return notYetImplemented('getFileByIdAndResourceKey')
146
+
147
+ getFolderByIdAndResourceKey(id, resourceKey) {
148
+ return this.getFolderById(id)
143
149
  }
144
150
 
145
- continueFileIterator() {
146
- return notYetImplemented('continueFileIterator')
151
+ getFileByIdAndResourceKey(id, resourceKey) {
152
+ return this.getFileById(id)
147
153
  }
148
- continueFolderIterator() {
149
- return notYetImplemented('continueFolderIterator')
154
+
155
+ continueFileIterator(token) {
156
+ return getFilesIterator({ token })
157
+ }
158
+
159
+ continueFolderIterator(token) {
160
+ return getFilesIterator({ token })
150
161
  }
162
+
151
163
  getTrashedFiles() {
152
- return notYetImplemented('getTrashedFiles')
164
+ return getFilesIterator({
165
+ folderTypes: false,
166
+ fileTypes: true,
167
+ qob: ['trashed = true']
168
+ })
153
169
  }
170
+
154
171
  getTrashedFolders() {
155
- return notYetImplemented('getTrashedFolders')
172
+ return getFilesIterator({
173
+ folderTypes: true,
174
+ fileTypes: false,
175
+ qob: ['trashed = true']
176
+ })
156
177
  }
157
178
 
158
179
  getStorageLimit() {
159
- return notYetImplemented('getStorageLimit')
180
+ const { data } = Syncit.fxDrive({
181
+ prop: 'about',
182
+ method: 'get',
183
+ params: { fields: 'storageQuota' }
184
+ })
185
+ return parseInt(data.storageQuota.limit, 10)
160
186
  }
187
+
161
188
  getStorageUsed() {
162
- return notYetImplemented('getStorageUsed')
189
+ const { data } = Syncit.fxDrive({
190
+ prop: 'about',
191
+ method: 'get',
192
+ params: { fields: 'storageQuota' }
193
+ })
194
+ return parseInt(data.storageQuota.usage, 10)
163
195
  }
164
- enforceSingleParent() {
165
- return notYetImplemented('enforceSingleParent')
196
+
197
+ enforceSingleParent(enabled) {
198
+ this.__enforceSingleParent = enabled
166
199
  }
200
+
167
201
  get Access() {
168
202
  return Access
169
203
  }
@@ -68,6 +68,96 @@ class FakeDriveFile extends FakeDriveMeta {
68
68
  return this.__getDecorated("webContentLink")
69
69
  }
70
70
 
71
+ /**
72
+ * get as a blob
73
+ * @param {string} contentType
74
+ * @returns {FakeBlob}
75
+ */
76
+ getAs(contentType) {
77
+ if (contentType === this.getMimeType()) {
78
+ return this.getBlob()
79
+ }
80
+ const result = Syncit.fxDriveExport({ id: this.getId(), mimeType: contentType })
81
+ if (result.error) {
82
+ // The error might be a string (from catch in sxStreamer) or an object
83
+ let isNotExportable = false;
84
+ let message = result.error;
85
+ if (typeof result.error === 'string' && result.error.startsWith('{')) {
86
+ try {
87
+ const parsed = JSON.parse(result.error);
88
+ isNotExportable = parsed.error?.errors?.[0]?.reason === 'fileNotExportable' ||
89
+ parsed.error?.reason === 'fileNotExportable';
90
+ message = parsed.error?.message || parsed.message || result.error;
91
+ } catch (e) {
92
+ // ignore
93
+ }
94
+ } else if (typeof result.error === 'object') {
95
+ isNotExportable = result.error.error?.errors?.[0]?.reason === 'fileNotExportable' ||
96
+ result.error.errors?.[0]?.reason === 'fileNotExportable' ||
97
+ result.error.error?.reason === 'fileNotExportable' ||
98
+ result.error.reason === 'fileNotExportable';
99
+ message = result.error.error?.message || result.error.message || JSON.stringify(result.error);
100
+ }
101
+
102
+ // Live GAS automatically handles exporting plain text/images to PDF etc.
103
+ // The REST API doesn't support this directly. We workaround it by
104
+ // temporarily converting the file to a Google Doc, exporting that, and trashing it.
105
+ if (isNotExportable) {
106
+ let targetMimeType = 'application/vnd.google-apps.document';
107
+ const currentMime = this.getMimeType();
108
+ if (currentMime === 'text/csv' || currentMime === 'text/tab-separated-values' || currentMime === 'application/vnd.ms-excel' || currentMime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
109
+ targetMimeType = 'application/vnd.google-apps.spreadsheet';
110
+ } else if (currentMime === 'application/vnd.ms-powerpoint' || currentMime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
111
+ targetMimeType = 'application/vnd.google-apps.presentation';
112
+ }
113
+
114
+ try {
115
+ // 1. Copy to temp Google Doc format
116
+ const copyResult = Syncit.fxDrive({
117
+ prop: 'files',
118
+ method: 'copy',
119
+ params: {
120
+ fileId: this.getId(),
121
+ resource: {
122
+ mimeType: targetMimeType,
123
+ name: `Temp_gasfakes_conversion_${this.getName()}`
124
+ }
125
+ }
126
+ });
127
+
128
+ if (!copyResult.data || !copyResult.data.id) {
129
+ throw new Error(`Failed to copy to intermediate format: ${JSON.stringify(copyResult.response)}`);
130
+ }
131
+ const tempFileId = copyResult.data.id;
132
+
133
+ // 2. Export the temp file
134
+ const tempExportResult = Syncit.fxDriveExport({ id: tempFileId, mimeType: contentType });
135
+
136
+ // 3. Delete the temp file
137
+ Syncit.fxDrive({
138
+ prop: 'files',
139
+ method: 'update',
140
+ params: {
141
+ fileId: tempFileId,
142
+ resource: { trashed: true }
143
+ }
144
+ });
145
+
146
+ if (tempExportResult.error) {
147
+ throw new Error(tempExportResult.error.error?.message || tempExportResult.error.message || JSON.stringify(tempExportResult.error));
148
+ }
149
+
150
+ return Utilities.newBlob(tempExportResult.data, contentType, this.getName());
151
+ } catch (workaroundError) {
152
+ throw new Error(`getAs API returned: ${message}. Then, a temporary two-step conversion workaround failed: ${workaroundError.message}`);
153
+ }
154
+ }
155
+
156
+ throw new Error(message)
157
+ }
158
+ return Utilities.newBlob(result.data, contentType, this.getName())
159
+ }
160
+
71
161
  /**
72
162
  * set the content to something else
73
163
  * @param {string} content apparently this can only be a string and not a blob
@@ -151,6 +241,21 @@ class FakeDriveFile extends FakeDriveMeta {
151
241
  return newFakeDriveFile(data);
152
242
  }
153
243
 
244
+ getTargetId() {
245
+ this.__decorateWithFields("shortcutDetails")
246
+ return this.meta.shortcutDetails?.targetId || null
247
+ }
248
+
249
+ getTargetMimeType() {
250
+ this.__decorateWithFields("shortcutDetails")
251
+ return this.meta.shortcutDetails?.targetMimeType || null
252
+ }
253
+
254
+ getTargetResourceKey() {
255
+ this.__decorateWithFields("shortcutDetails")
256
+ return this.meta.shortcutDetails?.targetResourceKey || null
257
+ }
258
+
154
259
  }
155
260
 
156
261
  /**
@@ -58,6 +58,14 @@ export class FakeDriveFolder extends FakeDriveMeta {
58
58
  })
59
59
  }
60
60
 
61
+ createShortcut(targetId, resourceKey) {
62
+ return this.folderApp.createShortcut({ targetId, resourceKey, file: { parents: [this.getId()] } })
63
+ }
64
+
65
+ createShortcutForTargetIdAndResourceKey(targetId, resourceKey) {
66
+ return this.folderApp.createShortcutForTargetIdAndResourceKey({ targetId, resourceKey, file: { parents: [this.getId()] } })
67
+ }
68
+
61
69
  /**
62
70
  * get files in this folder
63
71
  * @return {FakeDriveFileIterator}