diff --git a/CHANGELOG.md b/CHANGELOG.md index 7445c75..b428514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,11 @@ | Id | Type | Description | State | CreateTime | EndTime | BatchEECU | | --- | --- | --- | --- | --- | --- | --- | - - Provides "EE Tasks: update gcloud accounts list". Only required after using `gcloud` to authenticate additional users. This is done automatically the first time the extension is activated (if `gcloud` is available). \ No newline at end of file + - Provides "EE Tasks: update gcloud accounts list". Only required after using `gcloud` to authenticate additional users. This is done automatically the first time the extension is activated (if `gcloud` is available). + +## v0.1.0 + +- Major internal changes to improve performance +- New feature: run a GEE script from vscode! see documentation [here](https://github.com/gee-community/eetasks/blob/main/docs/runGEEscripts.md) +- Fixed a [bug](https://github.com/gee-community/eetasks/issues/1) that prevented the EndTime and BatchEECU columns to display. +- Renamed the `EE Tasks: update gcloud accounts list` command to `EE Tasks: update available accounts`. This reflects an implementation change that simplifies and groups all accounts (gcloud and "earthengine"). \ No newline at end of file diff --git a/README.md b/README.md index c294ee6..3afd971 100644 --- a/README.md +++ b/README.md @@ -4,43 +4,60 @@ An extension for monitoring Earth Engine tasks. ## Features -![eetasks-readme](https://raw.githubusercontent.com/lopezvoliver/eetasks/main/eetasks-readme.gif) +### EE Tasks: view tasks + +![eetasks-readme](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/eetasks-readme.gif) Open a table view of Earth Engine tasks for a user or service account. +### EE Tasks: run GEE script + +![eetasks-readme](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/geerunExample.gif) + +Run GEE code from within vscode! Learn more about what is [currently supported here](https://github.com/gee-community/eetasks/blob/main/docs/runGEEscripts.md). + +### EE Tasks: update available accounts + +Use this command if you expect to use this extension for [multiple accounts](#multi-account-views). The command is run automatically upon first use of either the [view tasks](#ee-tasks-view) or [run gee script](#ee-tasks-run-gee-script) commands. + ## Requirements A [Google Earth Engine account](https://code.earthengine.google.com/register) is required to use the Earth Engine Tasks Manager extension. Additionally, either the [Earth Engine command line tool](https://developers.google.com/earth-engine/guides/command_line) (eecli) or the Earth Engine [python client library](https://developers.google.com/earth-engine/guides/python_install) must have been used to authenticate an account. If you are familiar with the [gcloud cli](https://cloud.google.com/sdk/docs/install) and have already [authenticated](https://cloud.google.com/sdk/gcloud/reference/auth/login) an earth engine account, you can also use `gcloud` to authenticate the Earth Engine library for this extension. ### Multi-account views -![eetasks-multi](https://raw.githubusercontent.com/lopezvoliver/eetasks/main/eetasks-multi.png) - -Multiple panels may be opened to view the tasks for different users. +![eetasks-multi](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/eetasks-multi.png) +Multiple panels may be opened to view the tasks for different accounts. #### Interactive account selection -![eetasks-users](https://raw.githubusercontent.com/lopezvoliver/eetasks/main/eetasks-users.png) +The available user accounts are populated using the [EE Tasks: update available accounts](#ee-tasks-update-available-accounts) command at any time. This command is automatically called upon the first time you run either the [EE Tasks: view tasks](#ee-tasks-view-tasks) or the [EE Tasks: run GEE script](#ee-tasks-run-gee-script) commads. -Use the `EE Tasks: view tasks` command to interactively select a user account. +![eetasks-users](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/eetasks-users.png) -The `earthengine` account refers to the credentials stored and managed by the earthengine Python API. The EE tasks extensions will never modify these credentials. +The `earthengine` account refers to the credentials stored and managed by the earthengine Python API. The EE Tasks extension will not modify these credentials. -The other accounts shown (if any) are managed by the `gcloud cli` (as well as the earthengine cli through `gcloud`). You can use `gcloud auth login` to add an additional user, and `gcloud auth list` to display the accounts. The first time the EE tasks extension is activated, it will look for these accounts. If you have updated the credentialed accounts with `gcloud`, update them in the extension by using the `EE tasks: update gcloud accounts list`. +The `application-default` account refers to the +[application default credentials](https://cloud.google.com/sdk/gcloud/reference/auth/application-default) in `gcloud`. + +The other accounts shown (if any) are managed by the `gcloud cli` (as well as the earthengine cli through `gcloud`). You can use `gcloud auth login` to add an additional user, and `gcloud auth list` to display the accounts. If you update the credentialed accounts with `gcloud`, update them in the extension by using the [EE Tasks: update available accounts](#ee-tasks-update-available-accounts). When selecting an account other than `earthengine`, you will also be prompted to specify a project to use. #### Default account -![eetasks-default](https://raw.githubusercontent.com/lopezvoliver/eetasks/main/eetasks-default.png) +![eetasks-default](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/eetasks-default.png) + +You may also specify a default account and project to use with the `EE tasks: view tasks (default account)`. You may specify any of the available accounts. -You may also specify a default account and project to use with the `EE tasks: view tasks (defaut account)`. Set the default account to `earthengine` to use the credentials managed by the eecli or python library, in which case there is no need to specify a project. You may also specify `application-default` to use the [application default credentials](https://cloud.google.com/sdk/gcloud/reference/auth/application-default). If the project is left blank and the account is not `earthengine`, you will be prompted for it. If the account is blank, you will prompted for it. Specifying an account that is not any of `earthengine`, `application-default`, or in the `gcloud auth list` will result in an error. +If the project is left blank and the account is not `earthengine`, you will be prompted for it. If the account is blank, you will prompted for it. Specifying an account that is not any of the available accounts will not work. #### Service account (advanced use) -Use the `EE tasks: view tasks (service account)` to view the tasks associated to a [service account](https://developers.google.com/earth-engine/guides/service_account). You wil be prompted to select a `json` file ([see the animation](#features) above). +Use the `EE Tasks: view tasks (service account)` to view the tasks associated to a [service account](https://developers.google.com/earth-engine/guides/service_account). You wil be prompted to select a `json` file ([see the animation](#features) above). +There is also a variation of the [EE Tasks: run GEE script](#ee-tasks-run-gee-script) command to use a service account instead of a user account. ## Extension Settings @@ -55,6 +72,6 @@ This extension contributes the following settings: ## Known Issues -- Tested in linux and windows. - The tasks tables do not refresh automatically. However, you can use the refresh button (🔄) to update the table on demand. -- The gcloud accounts lists is retrieved automatically only the first time the extension is activated. However, you can use the `EE tasks: update gcloud accounts list` to update it. +- The intended use for the `EE Tasks: run GEE script` is lmited and currently experimental (recommended for experienced users only). [Learn more about it here](https://github.com/gee-community/eetasks/blob/main/docs/runGEEscripts.md). + - An unknown cause for an issue prevents the use of synchronous calls to some `ee` functions in Windows. Learn more about it [here](https://github.com/gee-community/eetasks/blob/main/docs/runGEEscripts.md#caveat-for-windows-users) \ No newline at end of file diff --git a/docs/assets/ExportTableSuccessAndFail.png b/docs/assets/ExportTableSuccessAndFail.png new file mode 100644 index 0000000..e68eb2a Binary files /dev/null and b/docs/assets/ExportTableSuccessAndFail.png differ diff --git a/docs/assets/ExportTableTaskCompleted.png b/docs/assets/ExportTableTaskCompleted.png new file mode 100644 index 0000000..417d646 Binary files /dev/null and b/docs/assets/ExportTableTaskCompleted.png differ diff --git a/eetasks-default.png b/docs/assets/eetasks-default.png old mode 100755 new mode 100644 similarity index 100% rename from eetasks-default.png rename to docs/assets/eetasks-default.png diff --git a/eetasks-multi.png b/docs/assets/eetasks-multi.png old mode 100755 new mode 100644 similarity index 100% rename from eetasks-multi.png rename to docs/assets/eetasks-multi.png diff --git a/eetasks-readme.gif b/docs/assets/eetasks-readme.gif old mode 100755 new mode 100644 similarity index 100% rename from eetasks-readme.gif rename to docs/assets/eetasks-readme.gif diff --git a/docs/assets/eetasks-users.png b/docs/assets/eetasks-users.png new file mode 100644 index 0000000..a827b64 Binary files /dev/null and b/docs/assets/eetasks-users.png differ diff --git a/docs/assets/geerunExample.gif b/docs/assets/geerunExample.gif new file mode 100644 index 0000000..aec3868 Binary files /dev/null and b/docs/assets/geerunExample.gif differ diff --git a/docs/assets/helloGEE-log.png b/docs/assets/helloGEE-log.png new file mode 100644 index 0000000..40a672a Binary files /dev/null and b/docs/assets/helloGEE-log.png differ diff --git a/docs/assets/helloGEE-syntaxError.png b/docs/assets/helloGEE-syntaxError.png new file mode 100644 index 0000000..df7db71 Binary files /dev/null and b/docs/assets/helloGEE-syntaxError.png differ diff --git a/docs/assets/helloGEE.PNG b/docs/assets/helloGEE.PNG new file mode 100644 index 0000000..05ecf5d Binary files /dev/null and b/docs/assets/helloGEE.PNG differ diff --git a/docs/assets/print.png b/docs/assets/print.png new file mode 100644 index 0000000..ee86408 Binary files /dev/null and b/docs/assets/print.png differ diff --git a/docs/assets/require.png b/docs/assets/require.png new file mode 100644 index 0000000..3d184c4 Binary files /dev/null and b/docs/assets/require.png differ diff --git a/docs/assets/silentlyIgnored.png b/docs/assets/silentlyIgnored.png new file mode 100644 index 0000000..92768a2 Binary files /dev/null and b/docs/assets/silentlyIgnored.png differ diff --git a/docs/runGEEscripts.md b/docs/runGEEscripts.md new file mode 100644 index 0000000..a9efd8a --- /dev/null +++ b/docs/runGEEscripts.md @@ -0,0 +1,112 @@ +# Run GEE scripts from vscode! + +## Hello world + +Use the `EE Tasks: run GEE script` command to run the GEE script open in the Editor using a user account/project. For example: + +![hellogee](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/helloGEE.PNG) + + +> The `EE Tasks: run GEE script (service account)` is also available to run scripts using a valid private key (JSON file). + +upon picking an available account (and project, if required), the script is started. If successful, the output will open in a channel called "EE Tasks: GEE script runs": + +![hellogee-log](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/helloGEE-log.png) + +## GEE script definition + +For the purpose of this document, a "GEE script" is a `.js` file that is able to run in the [Earth Engine Code Editor](https://developers.google.com/earth-engine/guides/playground). + +> ❗ Not all GEE scripts can be run here. More details will be given below. + +How does the [hello-world](#hello-world) example above work? Evidently, running the same script directly in [node](https://nodejs.org/en/) will not work: + +```bash +$ node helloGEE.js +C:\Users\lopezom\helloGEE.js:1 +ee.String("Hello world!") +^ + +ReferenceError: ee is not defined +``` + +Internally, the EE Tasks extension wraps your code in a function that handles the initialization of ee, as well as providing *some* of the extra features in the [Earth Engine Code Editor](https://developers.google.com/earth-engine/guides/playground), including [print](https://developers.google.com/earth-engine/apidocs/print) and `Export` (e.g. [Export.table.toDrive](https://developers.google.com/earth-engine/apidocs/export-table-todrive)). See more details of what features are [included](#features) and which ones are [excluded](#excluded-features). + + +## Use cases + +Why would I want to run a GEE script in vscode? Short answer: [just because I can](https://i.kym-cdn.com/entries/icons/original/000/040/653/goldblum-quote.jpeg). Kidding aside, the goal here is to provide a quick way for developers to test short, simple code, and to submit Export tasks without leaving vscode. + +> ❗ Use of `EE Tasks: run GEE script` is experimental and recommended for experienced GEE developers only. Not following [GEE coding best practices](https://developers.google.com/earth-engine/guides/best_practices) might lead to the extension crashing (e.g. the equivalent of [browser lock](https://developers.google.com/earth-engine/guides/debugging#browser-lock)). However, it's not the end of world, as a simple "Reload window" should get the extension running back to normal. + +However, simple client-side errors will be catched: + +![hellogee-log](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/helloGEE-syntaxError.png) + +> ❗ See another [caveat for Windows users here](#caveat-for-windows-users). + +## Not recommended use cases + +Outside of the specific use-cases defined above, **it is not recommended to run GEE scripts using the EE Tasks extension**. The [Code Editor](https://code.earthengine.google.com) or [geemap](https://geemap.org) are definitely the right tools for exploratory analyses, debugging, etc. + +## Features + +### `print` + +`print` somewhat mirrors the functionality of [print](https://developers.google.com/earth-engine/apidocs/print) in the Code Editor: + +> ⚠️ An important difference is that when objects that have a `getInfo` get printed, the operation is performed asynchronously, so the order is not guaranteed ❗ + +![ExportTableSuccessFail](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/print.png) + +> ⚠️ In windows, do not use the `.getInfo` methods directly. [Here's why](#caveat-for-windows-users). In linux, this shouldn't be an issue. + +### `Export` + +All `Export` features mirror the functionality from the [Code Editor](https://developers.google.com/earth-engine/guides/debugging#browser-lock) (in fact, they [wrap](https://github.com/gee-community/eetasks/blob/main/src/utilities/codeEditorUtils.js) the `ee.batch.Export*` methods). However, + +> ⚠️ In contrast to the Code Editor, tasks will be automatically submitted. + +For example: + +![ExportTableSuccessFail](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/ExportTableSuccessAndFail.png) + +> ⚠️ Another contrast to the Code Editor can be seen in the example above. Some parameters such as `description`, `fileNamePrefix`, `assetId`, etc. are either given default values (e.g. "myExportTableTask") in the Code Editor, or are prompted from the user after clicking "Run". Here, the task fails to start if they are not provided explicitly. + +We can then use the `EE Tasks: view` command to monitor the task: + +![ExportTableTaskCompleted](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/ExportTableTaskCompleted.png) + +We can see that the task completed, demonstrating task submission *and* monitoring from vscode! + +## Excluded features + +### `Map`, `Chart`, and `ui` + +These are not supported here. However, lines of code using these features are silently ignored, so there is no need to exclude them: + +![silentlyIgnored](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/silentlyIgnored.png) + +Support for `Chart` and `Map` *may* be developed in the future. + +### [require](https://developers.google.com/earth-engine/apicods/require) + +Importing a module directly from GEE (e.g. `require("users/homeFolder/repo:path/to/file)`) is not currently supported, and is unlikely to be developed soon. However, in some very *limited and specific* cases, one workaround is to (1) clone the git repository ([see how to here](https://gis.stackexchange.com/a/315134/67301)), and (2) change the line of code to use the absolute path to the local file instead. For example: + +![require](https://raw.githubusercontent.com/gee-community/eetasks/main/docs/assets/require.png) + +This silly example demonstrates the use of require with a local file. However, note that the `clonedGEErepo/test.js` file does not have access to `ee`, `print`, `Export`, etc.. so the script will fail if it attempts to access them. + +The example above works because the function `addOne` does not use any of these, but it does allow using the methods within the objects passed as arguments (e.g. the `.add` method from the passed argument `ee.Number(1)`). A workaround would be to modify the functions to use within the module to require so that they allow passing `ee` (or other features to use) as arguments. + +However.. if you are using `require` you are probably already doing something more complicated than the [recommended use cases](#use-cases) for `EE Tasks: run GEE script`. + +## Caveat for windows users + +For some unknown reason, synchronous calls to some `ee` methods (e.g., most notably `getInfo`) do not work when running from vscode. Even stranger, this issue only occurs in windows. This is unlikely to be a bug with `ee` itself, as running the same code directly in `node` does work in Windows, so the issue is isolated to within vscode. + +See more details in the [question](https://stackoverflow.com/questions/77436205/synchronous-function-call-to-external-library-within-vscode-freezes-only-in-wind) posted to StackOverflow, and if you have some insight please share it. + +What happens when I use `getInfo` (or other method that accepts a callback function) synchronously through a GEE script in `EE Tasks: run GEE script` in windows? + +Nothing really.. the error is not catched properly (there will be no message showing that the script run failed), but any other call to a `EE Tasks` extension will not work. A simple `Developer: Reload Window` will get the extension working back to normal. \ No newline at end of file diff --git a/eetasks-users.png b/eetasks-users.png deleted file mode 100755 index cc5cffd..0000000 Binary files a/eetasks-users.png and /dev/null differ diff --git a/package.json b/package.json index 0cec215..2f36c6e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,14 @@ "main": "./out/extension.js", "contributes": { "commands": [ + { + "command": "eetasks.run", + "title": "EE Tasks: run GEE script" + }, + { + "command": "eetasks.runAsServiceAccount", + "title": "EE Tasks: run GEE script (service account)" + }, { "command": "eetasks.open", "title": "EE Tasks: view tasks" @@ -31,8 +39,8 @@ "title": "EE Tasks: view tasks (service account)" }, { - "command": "eetasks.updateGcloudAccountsList", - "title": "EE Tasks: update gcloud accounts list" + "command": "eetasks.updateUserAccounts", + "title": "EE Tasks: update available accounts" } ], "configuration": { diff --git a/src/extension.ts b/src/extension.ts index 6c48ae6..ec9ba22 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,278 +2,73 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; import { EETasksPanel } from "./panels/EETasksPanel"; -import fs = require("fs"); -import os = require("os"); -import cp = require("child_process"); -import path = require("path"); - -function isStringArray(value: unknown): value is string[]{ - //https://stackoverflow.com/questions/49813443/type-guards-for-types-of-arrays - if (!Array.isArray(value)){ - return false; - } - if (value.some((v)=> typeof v !=="string")){ - return false; - } - return true; -} - -function _lookForCachedGcloudAccounts(context: vscode.ExtensionContext){ - let result: string[] = []; - let cachedGcloudAccounts = context.globalState.get("gcloudAccounts"); - console.log("Cached gcloud accounts: " + cachedGcloudAccounts); - if(cachedGcloudAccounts && isStringArray(cachedGcloudAccounts)){ - cachedGcloudAccounts.forEach((element:string)=>result.push(element)); - } - return result; -} - - -function _getGcloudAccounts(context: vscode.ExtensionContext){ - vscode.window.showInformationMessage("Looking for gcloud accounts."); - let result = cp.spawnSync("gcloud auth list --format=\"value(account)\"", {shell:true}); - let acc: string[] = []; - if (result.status===0){ - let gacc = result.stdout.toString().split(os.EOL); - gacc.forEach((element:string)=>{ - if (element.length>0){ - acc.push(element); - } - }); - context.globalState.update("gcloudAccounts", acc); - vscode.window.showInformationMessage("Updated gcloud accounts cache."); - }else{ - vscode.window.showErrorMessage("No gcloud accounts found. \n " - + " gcloud error message: " + result.stderr); - } - return acc; - } - - function _which(cli:string){ - let command; - if(os.platform()==="win32"){ - command = "where " + cli; - // Assuming cmd.exe by default.. caveat: - // https://nodejs.org/api/child_process.html#default-windows-shell - }else{ - command = "which " + cli; - } - let result = cp.spawnSync(command, {shell:true}); - return !result.status; - } - - function _eeCredentialsExist(){ - const homedir = os.homedir(); - const credentialsFile = path.join(homedir, ".config", "earthengine", "credentials"); - if (fs.existsSync(credentialsFile)){ - return true; - }else{ - return false; - } - } - - -function promptProject(context: vscode.ExtensionContext, account:string){ - // If "earthengine", project is not required. - if(account==="earthengine"){ - EETasksPanel.render(context.extensionUri, context.globalState, account.trim(), null); - }else{ - vscode.window.showInputBox({ - title: "Select a project to use.", - prompt: "Example: earthengine-legacy" - }) - .then(function(project){ - if(project){ - EETasksPanel.render(context.extensionUri, context.globalState, account.trim(), project.trim()); - } - }); - } -} +import { updateAccounts, promptProject, + pickAccount, pickServiceAccount } from './utilities/accountPicker'; +import { scriptRunnerAsAccount,scriptRunnerAsServiceAccount } from './utilities/scriptRunners'; // This method is called when your extension is activated // Your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { - - const updateGcloudAccountsList = vscode.commands.registerCommand("eetasks.updateGcloudAccountsList", ()=>{ - let _hasGcloud = _which("gcloud"); - if(!_hasGcloud){ - vscode.window.showErrorMessage("Gcloud not detected. Cannot update gcloud accounts list."); - return; - } - _getGcloudAccounts(context); - }); - + let scriptLog = vscode.window.createOutputChannel("EE Tasks: GEE script runs"); + const updateAccountsCommand = vscode.commands.registerCommand("eetasks.updateUserAccounts",()=>{ + vscode.window.showInformationMessage("Looking for available accounts."); + updateAccounts(context); + }); const openTasksViaPrivateKey = vscode.commands.registerCommand("eetasks.openViaPrivateKey", ()=>{ - // Prompt user for private key (should be json file) - vscode.window.showOpenDialog({ - filters:{"json": ['json', 'JSON']} - }).then((fileUri)=>{ - if(fileUri){ - // Read the JSON file - var fs = require("fs"); - let credentials = JSON.parse(fs.readFileSync(fileUri[0].fsPath, "utf8").toString()); - // Validation: should have at least: - if (credentials.hasOwnProperty("client_id") && - credentials.hasOwnProperty("project_id") && - credentials.hasOwnProperty("private_key")){ - - // Open task panel with private key. - EETasksPanel.render(context.extensionUri, context.globalState, - "service-account", - credentials.project_id, - credentials); - - }else{ - vscode.window.showErrorMessage("The file selected is not a valid service account private key."); - } - } + pickServiceAccount() + .then((credentials:any|undefined)=>{ + if(credentials){ + EETasksPanel.render( + "service-account", + credentials.project_id, + context, + credentials); + } }); }); const openDefault = vscode.commands.registerCommand("eetasks.openDefault", ()=>{ - // Get the default values. let conf = vscode.workspace.getConfiguration("eetasks"); let defaultProject = conf.defaultProject; let defaultAccount = conf.defaultAccount; - let _hasEECred = _eeCredentialsExist(); - let _hasGcloud = _which("gcloud"); - - // If none are set, then just do the eetasks.open command: - if(!defaultAccount && !defaultProject){ - openMain(); - return; - } - - if(defaultAccount==="earthengine"){ - if (! _hasEECred){ - vscode.window.showErrorMessage("EE credentials not found" - +" (~/.config/earthengine/credentials)." - + "Cannot use the Earth Engine Task Manager extension with defaultAccount" - + " set to \"earthengine\""); - return; - } + if (! defaultProject){ defaultProject = null;} + if (defaultAccount){ + // prompProject handles whether to prompt or not the project + // e.g. not needed if account is "earthengine", or if + // already set. + promptProject(defaultAccount.trim(), defaultProject, EETasksPanel.render, context); }else{ - if (! _hasGcloud){ - vscode.window.showErrorMessage("Gcloud not found. " - + "Cannot use the Earth Engine Task Manager extension with defaultAccount" - + " set to a user account. Try with account set to \"earthengine\""); - return; - } + pickAccount(defaultProject, context, EETasksPanel.render, context); } - - if(defaultAccount && defaultProject){ - EETasksPanel.render(context.extensionUri, context.globalState, defaultAccount.trim(), defaultProject.trim()); - return; - }else{ - // Either one of them is missing, prompt for the other one: - if(defaultAccount){ - // Prompt for project, then call. - promptProject(context, defaultAccount); - return; - } - - if(defaultProject){ - // Prompt for account, then call. - - let defaultAccounts = availableAccounts(_hasEECred, _hasGcloud); - // If just one account found, there is no need for quickpick. - if(defaultAccounts.length===1){ - EETasksPanel.render(context.extensionUri, context.globalState, defaultAccounts[0], defaultProject.trim()); - return; - } - - if(defaultAccounts.length<1){ - vscode.window.showErrorMessage("No accounts found for gcloud. Use gcloud to login and then " - + "use the \"EE tasks: update gcloud accounts list\" command."); - return; - } - - // Quickpick values (account only) - vscode.window.showQuickPick(defaultAccounts, - {title: "Select account to use."} - ) - .then(function(account){ - if(account){ - EETasksPanel.render(context.extensionUri, context.globalState, account, defaultProject.trim()); - } - }); - } - } + return; } ); - function availableAccounts(_hasEECred: boolean, _hasGcloud:boolean){ - let gcloudAccounts: string[] = []; // Might be empty (length === 0 ) - if (_hasGcloud){ - // Are there any gcloud accounts stored in the extension's cache? - // User may update the list manually using the - // eetasks.updateGcloudAccountsList command. - let cachedGcloudAccounts = _lookForCachedGcloudAccounts(context); - - if(cachedGcloudAccounts.length<1){ - gcloudAccounts = _getGcloudAccounts(context); - }else{ - gcloudAccounts = cachedGcloudAccounts; - } - } - - let defaultAccounts:string[] = []; - - if(_hasEECred){ - defaultAccounts = defaultAccounts.concat(["earthengine"]); - } - - if(gcloudAccounts.length>0){ - defaultAccounts = defaultAccounts.concat(gcloudAccounts); - } - - return defaultAccounts; - - - } - - function openMain(){ - // Do we have gcloud (optional)? - let _hasGcloud = _which("gcloud"); - // Do we have access to ~/.config/earthengine/credentials? (optional..) - let _hasEECred = _eeCredentialsExist(); // - // At least one of them must be true to use the extension. - if (!_hasEECred && !_hasGcloud){ - vscode.window.showErrorMessage("EE credentials not found" - +" (~/.config/earthengine/credentials) and gcloud was not detected. " - + "Cannot use the Earth Engine Task Manager extension."); - return; - } - - let defaultAccounts = availableAccounts(_hasEECred, _hasGcloud); + const openTasksTabCommand = vscode.commands.registerCommand("eetasks.open",()=>{ + pickAccount(null, context, EETasksPanel.render, context); + }); - // If just one account found, there is no need for quickpick. - if(defaultAccounts.length===1){ - promptProject(context,defaultAccounts[0]); - return; - } - if(defaultAccounts.length<1){ - vscode.window.showErrorMessage("No accounts found for gcloud. Use gcloud to login and then " - + "use the \"EE tasks: update gcloud accounts list\" command."); - return; - } + const runScriptCommand = vscode.commands.registerCommand('eetasks.run', ()=>{ + pickAccount(null, context, scriptRunnerAsAccount, context, scriptLog); + }); - // Quickpick values (account + project) - vscode.window.showQuickPick(defaultAccounts, - {title: "Select account to use."} - ) - .then(function(account){ - if(account){ - promptProject(context, account); - } + const runScriptAsServiceAccountCommand = vscode.commands.registerCommand( + 'eetasks.runAsServiceAccount', async()=>{ + pickServiceAccount() + .then((credentials:any|undefined)=>{ + if(credentials){ + scriptRunnerAsServiceAccount(credentials, scriptLog); + } }); - } + }); - const openTasksTabCommand = vscode.commands.registerCommand("eetasks.open",openMain); context.subscriptions.push(openTasksTabCommand); context.subscriptions.push(openDefault); context.subscriptions.push(openTasksViaPrivateKey); - context.subscriptions.push(updateGcloudAccountsList); + context.subscriptions.push(updateAccountsCommand); + context.subscriptions.push(runScriptCommand); + context.subscriptions.push(runScriptAsServiceAccountCommand); + } // This method is called when your extension is deactivated diff --git a/src/panels/EETasksPanel.ts b/src/panels/EETasksPanel.ts index 1620dd6..09cac05 100644 --- a/src/panels/EETasksPanel.ts +++ b/src/panels/EETasksPanel.ts @@ -165,8 +165,11 @@ export class EETasksPanel { https://github.com/microsoft/vscode-webview-ui-toolkit/blob/main/docs/getting-started.md */ - public static render(extensionUri: vscode.Uri, extensionState: any, account: string, project: string | null, + public static render(account: string, project: string | null, + context: vscode.ExtensionContext, privateKey?: any) { + let extensionUri = context.extensionUri; + let extensionState = context.globalState; let panelName = "EE Tasks: " + account; if(project){ diff --git a/src/utilities/accountPicker.ts b/src/utilities/accountPicker.ts new file mode 100644 index 0000000..522ad9a --- /dev/null +++ b/src/utilities/accountPicker.ts @@ -0,0 +1,197 @@ +/* +Handles account selection. +*/ +import * as vscode from 'vscode'; +import os = require("os"); +import path = require("path"); +import { exec } from 'child_process'; + +/* +Returns the ~/.config/earthengine/credentials file* +and returns the credenitials if they exist, +otherwise returns undefined. +*This file is stored and managed by the python +earthengine API. This extension will not modify it. +*/ +export async function getEECredentials(){ + let homedir = os.homedir(); + let credentialsFile = path.join(homedir, ".config", "earthengine", "credentials"); + let cUri = vscode.Uri.file(credentialsFile); + let credentials:any | undefined; + try{ + credentials = JSON.parse( + (await vscode.workspace.fs.readFile(cUri)).toString()); + credentials.grant_type="refresh_token"; + }catch(e){ + credentials = undefined; + } + return credentials; +} + +/* +Tests whether we can run gcloud. +*/ +export async function testGcloud(){ +return new Promise((resolve)=>{ + exec("gcloud --version", (err:any) => { + if (err) {resolve(false);} + resolve(true); + }); +}); +} + +/* +Run command and resolve with result or reject(error). +*/ +export async function runCmd(cmd:string){ +return new Promise((resolve, reject)=>{ + exec(cmd, (err:any, stdout:string) => { + if (err) {reject(err);} + resolve(stdout.trim()); + }); +}); +} + +/* +Gets the list of available gcloud accounts (if any) +*/ +export async function getGcloudAccounts(){ + const gcommand = "gcloud auth list --format=\"value(account)\""; + let accounts: string[] = []; + try{ + let result = await runCmd(gcommand); + if (typeof result ==="string"){ + accounts = result.split(os.EOL); + if(accounts.length>0){ + return accounts; + } + return undefined; + } + }catch(e){ + return undefined; + } +} + +export async function updateAccounts(context: vscode.ExtensionContext){ + const eeCredentials = await getEECredentials(); + const canRunGcloud = await testGcloud(); + let accounts: any = {}; // accountName:token|null + + if ((! canRunGcloud) && (! eeCredentials)){ + vscode.window.showErrorMessage("EE credentials not found, and gcloud is not available."); + return undefined; + } + + if(eeCredentials){ + accounts["earthengine"] = null; + } + + if(canRunGcloud){ + const gcloudAccounts= await getGcloudAccounts(); + if (gcloudAccounts){ + accounts["application-default"] = null; + gcloudAccounts.forEach((x)=>{ + accounts[x] = null; + }); + } + } + context.globalState.update("userAccounts", accounts); + vscode.window.showInformationMessage("Updated user accounts."); + return accounts; +} + +/* +Picks an account and prompts for project* + +If there is only one account available, then there is no need to pick. + +*project not needed if account picked is "earthengine" +Project may also be provided, in which case only the account +is picked. + +Finally, calls the callback function using the picked account, project +and extra arguments +*/ +export async function pickAccount(project: string | null, context: vscode.ExtensionContext, callback:any, ...args: any[] | undefined[]){ + let accounts:any = context.globalState.get("userAccounts"); + if(!accounts){ + vscode.window.showInformationMessage("Looking for available accounts."); + accounts = await updateAccounts(context); + if(!accounts){return;} + } + + let nAccounts = Object.entries(accounts).length; + if(nAccounts===1){ + // Only one account, so there is no need to pick + promptProject(Object.keys(accounts)[0], callback, project, ...args); + return; + } + + // Quickpick values (account + project) + vscode.window.showQuickPick(Object.keys(accounts), + {title: "Select account to use."} + ) + .then(function(account){ + if(account){ + promptProject(account, project, callback, ...args); + } + }); + return; +} + +export function promptProject(account:string, project:string | null, callback: any, ...args:any[] | undefined[]){ + // If "earthengine", project is not required, so it is set to null + if(account==="earthengine"){ + // No need to pick a project if using stored credentials + callback(account, null, ...args); + return; + }else{ + if(project){ + callback(account, project.trim(), ...args); + return; + }else{ + + vscode.window.showInputBox({ + title: "Select a project to use.", + prompt: "Example: earthengine-legacy" + }) + .then(function(project){ + if(project){ + callback(account, project.trim(), ...args); + } + }); + } + } +} + +/* +Prompts the user to pick a service account file (JSON) +reads it, validates it, and finally resolves +to the credentials, or undefined. +*/ +export async function pickServiceAccount(){ + let credentials:any | undefined; + return new Promise((resolve)=>{ + vscode.window.showOpenDialog({ + filters:{"json": ['json', 'JSON']} + }).then(async (fileUri)=>{ + if(fileUri){ + try{ + credentials = JSON.parse( + (await vscode.workspace.fs.readFile(fileUri[0])).toString()); + if (credentials.hasOwnProperty("client_id") && + credentials.hasOwnProperty("project_id") && + credentials.hasOwnProperty("private_key")){ + resolve(credentials); + }else{ + vscode.window.showErrorMessage("The file selected is not a valid service account private key."); + resolve(undefined); + } + }catch(e){ + vscode.window.showErrorMessage("Error reading file. \n" + e); + resolve(undefined); + } + } + }); + }); +} \ No newline at end of file diff --git a/src/utilities/codeEditorUtils.js b/src/utilities/codeEditorUtils.js new file mode 100644 index 0000000..f1b58b9 --- /dev/null +++ b/src/utilities/codeEditorUtils.js @@ -0,0 +1,257 @@ +/* +Code editor utilities. +- print: mirrors the functionality of print in the Code Editor. See: + https://developers.google.com/earth-engine/apidocs/print +- Export: mirrors the structure of Export in the Code Editor, with functions + named identically as in the code Editor, internally wrapping them from + ee.batch.Export. + ⚠️ In contrast to the code Editor, tasks + are automatically started with a successCallback/errorCallback. + This is an added feature of the extension. + ⚠️ Another contrast is that the code Editor defines some default values + for parameters such as description, fileNamePrefix, assetId, etc. Some of + could be implemented here (See 🔲 TODO's below), but not all. Therefore + submission of tasks without these defaults will raise the errorCallback. + See: + https://developers.google.com/earth-engine/apidocs/export-image-toasset + https://developers.google.com/earth-engine/apidocs/export-image-tocloudstorage + https://developers.google.com/earth-engine/apidocs/export-image-todrive + https://developers.google.com/earth-engine/apidocs/export-map-tocloudstorage + https://developers.google.com/earth-engine/apidocs/export-table-toasset + https://developers.google.com/earth-engine/apidocs/export-table-tobigquery + https://developers.google.com/earth-engine/apidocs/export-table-tocloudstorage + https://developers.google.com/earth-engine/apidocs/export-table-todrive + https://developers.google.com/earth-engine/apidocs/export-table-tofeatureview + https://developers.google.com/earth-engine/apidocs/export-video-tocloudstorage + +- Map, ui, and Chart: empty skeleton classes with functions accepting +the same arguments as in the Code Editor, but doing nothing, i.e., +any user code calling thee functions is silently ignored. +*/ + + +exports.Log = function(log){ + return function(...args){ + args.forEach((line)=>{ + if(line){ + if(typeof line==="object"){ + log.appendLine(JSON.stringify(line)); + }else{ + log.appendLine(line.toString()); + } + } + }); + }; +}; + +/* +Wraps a function to print one or more arguments +to a given log (vscode.window.OutputChannel) +If an argument is an object with the getInfo method, +then getInfo() is called asynchronously. +*/ +exports.Print = function(log){ + return function(...args){ + args.forEach((object)=>{ + if(object){ + if (typeof object === "object"){ + if ("getInfo" in object){ + object.getInfo(log); + }else{ + log(object); + } + }else{ + log(object); + } + } + }); + }; +}; + +/* ExportImage: wrapper for ee.batchExport.image.toXXX +functions, but also starts the tasks automatically. +🔲 TODO: description default to myExportImageTask +*/ +class ExportImage { + constructor(ee, successCallback, errCallback){ + this.toAsset = function(...args){ + //🔲 TODO: assetId default to + // projects/PROJECT/assets/ + description + return ee.batch.Export.image.toAsset(...args) + .start(successCallback, errCallback); + }; + this.toCloudStorage = function(...args){ + //🔲 TODO: fileNamePrefix default to description + return ee.batch.Export.image.toCloudStorage(...args) + .start(successCallback, errCallback); + }; + this.toDrive = function(...args){ + //🔲 TODO: fileNamePrefix default to description + return ee.batch.Export.image.toDrive(...args) + .start(successCallback, errCallback); + }; + } +} + +/* ExportMap: wrapper for ee.batchExport.map.toXXX +functions, but also starts the tasks automatically. +🔲 TODO: defaults? +*/ +class ExportMap { + constructor(ee, successCallback, errCallback){ + this.toCloudStorage = function(...args){ + return ee.batch.Export.map.toCloudStorage(...args) + .start(successCallback, errCallback); + }; + } +} + +/* ExportVideo: wrapper for ee.batchExport.video.toXXX +functions, but also starts the tasks automatically. +🔲 TODO: defaults? +*/ +class ExportVideo { + constructor(ee, successCallback, errCallback){ + this.toCloudStorage = function(...args){ + return ee.batch.Export.video.toCloudStorage(...args) + .start(successCallback, errCallback); + }; + } +} + +/* ExportTable: wrapper for ee.batchExport.table.toXXX +functions, but also starts the tasks automatically. +🔲 TODO: description default to myExportTableTask +*/ +class ExportTable { + constructor(ee, successCallback, errCallback){ + this.toAsset = function(...args){ + //🔲 TODO: assetId default to + // projects/PROJECT/assets/ + description + return ee.batch.Export.table.toAsset(...args) + .start(successCallback, errCallback); + }; + this.toCloudStorage = function(...args){ + //🔲 TODO: fileNamePrefix default to description + //🔲 TODO: bucket default? + return ee.batch.Export.table.toCloudStorage(...args) + .start(successCallback, errCallback); + }; + this.toDrive = function(...args){ + //🔲 TODO: fileNamePrefix default to description + return ee.batch.Export.table.toDrive(...args) + .start(successCallback, errCallback); + }; + this.toBigQuery = function(...args){ + //🔲 TODO: defaults? + return ee.batch.Export.table.toBigQuery(...args) + .start(successCallback, errCallback); + }; + this.toFeatureView = function(...args){ + //🔲 TODO: defaults? + return ee.batch.Export.table.toFeatureView(...args) + .start(successCallback, errCallback); + }; + } +} + +class ExportConstructor{ + constructor(ee, successCallback, errCallback){ + this.table = new ExportTable(ee, successCallback, errCallback); + this.image = new ExportImage(ee, successCallback, errCallback); + this.map = new ExportMap(ee, successCallback, errCallback); + this.video = new ExportVideo(ee, successCallback, errCallback); + } +} +exports.Export = ExportConstructor; + + +/* +The rest are defined to be ignored: +*/ +class MapConstructor{ + /* + Empty Map Class whose functions expect + the same arguments as in the code editor. + */ + constructor(){} + add=function(item){}; + addLayer=function(eeObject,visParams,name,shown,opacity){}; + centerObject=function(object,zoom,onComplete){}; + clear=function(){}; + drawingTools=function(){}; + getBounds=function(asGeoJSON){}; + getCenter=function(){}; + getScale=function(){}; + getZoom=function(){}; + layers=function(){}; + onChangeBounds=function(callback){}; + onChangeCenter=function(callback){}; + onChangeZoom=function(callback){}; + onClick=function(callback){}; + onIdle=function(callback){}; + onTileLoaded=function(callback){}; + remove=function(item){}; + setCenter=function(lon,lat,zoom){}; + setControlVisibility=function(all,layerList,zoomControl,scaleControl, + mapTypeControl,fullscreenControl,drawingToolsControl){}; + setGestureHandling=function(option){}; + setZoom=function(zoom){}; + style=function(){}; + unlisten=function(idOrType){}; + widgets=function(){}; + } + +exports.Map = new MapConstructor(); + +class UIConstructor{ + constructor(){} + // TODO +} +exports.ui = new UIConstructor(); + +class ChartArrayConstructor{ + constructor(){} + values=function(array,axis,xLabels){}; +} +class ChartFeatureConstructor{ + constructor(){} + byFeature=function(features,xProperty,yProperties){}; + byProperty=function(features,xProperties,seriesProperty){}; + groups=function(features,xProperty,yProperty,seriesProperty){}; + histogram=function(features,property,maxBuckets,minBucketWidth,maxRaw){}; +} +class ChartImageConstructor{ + constructor(){} + byClass=function(image, classBand, region, reducer, scale, classLabels, xLabels){}; + byRegion=function(image, regions, reducer, scale, xProperty){}; + doySeries=function(imageCollection, region, regionReducer, scale, yearReducer, startDay, endDay){}; + doySeriesByRegion=function(imageCollection, bandName, regions, regionReducer, scale, + yearReducer, seriesProperty, startDay, endDay){}; + doySeriesByYear=function(imageCollection, bandName, region, regionReducer, scale, + sameDayReducer, startDay, endDay){}; + histogram=function(image, region, scale, maxBuckets, minBucketWidth, maxRaw){}; + regions=function(image, regions, reducer, scale, seriesProperty, xLabels){}; + series=function(imageCollection, region, reducer, scale, xProperty){}; + seriesByRegion=function(imageCollection, regions, reducer, band, scale, xProperty, seriesProperty){}; +} +class ChartConstructor{ + /* + Empty Chart Class whose functions expect + the same arguments as in the code editor. + */ + constructor(){} + array = new ChartArrayConstructor(); + feature = new ChartFeatureConstructor(); + image = new ChartImageConstructor(); + // eslint-disable-next-line @typescript-eslint/naming-convention + Chart=function(dataTable, chartType, options, view){}; + setChartType=function(chartType){}; + setDataTable=function(dataTable){}; + setOptions=function(options){}; + setSeriesNames=function(seriesNames, seriesIndex){}; + setView=function(view){}; + transform=function(transformer){}; +} + +exports.Chart = new ChartConstructor(); diff --git a/src/utilities/getToken.ts b/src/utilities/getToken.ts index 740e0be..0e66753 100644 --- a/src/utilities/getToken.ts +++ b/src/utilities/getToken.ts @@ -1,3 +1,4 @@ +import { getEECredentials } from './accountPicker'; import https = require('https'); /* Handles retrieving a token for an account, either @@ -11,13 +12,13 @@ export function getAccountToken(account:string, extensionState:any){ return new Promise((resolve, reject) => { validateToken(checkStateForExistingToken(account, extensionState)) .then((tokenInfo:any)=>{ - if("error" in tokenInfo){ + if((! tokenInfo)){ console.log("Generating a new token for " + account); newToken(account) .then((tok:any)=>{ saveToken(tok, account, extensionState); resolve(tok); - }); + }).catch((err:any)=>{reject("Error generating a new token. \n" + err);}); }else{ console.log("Reusing valid token for " + account); resolve(tokenInfo.token); @@ -30,13 +31,17 @@ export function getAccountToken(account:string, extensionState:any){ /* Looks for an account token in the extension state -Returns an empty string "" if not found. +Returns null if not found. +account must already exist in userAccounts +userAccounts is a dictionary with accountName:token pairs */ function checkStateForExistingToken(account:string, extensionState:any){ - let token = ""; - let tokens = extensionState.get("tokens"); - if(tokens){ - if (account in tokens){token = tokens[account];} + let token = null; + let accounts = extensionState.get("userAccounts"); + if(accounts){ + if (account in accounts){ + token = accounts[account]; + } } return token; } @@ -47,13 +52,12 @@ Sends a GET request to https://oauth2.googleapis.com to validate the token Returns a Promise that resolves to: {"expires_in", "access_type", "token", ...} for a valid token, -{"error":"invalid_token", "error_description":..., "token": ...} for an invalid token, -or rejects the Promise with an error. +or null if the token is invalid or there is an error */ -function validateToken(token:string){ +function validateToken(token:string|null){ const oauthHost="oauth2.googleapis.com"; - return new Promise((resolve, reject) => { - if (token.length===0){reject;} + return new Promise((resolve) => { + if (! token){resolve(null);} let req = https.request( { host: oauthHost, @@ -62,14 +66,23 @@ function validateToken(token:string){ }, function(res:any) { let buffers: any[] | Uint8Array[] = []; - res.on('error', reject); + res.on('error', (e:any)=> + { + console.log(e); + resolve(null); + } + ); res.on('data', (buffer: any) => buffers.push(buffer)); res.on( 'end', () =>{ let out = JSON.parse(Buffer.concat(buffers).toString()); out.token = token; + if ("error" in out){ + resolve(null); + }else{ resolve(out); + } } ); } @@ -94,13 +107,14 @@ function newToken(account:string){ /* Reads persistent credentials and gets a new token using the credentials. -Returns a Promise with the token (string) or -rejects with the error. +Returns a Promise that resolves to the token *string) +or rejects with the error. */ function getTokenFromPersistentCredentials(){ return new Promise((resolve, reject)=>{ - readPersistentCredentials() + getEECredentials() .then((credentials:any)=>{ + if(!credentials){reject("EE credentials not found.");} getTokenFromCredentials(credentials) .then((tokenInfo:any)=>{ resolve(tokenInfo.access_token); @@ -110,35 +124,6 @@ function getTokenFromPersistentCredentials(){ }); } - -/* -Reads the ~/.config/earthengine/credentials file* -asynchronously. Returns a Promise that resolves -to the JSON content of the file, or rejects -with the error. -*This file is stored and managed by the python -earthengine API. This extension will not modify it. -*/ -function readPersistentCredentials(){ - const os = require("os"); - const path = require("path"); - const fs = require("fs"); - const homedir = os.homedir(); - const credentialsFile = path.join(homedir, - ".config", "earthengine", "credentials" - ); - return new Promise((resolve, reject) => { - fs.readFile(credentialsFile,"utf8", - (err:any, data:any) => { - if (err) {reject(err);}; - let credentials=JSON.parse(data.toString()); - credentials.grant_type="refresh_token"; - resolve(credentials); - }); - }); -} - - /* Sends a POST request to https://oauth2.googleapis.com/token with the given credentials @@ -186,6 +171,7 @@ Calls gcloud using child_process.exec Returns a Promise that resolves to the token (str), or rejects with the error. +🔲 TODO: use a vscode function instead of exec. */ function gcloud(account:string){ const { exec } = require('child_process'); @@ -206,10 +192,13 @@ function gcloud(account:string){ Handles saving an account token to the extention state. */ function saveToken(token:string, account:string, extensionState:any){ - - let tokens = extensionState.get("tokens"); - if(!tokens){tokens = {}; } - tokens[account] = token; - extensionState.update("tokens", tokens); + let accounts = extensionState.get("userAccounts"); + if(accounts){ + if (account in accounts){ + accounts[account] = token; + extensionState.update("userAccounts", accounts); + } + } + return; } diff --git a/src/utilities/scriptRunners.ts b/src/utilities/scriptRunners.ts new file mode 100644 index 0000000..bffe3ac --- /dev/null +++ b/src/utilities/scriptRunners.ts @@ -0,0 +1,169 @@ +/* +Helpers to run GEE scripts from within vscode. + +A GEE script is written to a temporary file consisting of: + +scriptPrefix (see below) +USER-PROVIDED-CODE +scriptSuffix (a single closing curly brace '}' ) + +Basically, the user script is wrapped into a "main" +function that receives the ee library, the additional +"code Editor"-like utilities*, as well as a successCallback +and errorCallback functions to handle when a task is +successfully submitted or fails to submit. + +The temporary script is require()d `const userCode = require(tempFile)` +and then the main function is called `userCode.main(...)` +If there is an error with the script itself, it is catched and raised. +Finally, the temporary file is deleted. + +*(see codeEditorUtils.js): +- print: mirrors the functionality of print in the Code Editor. +- Export: mirrors the structure of Export in the Code Editor, with functions + named identically as in the code Editor, internally wrapping them from + ee.batch.Export. + ⚠️ In contrast to the code Editor, tasks + are automatically started with a successCallback/errorCallback. + This is an added feature of the extension. + ⚠️ Another contrast is that the code Editor defines some default values + for parameters such as description, fileNamePrefix, assetId, etc. Some of + could be implemented here (See 🔲 TODO's below), but not all. Therefore + submission of tasks without these defaults will raise the errorCallback. +- Map, ui, and Chart: empty skeleton classes with functions accepting +the same arguments as in the Code Editor, but doing nothing, i.e., +any user code calling thee functions is silently ignored. + +❗ TODO: fix Windows-vscode-specific issue: +https://stackoverflow.com/questions/77436205/synchronous-function-call-to-external-library-within-vscode-freezes-only-in-wind +Basically, synchronous calls to some ee functions (most notably getInfo) +will crash the extension. This does not affect Linux users. +It's also unlikely to be a bug in ee itself, as the issue doesn't occur +in nodejs directly. +*/ +import * as vscode from 'vscode'; +import { getAccountToken } from './getToken'; +import { mkdtemp, rmdir } from 'node:fs/promises'; +import path = require("path"); +import os = require("os"); +import fs = require("fs"); +var ee = require("@google/earthengine"); +const scriptPrefix = "exports.main=function(ee,ceu, onTaskStart, onTaskStartError, vslog){" + +"var log=ceu.Log(vslog);" + +"var print=ceu.Print(log);var Map=ceu.Map; " + +"Export = new ceu.Export(ee, onTaskStart, onTaskStartError);"; +const scriptSuffix = "\n}"; + +var codeEditorUtils = require("./codeEditorUtils.js"); + +function wrapOnTaskStart(log: vscode.OutputChannel){ + return function onTaskStart(){ + log.appendLine("Successfully submitted task"); + vscode.window.showInformationMessage("Successfully submitted task"); + }; +} + +function wrapOnTaskStartError(log: vscode.OutputChannel){ + return function onTaskStartError(err:any){ + log.appendLine("Failed to start EE task: \n " + err); + vscode.window.showErrorMessage("Failed to start EE task: \n " + err); + }; +} + +function scriptRunError(err:any){ + vscode.window.showErrorMessage("EE script run failed: \n " + err); +} + +function eeInitError(err:any){ + vscode.window.showErrorMessage("EE initialization failed: \n " + err); +} + +function scriptRunner(project:string | null, document:vscode.TextDocument, log:vscode.OutputChannel){ + let onTaskStart = wrapOnTaskStart(log); + let onTaskStartError = wrapOnTaskStartError(log); + try{ + ee.initialize(null, null, + ()=>{ + try{ + if(project){ + ee.data.setProject(project); + } + // Create a temporary directory: + mkdtemp(path.join(os.tmpdir(), 'eetasksRunner-')) + .then((tempDir:string)=>{ + // Create the file to run in the temporary directory + let tempFile = path.join(tempDir, "temp.js"); + //🔲 TODO: random name instead of temp.js + let tempUri = vscode.Uri.file(tempFile); + //🔲 TODO: replace with vscode.workspace.fs.writeFile + fs.writeFile(tempFile, + scriptPrefix + document.getText() + scriptSuffix, () => { + try{ + const userCode = require(tempFile); + try{ + log.appendLine("Starting GEE script run: "); + log.appendLine(document.fileName); + log.appendLine("----------------------------------"); + userCode.main(ee, codeEditorUtils, + onTaskStart, onTaskStartError, log); + log.show(); + }catch(error){scriptRunError(error);} + }catch(error){scriptRunError(error);} + // Delete the temporary file and directory, + // even if the script failed. + vscode.workspace.fs.delete(tempUri).then( + ()=>rmdir(tempDir)); + }); // fs.writeFile + }); // mkdtemp + }catch (error){ + scriptRunError(error); + }finally{ + return; + }}, + (error:any)=>{eeInitError(error);}, + null, project); + }catch(error){ + vscode.window.showErrorMessage("Error initializing earth engine token: \n" + error); + } +} + +export function scriptRunnerAsAccount(account:string, project: string | null, + context:vscode.ExtensionContext, log:vscode.OutputChannel){ + /* + Runs a GEE script using a user account/project + */ + const editor = vscode.window.activeTextEditor; + if (editor) { + let document = editor.document; + const documentUri = document.uri; + if (documentUri.scheme==='file'){ + getAccountToken(account, context.globalState) + .then((token:any)=>{ + ee.data.setAuthToken('', 'Bearer', token, 3600, [], + ()=>scriptRunner(project, document, log) + , false); + }) + .catch((err:any)=>{ + vscode.window.showErrorMessage(err); + console.log(err); + }); + } + } +} + +export function scriptRunnerAsServiceAccount(credentials:any, log:vscode.OutputChannel){ + /* + Runs a GEE script using credentials from a service account + */ + const editor = vscode.window.activeTextEditor; + if (editor) { + let document = editor.document; + const documentUri = document.uri; + if (documentUri.scheme==='file'){ + ee.data.authenticateViaPrivateKey(credentials, + ()=>scriptRunner(credentials.project, document, log), + (error:any)=>{console.log("Error authenticating via private key. \n" + error);} + ); + } + } +} \ No newline at end of file