From 45225954af684ce30b631ac612b23639aaac6f6f Mon Sep 17 00:00:00 2001 From: Ray Bell Date: Wed, 28 Jan 2026 10:09:33 -0500 Subject: [PATCH 1/7] chore: add prettier to CI and project configuration --- .github/workflows/ci.yml | 3 +++ .prettierignore | 6 ++++++ .prettierrc.json | 17 +++++++++++++++++ package-lock.json | 37 +++++++++++++++++++++++++++++++++++++ package.json | 3 +++ 5 files changed, 66 insertions(+) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0827e89..3860090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Run linter run: npm run lint + - name: Run Prettier check + run: npm run format:check + - name: Run type checking run: npx tsc --noEmit --project workspace-server diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c57250f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +coverage +.vitepress/cache +.vitepress/dist +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5ce969b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,17 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "overrides": [ + { + "files": ["**/*.md"], + "options": { + "tabWidth": 2, + "printWidth": 80, + "proseWrap": "always" + } + } + ] +} diff --git a/package-lock.json b/package-lock.json index 7ffc2fa..38077fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "eslint-plugin-license-header": "^0.8.0", "jest": "^30.1.3", "minimist": "^1.2.8", + "prettier": "^3.8.1", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.2", @@ -217,6 +218,7 @@ "integrity": "sha512-22SHEEVNjZfFWkFks3P6HilkR3rS7a6GjnCIqR22Zz4HNxdfT0FG+RE7efTcFVfLUkTTMQQybvaUcwMrHXYa7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.46.0", "@algolia/requester-browser-xhr": "5.46.0", @@ -395,6 +397,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -988,6 +991,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1032,6 +1036,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3396,6 +3401,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3487,6 +3493,7 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4281,6 +4288,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4382,6 +4390,7 @@ "integrity": "sha512-7ML6fa2K93FIfifG3GMWhDEwT5qQzPTmoHKCTvhzGEwdbQ4n0yYUWZlLYT75WllTGJCJtNUI0C1ybN4BCegqvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.12.0", "@algolia/client-abtesting": "5.46.0", @@ -5002,6 +5011,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -6214,6 +6224,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6714,6 +6725,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -6935,6 +6947,7 @@ "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.3.0" } @@ -8357,6 +8370,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10304,6 +10318,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10360,6 +10375,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", @@ -11929,6 +11960,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12103,6 +12135,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12331,6 +12364,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12628,6 +12662,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13160,6 +13195,7 @@ "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -13641,6 +13677,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1e7bd93..45c868b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "clean": "npm run clean --workspaces --if-present && rm -rf release node_modules logs docs/.vitepress/cache docs/.vitepress/dist", "lint": "eslint .", "lint:fix": "eslint . --fix", + "format:check": "prettier --check .", + "format:fix": "prettier --write .", "release": "node scripts/release.js", "release:dev": "npm install && npm run build && node scripts/release.js", "set-version": "node scripts/set-version.js", @@ -58,6 +60,7 @@ "eslint-plugin-license-header": "^0.8.0", "jest": "^30.1.3", "minimist": "^1.2.8", + "prettier": "^3.8.1", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.2", From bac94dc4b8025785459b6bd259c5158f7bd2c56e Mon Sep 17 00:00:00 2001 From: Ray Bell Date: Wed, 28 Jan 2026 16:15:32 -0500 Subject: [PATCH 2/7] chore: remove redundant prettier overrides --- .prettierrc.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.prettierrc.json b/.prettierrc.json index 5ce969b..52ff592 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -8,8 +8,6 @@ { "files": ["**/*.md"], "options": { - "tabWidth": 2, - "printWidth": 80, "proseWrap": "always" } } From 86bea6c6fc916f4912e69ae4368718a22e31aef8 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Jan 2026 15:10:34 -0800 Subject: [PATCH 3/7] Run npm run format:fix --- .github/dependabot.yml | 42 +- .github/workflows/ci.yml | 140 +- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 55 +- GEMINI.md | 22 +- README.md | 47 +- cloud_function/index.js | 171 +- cloud_function/package.json | 2 +- docs/.vitepress/config.mts | 25 +- docs/development.md | 59 +- docs/index.md | 53 +- docs/release.md | 26 +- docs/release_notes.md | 21 +- eslint.config.js | 179 +- gemini-extension.json | 6 +- jest.config.js | 26 +- package-lock.json | 20 - scripts/auth-utils.js | 18 +- scripts/list-deps.js | 8 +- scripts/release.js | 30 +- scripts/set-version.js | 24 +- scripts/start.js | 20 +- scripts/utils/dependencies.js | 4 +- tsconfig.json | 6 +- workspace-server/.github/workflows/ci.yml | 150 +- .../.github/workflows/release.yml | 84 +- workspace-server/WORKSPACE-Context.md | 91 +- workspace-server/esbuild.config.js | 10 +- workspace-server/jest.config.js | 2 +- .../src/__tests__/auth/AuthManager.test.ts | 207 +- .../token-storage/base-token-storage.test.ts | 11 +- .../token-storage/file-token-storage.test.ts | 2 +- .../hybrid-token-storage.test.ts | 29 +- .../keychain-token-storage.test.ts | 16 +- .../oauth-credential-storage.test.ts | 2 +- workspace-server/src/__tests__/mocks/jsdom.ts | 243 ++- .../src/__tests__/mocks/marked.js | 10 +- workspace-server/src/__tests__/mocks/wasm.js | 2 +- .../services/CalendarService.test.ts | 117 +- .../__tests__/services/ChatService.test.ts | 79 +- .../__tests__/services/DocsService.test.ts | 1576 ++++++++------- .../__tests__/services/DriveService.test.ts | 246 ++- .../__tests__/services/GmailService.test.ts | 233 ++- .../__tests__/services/PeopleService.test.ts | 532 ++--- .../__tests__/services/SheetsService.test.ts | 759 +++---- .../__tests__/services/SlidesService.test.ts | 448 +++-- .../__tests__/services/TimeService.test.ts | 8 +- workspace-server/src/__tests__/setup.ts | 2 +- .../__tests__/utils/DriveQueryBuilder.test.ts | 157 +- .../src/__tests__/utils/IdUtils.test.ts | 12 +- .../src/__tests__/utils/MimeHelper.test.ts | 70 +- .../src/__tests__/utils/logger.test.ts | 126 +- .../utils/markdownToDocsRequests.test.ts | 510 ++--- .../src/__tests__/utils/paths.test.ts | 7 +- .../utils/secure-browser-launcher.test.ts | 67 +- .../src/__tests__/utils/validation.test.ts | 307 +-- workspace-server/src/auth/AuthManager.ts | 685 ++++--- .../auth/token-storage/base-token-storage.ts | 9 +- .../auth/token-storage/file-token-storage.ts | 4 +- .../token-storage/hybrid-token-storage.ts | 12 +- .../token-storage/keychain-token-storage.ts | 5 +- .../token-storage/oauth-credential-storage.ts | 2 +- .../src/auth/token-storage/types.ts | 2 +- workspace-server/src/index.ts | 1787 ++++++++++------- .../src/services/CalendarService.ts | 463 +++-- workspace-server/src/services/ChatService.ts | 908 +++++---- workspace-server/src/services/DocsService.ts | 1402 +++++++------ workspace-server/src/services/DriveService.ts | 706 ++++--- workspace-server/src/services/GmailService.ts | 919 +++++---- .../src/services/PeopleService.ts | 312 +-- .../src/services/SheetsService.ts | 500 +++-- .../src/services/SlidesService.ts | 396 ++-- workspace-server/src/services/TimeService.ts | 39 +- .../src/utils/DriveQueryBuilder.ts | 54 +- workspace-server/src/utils/GaxiosConfig.ts | 36 +- workspace-server/src/utils/MimeHelper.ts | 351 ++-- workspace-server/src/utils/logger.ts | 34 +- .../src/utils/markdownToDocsRequests.ts | 423 ++-- workspace-server/src/utils/open-wrapper.ts | 15 +- .../src/utils/secure-browser-launcher.ts | 465 ++--- workspace-server/src/utils/validation.ts | 175 +- workspace-server/tsconfig.json | 13 +- workspace-server/tsconfig.test.json | 7 +- 83 files changed, 9366 insertions(+), 7479 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 70bc1bf..01589ba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,37 +1,37 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 5 labels: - - "dependencies" - - "npm" + - 'dependencies' + - 'npm' commit-message: - prefix: "chore" - include: "scope" + prefix: 'chore' + include: 'scope' - - package-ecosystem: "npm" - directory: "/workspace-server" + - package-ecosystem: 'npm' + directory: '/workspace-server' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 5 labels: - - "dependencies" - - "npm" + - 'dependencies' + - 'npm' commit-message: - prefix: "chore" - include: "scope" + prefix: 'chore' + include: 'scope' - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' open-pull-requests-limit: 5 labels: - - "dependencies" - - "github-actions" + - 'dependencies' + - 'github-actions' commit-message: - prefix: "chore" - include: "scope" + prefix: 'chore' + include: 'scope' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3860090..a650216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,92 +2,92 @@ name: CI on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main ] + branches: [main] jobs: test: runs-on: ${{ matrix.os }} - + strategy: matrix: node-version: [20.x, 22.x, 24.x] os: [ubuntu-latest, windows-latest, macos-latest] - + steps: - - uses: actions/checkout@v6 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install libsecret (Linux) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y libsecret-1-0 - - - name: Install dependencies - run: npm ci - - - name: Run linter - run: npm run lint - - - name: Run Prettier check - run: npm run format:check - - - name: Run type checking - run: npx tsc --noEmit --project workspace-server - - - name: Run tests with coverage - run: npm run test:ci - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - directory: ./workspace-server/coverage - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false + - uses: actions/checkout@v6 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install libsecret (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libsecret-1-0 + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run Prettier check + run: npm run format:check + + - name: Run type checking + run: npx tsc --noEmit --project workspace-server + + - name: Run tests with coverage + run: npm run test:ci + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + directory: ./workspace-server/coverage + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false build: runs-on: ubuntu-latest needs: test - + steps: - - uses: actions/checkout@v6 - - - name: Use Node.js - uses: actions/setup-node@v6 - with: - node-version: '20.x' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Upload build artifacts - uses: actions/upload-artifact@v6 - with: - name: dist - path: workspace-server/dist/ + - uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v6 + with: + name: dist + path: workspace-server/dist/ security: runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v6 - - - name: Run security audit - run: npm audit --audit-level=moderate - continue-on-error: true - - - name: Check for known vulnerabilities - run: npx audit-ci --moderate - continue-on-error: true \ No newline at end of file + - uses: actions/checkout@v6 + + - name: Run security audit + run: npm audit --audit-level=moderate + continue-on-error: true + + - name: Check for known vulnerabilities + run: npx audit-ci --moderate + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b83046..7f2f817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,4 +44,4 @@ jobs: uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: - files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz \ No newline at end of file + files: release/${{ matrix.platform }}.google-workspace-extension.tar.gz diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39a7af0..005668a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,8 @@ sign a new one. ### Review our Community Guidelines -This project follows [Google's Open Source Community -Guidelines](https://opensource.google/conduct/). +This project follows +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). ## Contribution Process @@ -33,51 +33,72 @@ for this purpose. ### Self Assigning Issues -If you're looking for an issue to work on, check out our list of issues that are labeled ["help wanted"](https://github.com/gemini-cli-extensions/workspace/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22). +If you're looking for an issue to work on, check out our list of issues that are +labeled +["help wanted"](https://github.com/gemini-cli-extensions/workspace/issues?q=is%3Aissue+state%3Aopen+label%3A%22help+wanted%22). -To assign an issue to yourself, simply add a comment with the text `/assign`. The comment must contain only that text and nothing else. This command will assign the issue to you, provided it is not already assigned. +To assign an issue to yourself, simply add a comment with the text `/assign`. +The comment must contain only that text and nothing else. This command will +assign the issue to you, provided it is not already assigned. -Please note that you can have a maximum of 3 issues assigned to you at any given time. +Please note that you can have a maximum of 3 issues assigned to you at any given +time. ### Pull Request Guidelines -To help us review and merge your PRs quickly, please follow these guidelines. PRs that do not meet these standards may be closed. +To help us review and merge your PRs quickly, please follow these guidelines. +PRs that do not meet these standards may be closed. #### 1. Link to an Existing Issue -All PRs should be linked to an existing issue in our tracker. This ensures that every change has been discussed and is aligned with the project's goals before any code is written. +All PRs should be linked to an existing issue in our tracker. This ensures that +every change has been discussed and is aligned with the project's goals before +any code is written. - **For bug fixes:** The PR should be linked to the bug report issue. -- **For features:** The PR should be linked to the feature request or proposal issue that has been approved by a maintainer. +- **For features:** The PR should be linked to the feature request or proposal + issue that has been approved by a maintainer. -If an issue for your change doesn't exist, please **open one first** and wait for feedback before you start coding. +If an issue for your change doesn't exist, please **open one first** and wait +for feedback before you start coding. #### 2. Keep It Small and Focused -We favor small, atomic PRs that address a single issue or add a single, self-contained feature. +We favor small, atomic PRs that address a single issue or add a single, +self-contained feature. - **Do:** Create a PR that fixes one specific bug or adds one specific feature. -- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, and a refactor) into a single PR. +- **Don't:** Bundle multiple unrelated changes (e.g., a bug fix, a new feature, + and a refactor) into a single PR. -Large changes should be broken down into a series of smaller, logical PRs that can be reviewed and merged independently. +Large changes should be broken down into a series of smaller, logical PRs that +can be reviewed and merged independently. #### 3. Use Draft PRs for Work in Progress -If you'd like to get early feedback on your work, please use GitHub's **Draft Pull Request** feature. This signals to the maintainers that the PR is not yet ready for a formal review but is open for discussion and initial feedback. +If you'd like to get early feedback on your work, please use GitHub's **Draft +Pull Request** feature. This signals to the maintainers that the PR is not yet +ready for a formal review but is open for discussion and initial feedback. #### 4. Ensure All Checks Pass -Before submitting your PR, ensure that all automated checks are passing by running `npm run test && npm run lint`. This command runs all tests, linting, and other style checks. +Before submitting your PR, ensure that all automated checks are passing by +running `npm run test && npm run lint`. This command runs all tests, linting, +and other style checks. #### 5. Write Clear Commit Messages and a Good PR Description -Your PR should have a clear, descriptive title and a detailed description of the changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/) standard for your commit messages. +Your PR should have a clear, descriptive title and a detailed description of the +changes. Follow the [Conventional Commits](https://www.conventionalcommits.org/) +standard for your commit messages. - **Good PR Title:** `feat(cli): Add --json flag to 'config get' command` - **Bad PR Title:** `Made some changes` -In the PR description, explain the "why" behind your changes and link to the relevant issue (e.g., `Fixes #123`). +In the PR description, explain the "why" behind your changes and link to the +relevant issue (e.g., `Fixes #123`). ## Development Setup and Workflow -For information on how to build, modify, and understand the development setup of this project, please see the [development documentation](docs/development.md). \ No newline at end of file +For information on how to build, modify, and understand the development setup of +this project, please see the [development documentation](docs/development.md). diff --git a/GEMINI.md b/GEMINI.md index b67b391..ed586ae 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,21 +1,29 @@ -This is a Gemini extension that provides tools for interacting with Google Workspace services like Google Docs. +This is a Gemini extension that provides tools for interacting with Google +Workspace services like Google Docs. ### Building and Running -* **Install dependencies:** `npm install` -* **Build the project:** `npm run build --prefix workspace-server` +- **Install dependencies:** `npm install` +- **Build the project:** `npm run build --prefix workspace-server` ### Development Conventions -This project uses TypeScript and the Model Context Protocol (MCP) SDK to create a Gemini extension. The main entry point is `src/index.ts`, which initializes the MCP server and registers the available tools. +This project uses TypeScript and the Model Context Protocol (MCP) SDK to create +a Gemini extension. The main entry point is `src/index.ts`, which initializes +the MCP server and registers the available tools. -The business logic for each service is separated into its own file in the `src/services` directory. For example, `src/services/DocsService.ts` contains the logic for interacting with the Google Docs API. +The business logic for each service is separated into its own file in the +`src/services` directory. For example, `src/services/DocsService.ts` contains +the logic for interacting with the Google Docs API. -Authentication is handled by the `src/auth/AuthManager.ts` file, which uses the `@google-cloud/local-auth` library to obtain and refresh OAuth 2.0 credentials. +Authentication is handled by the `src/auth/AuthManager.ts` file, which uses the +`@google-cloud/local-auth` library to obtain and refresh OAuth 2.0 credentials. ### Adding New Tools To add a new tool, you need to: 1. Add a new method to the appropriate service file in `src/services`. -2. In `src/index.ts`, register the new tool with the MCP server by calling `server.registerTool()`. You will need to provide a name for the tool, a description, and the input schema using the `zod` library. +2. In `src/index.ts`, register the new tool with the MCP server by calling + `server.registerTool()`. You will need to provide a name for the tool, a + description, and the input schema using the `zod` library. diff --git a/README.md b/README.md index 8744edb..30fbcc6 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,19 @@ [![Build Status](https://github.com/gemini-cli-extensions/workspace/actions/workflows/ci.yml/badge.svg)](https://github.com/gemini-cli-extensions/workspace/actions/workflows/ci.yml) -The Google Workspace extension for Gemini CLI brings the power of your Google Workspace apps to your command line. Manage your documents, spreadsheets, presentations, emails, chat, and calendar events without leaving your terminal. +The Google Workspace extension for Gemini CLI brings the power of your Google +Workspace apps to your command line. Manage your documents, spreadsheets, +presentations, emails, chat, and calendar events without leaving your terminal. ## Prerequisites -Before using the Google Workspace extension, you need to be logged into your Google account. +Before using the Google Workspace extension, you need to be logged into your +Google account. ## Installation -Install the Google Workspace extension by running the following command from your terminal: +Install the Google Workspace extension by running the following command from +your terminal: ```bash gemini extensions install https://github.com/gemini-cli-extensions/workspace @@ -18,11 +22,13 @@ gemini extensions install https://github.com/gemini-cli-extensions/workspace ## Usage -Once the extension is installed, you can use it to interact with your Google Workspace apps. Here are a few examples: +Once the extension is installed, you can use it to interact with your Google +Workspace apps. Here are a few examples: **Create a new Google Doc:** -> "Create a new Google Doc with the title 'My New Doc' and the content '# My New Document\n\nThis is a new document created from the command line.'" +> "Create a new Google Doc with the title 'My New Doc' and the content '# My New +> Document\n\nThis is a new document created from the command line.'" **List your upcoming calendar events:** @@ -50,23 +56,35 @@ Searches your Google Drive for files matching the given query. ## Resources -- [Documentation](docs/index.md): Detailed documentation on all the available tools. -- [GitHub Issues](https://github.com/gemini-cli-extensions/workspace/issues): Report bugs or request features. +- [Documentation](docs/index.md): Detailed documentation on all the available + tools. +- [GitHub Issues](https://github.com/gemini-cli-extensions/workspace/issues): + Report bugs or request features. ## Important security consideration: Indirect Prompt Injection Risk -When exposing any language model to untrusted data, there's a risk of an [indirect prompt injection attack](https://en.wikipedia.org/wiki/Prompt_injection). Agentic tools like Gemini CLI, connected to MCP servers, have access to a wide array of tools and APIs. +When exposing any language model to untrusted data, there's a risk of an +[indirect prompt injection attack](https://en.wikipedia.org/wiki/Prompt_injection). +Agentic tools like Gemini CLI, connected to MCP servers, have access to a wide +array of tools and APIs. -This MCP server grants the agent the ability to read, modify, and delete your Google Account data, as well as other data shared with you. +This MCP server grants the agent the ability to read, modify, and delete your +Google Account data, as well as other data shared with you. -* Never use this with untrusted tools -* Never include untrusted inputs into the model context. This includes asking Gemini CLI to process mail, documents, or other resources from unverified sources. -* Untrusted inputs may contain hidden instructions that could hijack your CLI session. Attackers can then leverage this to modify, steal, or destroy your data. -* Always carefully review actions taken by Gemini CLI on your behalf to ensure they are correct and align with your intentions. +- Never use this with untrusted tools +- Never include untrusted inputs into the model context. This includes asking + Gemini CLI to process mail, documents, or other resources from unverified + sources. +- Untrusted inputs may contain hidden instructions that could hijack your CLI + session. Attackers can then leverage this to modify, steal, or destroy your + data. +- Always carefully review actions taken by Gemini CLI on your behalf to ensure + they are correct and align with your intentions. ## Contributing -Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) file for details on how to contribute to this project. +Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) +file for details on how to contribute to this project. ## 📄 Legal @@ -74,4 +92,3 @@ Contributions are welcome! Please read the [CONTRIBUTING.md](CONTRIBUTING.md) fi - **Terms of Service**: [Terms of Service](https://policies.google.com/terms) - **Privacy Policy**: [Privacy Policy](https://policies.google.com/privacy) - **Security**: [Security Policy](SECURITY.md) - diff --git a/cloud_function/index.js b/cloud_function/index.js index 75d8787..bcdd34f 100644 --- a/cloud_function/index.js +++ b/cloud_function/index.js @@ -19,7 +19,7 @@ const REDIRECT_URI = process.env.REDIRECT_URI; // Fail fast if required environment variables are missing if (!CLIENT_ID || !SECRET_NAME || !REDIRECT_URI) { throw new Error( - 'Missing required environment variables: CLIENT_ID, SECRET_NAME, and REDIRECT_URI must be set.' + 'Missing required environment variables: CLIENT_ID, SECRET_NAME, and REDIRECT_URI must be set.', ); } @@ -36,7 +36,6 @@ const secretClient = new SecretManagerServiceClient(); */ async function getClientSecret() { try { - const [version] = await secretClient.accessSecretVersion({ name: SECRET_NAME, }); @@ -65,70 +64,86 @@ async function handleCallback(req, res) { try { const clientSecret = await getClientSecret(); - const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', { - client_id: CLIENT_ID, - client_secret: clientSecret, - code: code, - grant_type: 'authorization_code', - redirect_uri: REDIRECT_URI, - }); - - - const { access_token, refresh_token, expires_in, scope, token_type } = tokenResponse.data; + const tokenResponse = await axios.post( + 'https://oauth2.googleapis.com/token', + { + client_id: CLIENT_ID, + client_secret: clientSecret, + code: code, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + }, + ); + + const { access_token, refresh_token, expires_in, scope, token_type } = + tokenResponse.data; // Calculate expiry_date (timestamp in milliseconds) - const expiry_date = Date.now() + (expires_in * 1000); + const expiry_date = Date.now() + expires_in * 1000; // If state is present, decode it and decide whether to redirect or show manual page. if (state) { - try { - // SECURITY: Enforce a reasonable size limit on the state parameter to prevent DoS. - if (state.length > 4096) { - throw new Error('State parameter exceeds size limit of 4KB.'); - } - - const payload = JSON.parse(Buffer.from(state, 'base64').toString('utf8')); - - // If not in manual mode and a URI is present, perform the redirect. - if (payload && payload.manual === false && payload.uri) { - - const redirectUrl = new URL(payload.uri); - - // SECURITY: Validate the redirect URI to prevent open redirect attacks. - if (redirectUrl.hostname !== 'localhost' && redirectUrl.hostname !== '127.0.0.1') { - throw new Error(`Invalid redirect hostname: ${redirectUrl.hostname}. Must be localhost or 127.0.0.1.`); - } - - const finalUrl = redirectUrl; // Use the validated URL object - finalUrl.searchParams.append('access_token', access_token); - if (refresh_token) { - finalUrl.searchParams.append('refresh_token', refresh_token); - } - finalUrl.searchParams.append('scope', scope); - finalUrl.searchParams.append('token_type', token_type); - finalUrl.searchParams.append('expiry_date', expiry_date.toString()); + try { + // SECURITY: Enforce a reasonable size limit on the state parameter to prevent DoS. + if (state.length > 4096) { + throw new Error('State parameter exceeds size limit of 4KB.'); + } - // SECURITY: Pass the CSRF token back to the client for validation. - if (payload.csrf) { - finalUrl.searchParams.append('state', payload.csrf); - } - - return res.redirect(302, finalUrl.toString()); - } - } catch (e) { - console.error('Error processing state or redirect. Falling back to manual page.', e); + const payload = JSON.parse( + Buffer.from(state, 'base64').toString('utf8'), + ); + + // If not in manual mode and a URI is present, perform the redirect. + if (payload && payload.manual === false && payload.uri) { + const redirectUrl = new URL(payload.uri); + + // SECURITY: Validate the redirect URI to prevent open redirect attacks. + if ( + redirectUrl.hostname !== 'localhost' && + redirectUrl.hostname !== '127.0.0.1' + ) { + throw new Error( + `Invalid redirect hostname: ${redirectUrl.hostname}. Must be localhost or 127.0.0.1.`, + ); + } + + const finalUrl = redirectUrl; // Use the validated URL object + finalUrl.searchParams.append('access_token', access_token); + if (refresh_token) { + finalUrl.searchParams.append('refresh_token', refresh_token); + } + finalUrl.searchParams.append('scope', scope); + finalUrl.searchParams.append('token_type', token_type); + finalUrl.searchParams.append('expiry_date', expiry_date.toString()); + + // SECURITY: Pass the CSRF token back to the client for validation. + if (payload.csrf) { + finalUrl.searchParams.append('state', payload.csrf); + } + + return res.redirect(302, finalUrl.toString()); } + } catch (e) { + console.error( + 'Error processing state or redirect. Falling back to manual page.', + e, + ); + } } // --- Fallback to manual instructions --- - const credentialsJson = JSON.stringify({ + const credentialsJson = JSON.stringify( + { refresh_token: refresh_token, scope: scope, token_type: token_type, access_token: access_token, - expiry_date: expiry_date - }, null, 2); // Pretty print JSON + expiry_date: expiry_date, + }, + null, + 2, + ); // Pretty print JSON // 4. Display the JSON and add a copy button + instructions res.set('Content-Type', 'text/html'); @@ -240,14 +255,20 @@ async function handleCallback(req, res) { `); - } catch (error) { if (axios.isAxiosError(error) && error.response) { - console.error('Error during token exchange:', error.response.data); + console.error('Error during token exchange:', error.response.data); } else { - console.error('Error during token exchange:', error instanceof Error ? error.message : error); + console.error( + 'Error during token exchange:', + error instanceof Error ? error.message : error, + ); } - res.status(500).send('An error occurred during the token exchange. Check function logs for details.'); + res + .status(500) + .send( + 'An error occurred during the token exchange. Check function logs for details.', + ); } } @@ -266,27 +287,30 @@ async function handleRefreshToken(req, res) { const { refresh_token } = req.body; - if (!refresh_token) { console.error('Missing refresh_token in request body'); - return res.status(400).send('Error: Missing refresh_token in request body.'); + return res + .status(400) + .send('Error: Missing refresh_token in request body.'); } try { const clientSecret = await getClientSecret(); - const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', { - client_id: CLIENT_ID, - client_secret: clientSecret, - refresh_token: refresh_token, - grant_type: 'refresh_token', - }); - + const tokenResponse = await axios.post( + 'https://oauth2.googleapis.com/token', + { + client_id: CLIENT_ID, + client_secret: clientSecret, + refresh_token: refresh_token, + grant_type: 'refresh_token', + }, + ); const { access_token, expires_in, scope, token_type } = tokenResponse.data; // Calculate expiry_date (timestamp in milliseconds) - const expiry_date = Date.now() + (expires_in * 1000); + const expiry_date = Date.now() + expires_in * 1000; // Return the new credentials // Note: Google does NOT return a new refresh_token on refresh @@ -297,13 +321,15 @@ async function handleRefreshToken(req, res) { token_type, scope, }); - } catch (error) { if (axios.isAxiosError(error) && error.response) { console.error('Error during token refresh:', error.response.data); res.status(error.response.status).json(error.response.data); } else { - console.error('Error during token refresh:', error instanceof Error ? error.message : error); + console.error( + 'Error during token refresh:', + error instanceof Error ? error.message : error, + ); res.status(500).send('An error occurred during token refresh.'); } } @@ -315,7 +341,10 @@ async function handleRefreshToken(req, res) { */ functions.http('oauthHandler', async (req, res) => { // Route to refresh handler if path ends with /refresh or /refreshToken or it's a POST with refresh_token - if (['/refresh', '/refreshToken'].includes(req.path) || (req.method === 'POST' && req.body?.refresh_token)) { + if ( + ['/refresh', '/refreshToken'].includes(req.path) || + (req.method === 'POST' && req.body?.refresh_token) + ) { return handleRefreshToken(req, res); } @@ -325,5 +354,9 @@ functions.http('oauthHandler', async (req, res) => { } // Default/Error case - res.status(400).send('Unknown request type. Expected OAuth callback or token refresh request.'); + res + .status(400) + .send( + 'Unknown request type. Expected OAuth callback or token refresh request.', + ); }); diff --git a/cloud_function/package.json b/cloud_function/package.json index 80383db..483a3bd 100644 --- a/cloud_function/package.json +++ b/cloud_function/package.json @@ -7,4 +7,4 @@ "@google-cloud/secret-manager": "^5.0.0", "axios": "^1.0.0" } -} \ No newline at end of file +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 0ad89ed..4b9efe7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,17 +1,17 @@ -import { defineConfig } from 'vitepress' +import { defineConfig } from 'vitepress'; // https://vitepress.dev/reference/site-config export default defineConfig({ base: '/workspace/', - title: "Gemini Workspace Extension", - description: "Documentation for the Google Workspace Server Extension", + title: 'Gemini Workspace Extension', + description: 'Documentation for the Google Workspace Server Extension', themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ { text: 'Home', link: '/' }, { text: 'Development', link: '/development' }, { text: 'Release', link: '/release' }, - { text: 'Release Notes', link: '/release_notes' } + { text: 'Release Notes', link: '/release_notes' }, ], sidebar: [ @@ -21,13 +21,16 @@ export default defineConfig({ { text: 'Overview', link: '/' }, { text: 'Development Guide', link: '/development' }, { text: 'Release Guide', link: '/release' }, - { text: 'Release Notes', link: '/release_notes' } - ] - } + { text: 'Release Notes', link: '/release_notes' }, + ], + }, ], socialLinks: [ - { icon: 'github', link: 'https://github.com/gemini-cli-extensions/workspace' } - ] - } -}) + { + icon: 'github', + link: 'https://github.com/gemini-cli-extensions/workspace', + }, + ], + }, +}); diff --git a/docs/development.md b/docs/development.md index 342b270..1db9637 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,18 +1,23 @@ # Development -This document provides instructions for developing the Google Workspace extension. +This document provides instructions for developing the Google Workspace +extension. ## Development Setup and Workflow -This section guides contributors on how to build, modify, and understand the development setup of this project. +This section guides contributors on how to build, modify, and understand the +development setup of this project. ### Setting Up the Development Environment **Prerequisites:** 1. **Node.js**: - - **Development:** Please use Node.js `~20.19.0`. This specific version is required due to an upstream development dependency issue. You can use a tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions. - - **Production:** For running the CLI in a production environment, any version of Node.js `>=20` is acceptable. + - **Development:** Please use Node.js `~20.19.0`. This specific version is + required due to an upstream development dependency issue. You can use a + tool like [nvm](https://github.com/nvm-sh/nvm) to manage Node.js versions. + - **Production:** For running the CLI in a production environment, any + version of Node.js `>=20` is acceptable. 2. **Git** ### Build Process @@ -36,7 +41,9 @@ To build the entire project (all packages): npm run build ``` -This command typically compiles TypeScript to JavaScript, bundles assets, and prepares the packages for execution. Refer to `scripts/build.js` and `package.json` scripts for more details on what happens during the build. +This command typically compiles TypeScript to JavaScript, bundles assets, and +prepares the packages for execution. Refer to `scripts/build.js` and +`package.json` scripts for more details on what happens during the build. ### Running Tests @@ -50,11 +57,14 @@ To execute the unit test suite for the project: npm run test ``` -This will run tests located in the `workspace-server/src/__tests__` directory. Ensure tests pass before submitting any changes. For a more comprehensive check, it is recommended to run `npm run test && npm run lint`. +This will run tests located in the `workspace-server/src/__tests__` directory. +Ensure tests pass before submitting any changes. For a more comprehensive check, +it is recommended to run `npm run test && npm run lint`. -To test a single file, you can pass its path from the project root as an argument. For example: +To test a single file, you can pass its path from the project root as an +argument. For example: -```bash +````bash npm run test -- workspace-server/src/__tests__/GmailService.test.ts ### Linting and Style Checks @@ -63,12 +73,13 @@ To ensure code quality and formatting consistency, run the linter and tests: ```bash npm run test && npm run lint -``` +```` -This command will run ESLint, Prettier, all tests, and other checks as defined in the project's `package.json`. +This command will run ESLint, Prettier, all tests, and other checks as defined +in the project's `package.json`. -> [!TIP] -> After cloning create a git pre-commit hook file to ensure your commits are always clean. +> [!TIP] After cloning create a git pre-commit hook file to ensure your commits +> are always clean. > > ```bash > cat <<'EOF' > .git/hooks/pre-commit @@ -84,17 +95,20 @@ This command will run ESLint, Prettier, all tests, and other checks as defined i #### Formatting -To separately format the code in this project by running the following command from the root directory: +To separately format the code in this project by running the following command +from the root directory: ```bash npm run format ``` -This command uses Prettier to format the code according to the project's style guidelines. +This command uses Prettier to format the code according to the project's style +guidelines. #### Linting -To separately lint the code in this project, run the following command from the root directory: +To separately lint the code in this project, run the following command from the +root directory: ```bash npm run lint @@ -115,9 +129,14 @@ gemini --debug ### Coding Conventions -- Please adhere to the coding style, patterns, and conventions used throughout the existing codebase. -- Consult [GEMINI.md](https://github.com/gemini-cli-extensions/workspace/blob/main/GEMINI.md) (typically found in the project root) for specific instructions related to AI-assisted development, including conventions for comments, and Git usage. -- **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. +- Please adhere to the coding style, patterns, and conventions used throughout + the existing codebase. +- Consult + [GEMINI.md](https://github.com/gemini-cli-extensions/workspace/blob/main/GEMINI.md) + (typically found in the project root) for specific instructions related to + AI-assisted development, including conventions for comments, and Git usage. +- **Imports:** Pay special attention to import paths. The project uses ESLint to + enforce restrictions on relative imports between packages. ### Project Structure @@ -132,7 +151,9 @@ gemini --debug ## Authentication -The extension uses OAuth 2.0 to authenticate with Google Workspace APIs. The `scripts/auth-utils.js` script provides a command-line interface to manage authentication credentials. +The extension uses OAuth 2.0 to authenticate with Google Workspace APIs. The +`scripts/auth-utils.js` script provides a command-line interface to manage +authentication credentials. ### Usage diff --git a/docs/index.md b/docs/index.md index 6930c22..0f1fef5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,62 +1,76 @@ # Google Workspace Extension Documentation -This document provides an overview of the Google Workspace extension for Gemini CLI. +This document provides an overview of the Google Workspace extension for Gemini +CLI. ## Available Tools The extension provides the following tools: ### Google Docs + - `docs.create`: Creates a new Google Doc. - `docs.insertText`: Inserts text at the beginning of a Google Doc. - `docs.find`: Finds Google Docs by searching for a query in their title. - `docs.move`: Moves a document to a specified folder. - `docs.getText`: Retrieves the text content of a Google Doc. - `docs.appendText`: Appends text to the end of a Google Doc. -- `docs.replaceText`: Replaces all occurrences of a given text with new text in a Google Doc. +- `docs.replaceText`: Replaces all occurrences of a given text with new text in + a Google Doc. - `docs.extractIdFromUrl`: Extracts the document ID from a Google Workspace URL. ### Google Slides + - `slides.getText`: Retrieves the text content of a Google Slides presentation. - `slides.find`: Finds Google Slides presentations by searching for a query. - `slides.getMetadata`: Gets metadata about a Google Slides presentation. ### Google Sheets + - `sheets.getText`: Retrieves the content of a Google Sheets spreadsheet. -- `sheets.getRange`: Gets values from a specific range in a Google Sheets spreadsheet. +- `sheets.getRange`: Gets values from a specific range in a Google Sheets + spreadsheet. - `sheets.find`: Finds Google Sheets spreadsheets by searching for a query. - `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet. ### Google Drive + - `drive.search`: Searches for files and folders in Google Drive. - `drive.findFolder`: Finds a folder by name in Google Drive. - `drive.createFolder`: Creates a new folder in Google Drive. - `drive.downloadFile`: Downloads a file from Google Drive to a local path. ### Google Calendar + - `calendar.list`: Lists all of the user's calendars. - `calendar.createEvent`: Creates a new event in a calendar. - `calendar.listEvents`: Lists events from a calendar. - `calendar.getEvent`: Gets the details of a specific calendar event. - `calendar.findFreeTime`: Finds a free time slot for multiple people to meet. - `calendar.updateEvent`: Updates an existing event in a calendar. -- `calendar.respondToEvent`: Responds to a meeting invitation (accept, decline, or tentative). +- `calendar.respondToEvent`: Responds to a meeting invitation (accept, decline, + or tentative). - `calendar.deleteEvent`: Deletes an event from a calendar. ### Google Chat + - `chat.listSpaces`: Lists the spaces the user is a member of. - `chat.findSpaceByName`: Finds a Google Chat space by its display name. - `chat.sendMessage`: Sends a message to a Google Chat space. - `chat.getMessages`: Gets messages from a Google Chat space. - `chat.sendDm`: Sends a direct message to a user. - `chat.findDmByEmail`: Finds a Google Chat DM space by a user's email address. -- `chat.listThreads`: Lists threads from a Google Chat space in reverse chronological order. -- `chat.setUpSpace`: Sets up a new Google Chat space with a display name and a list of members. +- `chat.listThreads`: Lists threads from a Google Chat space in reverse + chronological order. +- `chat.setUpSpace`: Sets up a new Google Chat space with a display name and a + list of members. ### Gmail + - `gmail.search`: Search for emails in Gmail using query parameters. - `gmail.get`: Get the full content of a specific email message. -- `gmail.downloadAttachment`: Downloads an attachment from a Gmail message to a local file. +- `gmail.downloadAttachment`: Downloads an attachment from a Gmail message to a + local file. - `gmail.modify`: Modify a Gmail message. - `gmail.send`: Send an email message. - `gmail.createDraft`: Create a draft email message. @@ -64,25 +78,34 @@ The extension provides the following tools: - `gmail.listLabels`: List all Gmail labels in the user's mailbox. ### Time -- `time.getCurrentDate`: Gets the current date. Returns both UTC (for API use) and local time (for user display), along with the timezone. -- `time.getCurrentTime`: Gets the current time. Returns both UTC (for API use) and local time (for user display), along with the timezone. + +- `time.getCurrentDate`: Gets the current date. Returns both UTC (for API use) + and local time (for user display), along with the timezone. +- `time.getCurrentTime`: Gets the current time. Returns both UTC (for API use) + and local time (for user display), along with the timezone. - `time.getTimeZone`: Gets the local timezone. ### People + - `people.getUserProfile`: Gets a user's profile information. - `people.getMe`: Gets the profile information of the authenticated user. -- `people.getUserRelations`: Gets a user's relations (e.g., manager, spouse, assistant). Defaults to the authenticated user and supports filtering by relation type. +- `people.getUserRelations`: Gets a user's relations (e.g., manager, spouse, + assistant). Defaults to the authenticated user and supports filtering by + relation type. ## Custom Commands The extension includes several pre-configured commands for common tasks: - `/calendar/get-schedule`: Show your schedule for today, or a specified date. -- `/calendar/clear-schedule`: Clear all events for a specific date or range by deleting or declining them. -- `/drive/search`: Searches Google Drive for files matching a query and displays their name and ID. -- `/gmail/search`: Searches for emails in Gmail matching a query and displays the sender, subject, and snippet. +- `/calendar/clear-schedule`: Clear all events for a specific date or range by + deleting or declining them. +- `/drive/search`: Searches Google Drive for files matching a query and displays + their name and ID. +- `/gmail/search`: Searches for emails in Gmail matching a query and displays + the sender, subject, and snippet. ## Release Notes -See the [Release Notes](release_notes.md) for details on new features and changes. - +See the [Release Notes](release_notes.md) for details on new features and +changes. diff --git a/docs/release.md b/docs/release.md index 0b22779..a3eb65b 100644 --- a/docs/release.md +++ b/docs/release.md @@ -11,18 +11,25 @@ This project uses GitHub Actions to automate the release process. To streamline the release process: -1. **Update Version**: Run the `set-version` script to update the version in `package.json` files. The `workspace-server` will now dynamically read its version from its `package.json`. +1. **Update Version**: Run the `set-version` script to update the version in + `package.json` files. The `workspace-server` will now dynamically read its + version from its `package.json`. + ```bash npm run set-version #0.0.x for example ``` -2. **Commit Changes**: Commit the version bump and push the changes to `main` (either directly or via a PR). +2. **Commit Changes**: Commit the version bump and push the changes to `main` + (either directly or via a PR). + ```bash git commit -am "chore: bump version to " git push origin main ``` -3. **Create Release**: Use the `gh release create` command. This will trigger the GitHub Actions workflow to build the extension and attach the artifacts to the release. +3. **Create Release**: Use the `gh release create` command. This will trigger + the GitHub Actions workflow to build the extension and attach the artifacts + to the release. ```bash # Syntax: gh release create --generate-notes @@ -31,10 +38,13 @@ To streamline the release process: ### What happens next? -1. **GitHub Actions Trigger**: The `release.yml` workflow is triggered by the new tag. +1. **GitHub Actions Trigger**: The `release.yml` workflow is triggered by the + new tag. 2. **Build**: The workflow builds the project using `npm run build`. -3. **Package**: It creates a `workspace-server.tar.gz` file containing the extension. -4. **Upload**: The workflow uploads the tarball to the release you just created. +3. **Package**: It creates a `workspace-server.tar.gz` file containing the + extension. +4. **Upload**: The workflow uploads the tarball to the release you just + created. ## Manual Release (Alternative) @@ -45,4 +55,6 @@ git tag v1.0.0 git push origin v1.0.0 ``` -This pushes the tag to GitHub, which triggers the release workflow to create a release and upload the artifacts. However, using `gh release create` is recommended as it allows you to easily generate release notes. +This pushes the tag to GitHub, which triggers the release workflow to create a +release and upload the artifacts. However, using `gh release create` is +recommended as it allows you to easily generate release notes. diff --git a/docs/release_notes.md b/docs/release_notes.md index b73ff44..7607c45 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -5,25 +5,32 @@ ### New Features - **Google Drive**: Added `drive.createFolder` to create new folders. -- **People**: Added `people.getUserRelations` to retrieve user relationships (manager, reports, etc.). -- **Google Chat**: Added threading support to `chat.sendMessage` and `chat.sendDm`, and filtering by thread in `chat.getMessages`. +- **People**: Added `people.getUserRelations` to retrieve user relationships + (manager, reports, etc.). +- **Google Chat**: Added threading support to `chat.sendMessage` and + `chat.sendDm`, and filtering by thread in `chat.getMessages`. - **Gmail**: Added `gmail.downloadAttachment` to download email attachments. -- **Google Drive**: Added `drive.downloadFile` to download files from Google Drive. +- **Google Drive**: Added `drive.downloadFile` to download files from Google + Drive. - **Calendar**: Added `calendar.deleteEvent` to delete calendar events. - **Google Docs**: Added support for Tabs in DocsService. ### Improvements -- **Dependencies**: Updated various dependencies including `@googleapis/drive`, `google-googleapis`, and `jsdom`. -- **CI/CD**: Added a weekly preview release workflow and updated GitHub Actions versions. +- **Dependencies**: Updated various dependencies including `@googleapis/drive`, + `google-googleapis`, and `jsdom`. +- **CI/CD**: Added a weekly preview release workflow and updated GitHub Actions + versions. - **Testing**: Added documentation for the testing process with Gemini CLI. ### Fixes -- Fixed an issue where the `v` prefix was not stripped correctly in the release script. +- Fixed an issue where the `v` prefix was not stripped correctly in the release + script. - Fixed an issue with invalid assignees in dependabot config. - Fixed log directory creation. ## 0.0.3 -- Initial release with support for Google Docs, Sheets, Slides, Drive, Calendar, Gmail, Chat, Time, and People. +- Initial release with support for Google Docs, Sheets, Slides, Drive, Calendar, + Gmail, Chat, Time, and People. diff --git a/eslint.config.js b/eslint.config.js index 94d3f38..615914d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,93 +10,104 @@ const licenseHeader = require('eslint-plugin-license-header'); const importPlugin = require('eslint-plugin-import'); module.exports = [ - { - ignores: ['**/dist/', '*.js', '**/node_modules/', '**/coverage/', '!eslint.config.js', '**/docs/.vitepress/cache/', '**/docs/.vitepress/dist/'], + { + ignores: [ + '**/dist/', + '*.js', + '**/node_modules/', + '**/coverage/', + '!eslint.config.js', + '**/docs/.vitepress/cache/', + '**/docs/.vitepress/dist/', + ], + }, + { + files: ['workspace-server/src/**/*.ts'], + ignores: ['**/*.test.ts', '**/*.spec.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: true, + tsconfigRootDir: __dirname, + ecmaVersion: 2020, + sourceType: 'module', + }, }, - { - files: ['workspace-server/src/**/*.ts'], - ignores: ['**/*.test.ts', '**/*.spec.ts'], - languageOptions: { - parser: tsParser, - parserOptions: { - project: true, - tsconfigRootDir: __dirname, - ecmaVersion: 2020, - sourceType: 'module', - }, - }, - plugins: { - '@typescript-eslint': tseslint, - }, - rules: { - ...tseslint.configs.recommended.rules, - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - 'prefer-const': 'warn', - }, + plugins: { + '@typescript-eslint': tseslint, }, - { - files: ['workspace-server/src/**/*.test.ts', 'workspace-server/src/**/*.spec.ts'], - languageOptions: { - parser: tsParser, - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - }, - }, - plugins: { - '@typescript-eslint': tseslint, - }, - rules: { - ...tseslint.configs.recommended.rules, - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - 'prefer-const': 'warn', + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', }, + ], + 'prefer-const': 'warn', }, - { - files: ['./**/*.{tsx,ts,js}'], - ignores: ['workspace-server/src/index.ts'], // Has shebang which conflicts with license header - plugins: { - 'license-header': licenseHeader, - import: importPlugin, - }, - rules: { - 'license-header/header': [ - 'error', - [ - '/**', - ' * @license', - ' * Copyright 2025 Google LLC', - ' * SPDX-License-Identifier: Apache-2.0', - ' */', - ], - ], - 'import/enforce-node-protocol-usage': ['error', 'always'], - }, + }, + { + files: [ + 'workspace-server/src/**/*.test.ts', + 'workspace-server/src/**/*.spec.ts', + ], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', }, - { - files: ['workspace-server/src/index.ts'], - plugins: { - import: importPlugin, - }, - rules: { - 'import/enforce-node-protocol-usage': ['error', 'always'], + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', }, - }, -]; \ No newline at end of file + ], + 'prefer-const': 'warn', + }, + }, + { + files: ['./**/*.{tsx,ts,js}'], + ignores: ['workspace-server/src/index.ts'], // Has shebang which conflicts with license header + plugins: { + 'license-header': licenseHeader, + import: importPlugin, + }, + rules: { + 'license-header/header': [ + 'error', + [ + '/**', + ' * @license', + ' * Copyright 2025 Google LLC', + ' * SPDX-License-Identifier: Apache-2.0', + ' */', + ], + ], + 'import/enforce-node-protocol-usage': ['error', 'always'], + }, + }, + { + files: ['workspace-server/src/index.ts'], + plugins: { + import: importPlugin, + }, + rules: { + 'import/enforce-node-protocol-usage': ['error', 'always'], + }, + }, +]; diff --git a/gemini-extension.json b/gemini-extension.json index c6e94c3..40ece99 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -6,10 +6,8 @@ "google-workspace": { "program": "./workspace-server/dist/index.js", "command": "node", - "args": [ - "scripts${/}start.js" - ], + "args": ["scripts${/}start.js"], "cwd": "${extensionPath}" } } -} \ No newline at end of file +} diff --git a/jest.config.js b/jest.config.js index 90da36a..75fc1cd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,17 +5,21 @@ module.exports = { projects: [ { displayName: 'workspace-server', - testMatch: ['/workspace-server/src/**/*.test.ts', '/workspace-server/src/**/*.spec.ts'], + testMatch: [ + '/workspace-server/src/**/*.test.ts', + '/workspace-server/src/**/*.spec.ts', + ], transform: { - '^.+\\.ts$': ['ts-jest', { - tsconfig: { - strict: false - } - }], + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: { + strict: false, + }, + }, + ], }, - transformIgnorePatterns: [ - 'node_modules/(?!(marked)/)', - ], + transformIgnorePatterns: ['node_modules/(?!(marked)/)'], moduleNameMapper: { '^@/(.*)$': '/workspace-server/src/$1', '\\.wasm$': '/workspace-server/src/__tests__/mocks/wasm.js', @@ -40,9 +44,9 @@ module.exports = { statements: 60, }, }, - } + }, ], coverageReporters: ['text', 'lcov', 'html'], testTimeout: 10000, verbose: true, -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index 38077fd..dc799d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -218,7 +218,6 @@ "integrity": "sha512-22SHEEVNjZfFWkFks3P6HilkR3rS7a6GjnCIqR22Zz4HNxdfT0FG+RE7efTcFVfLUkTTMQQybvaUcwMrHXYa7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.46.0", "@algolia/requester-browser-xhr": "5.46.0", @@ -397,7 +396,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -991,7 +989,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1036,7 +1033,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3401,7 +3397,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3493,7 +3488,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -4288,7 +4282,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4390,7 +4383,6 @@ "integrity": "sha512-7ML6fa2K93FIfifG3GMWhDEwT5qQzPTmoHKCTvhzGEwdbQ4n0yYUWZlLYT75WllTGJCJtNUI0C1ybN4BCegqvg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@algolia/abtesting": "1.12.0", "@algolia/client-abtesting": "5.46.0", @@ -5011,7 +5003,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -6224,7 +6215,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6725,7 +6715,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -6947,7 +6936,6 @@ "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.3.0" } @@ -8370,7 +8358,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -10318,7 +10305,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11960,7 +11946,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12135,7 +12120,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12364,7 +12348,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12662,7 +12645,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -13195,7 +13177,6 @@ "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -13677,7 +13658,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/scripts/auth-utils.js b/scripts/auth-utils.js index f88872f..0f57b7a 100644 --- a/scripts/auth-utils.js +++ b/scripts/auth-utils.js @@ -4,7 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { OAuthCredentialStorage } = require('../workspace-server/dist/auth-utils.js'); +const { + OAuthCredentialStorage, +} = require('../workspace-server/dist/auth-utils.js'); async function clearAuth() { try { @@ -23,7 +25,7 @@ async function expireToken() { console.log('ℹ️ No credentials found to expire.'); return; } - + // Set expiry to 1 second ago credentials.expiry_date = Date.now() - 1000; await OAuthCredentialStorage.saveCredentials(credentials); @@ -42,16 +44,20 @@ async function showStatus() { console.log('ℹ️ No credentials found.'); return; } - + const now = Date.now(); const expiry = credentials.expiry_date; const hasRefreshToken = !!credentials.refresh_token; const hasAccessToken = !!credentials.access_token; const isExpired = expiry ? expiry < now : false; - + console.log('📊 Auth Status:'); - console.log(` Access Token: ${hasAccessToken ? '✅ Present' : '❌ Missing'}`); - console.log(` Refresh Token: ${hasRefreshToken ? '✅ Present' : '❌ Missing'}`); + console.log( + ` Access Token: ${hasAccessToken ? '✅ Present' : '❌ Missing'}`, + ); + console.log( + ` Refresh Token: ${hasRefreshToken ? '✅ Present' : '❌ Missing'}`, + ); if (expiry) { console.log(` Expiry: ${new Date(expiry).toISOString()}`); console.log(` Status: ${isExpired ? '❌ EXPIRED' : '✅ Valid'}`); diff --git a/scripts/list-deps.js b/scripts/list-deps.js index c5ef70f..54b24dd 100644 --- a/scripts/list-deps.js +++ b/scripts/list-deps.js @@ -20,6 +20,8 @@ console.log(`Analyzing dependencies for: ${targetPackages.join(', ')}`); const allDeps = getTransitiveDependencies(root, targetPackages); console.log('\nTransitive Dependencies:'); -Array.from(allDeps).sort().forEach(dep => { - console.log(`- ${dep}`); -}); +Array.from(allDeps) + .sort() + .forEach((dep) => { + console.log(`- ${dep}`); + }); diff --git a/scripts/release.js b/scripts/release.js index 4f98248..478a9e2 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -30,7 +30,7 @@ const main = async () => { const platform = argv.platform; if (platform && typeof platform !== 'string') { console.error( - 'Error: The --platform argument must be a string (e.g., --platform=linux).' + 'Error: The --platform argument must be a string (e.g., --platform=linux).', ); process.exit(1); } @@ -55,7 +55,7 @@ const main = async () => { fs.cpSync( path.join(workspaceMcpServerDir, 'dist'), path.join(archiveDir, 'dist'), - { recursive: true } + { recursive: true }, ); // Clean up the dist directory @@ -70,11 +70,11 @@ const main = async () => { // Copy native modules and dependencies (keytar, jsdom) const nodeModulesDir = path.join(archiveDir, 'node_modules'); fs.mkdirSync(nodeModulesDir, { recursive: true }); - + const { getTransitiveDependencies } = require('./utils/dependencies'); const visited = getTransitiveDependencies(rootDir, ['keytar', 'jsdom']); - visited.forEach(pkg => { + visited.forEach((pkg) => { const source = path.join(rootDir, 'node_modules', pkg); const dest = path.join(nodeModulesDir, pkg); if (fs.existsSync(source)) { @@ -83,7 +83,10 @@ const main = async () => { }); const packageJson = require('../package.json'); - const version = (process.env.GITHUB_REF_NAME || packageJson.version).replace(/^v/, ''); + const version = (process.env.GITHUB_REF_NAME || packageJson.version).replace( + /^v/, + '', + ); // Generate the gemini-extension.json file const geminiExtensionJson = { @@ -100,22 +103,23 @@ const main = async () => { }; fs.writeFileSync( path.join(archiveDir, 'gemini-extension.json'), - JSON.stringify(geminiExtensionJson, null, 2) + JSON.stringify(geminiExtensionJson, null, 2), ); // Copy the WORKSPACE-Context.md file fs.copyFileSync( path.join(workspaceMcpServerDir, 'WORKSPACE-Context.md'), - path.join(archiveDir, 'WORKSPACE-Context.md') + path.join(archiveDir, 'WORKSPACE-Context.md'), ); // Copy the commands directory const commandsDir = path.join(rootDir, 'commands'); if (fs.existsSync(commandsDir)) { - fs.cpSync(commandsDir, path.join(archiveDir, 'commands'), { recursive: true }); + fs.cpSync(commandsDir, path.join(archiveDir, 'commands'), { + recursive: true, + }); } - // Create the archive const output = fs.createWriteStream(path.join(releaseDir, archiveName)); const archive = archiver('tar', { @@ -126,7 +130,7 @@ const main = async () => { output.on('close', function () { console.log(archive.pointer() + ' total bytes'); console.log( - 'archiver has been finalized and the output file descriptor has closed.' + 'archiver has been finalized and the output file descriptor has closed.', ); resolve(); }); @@ -143,7 +147,7 @@ const main = async () => { await archivePromise; }; -main().catch(err => { - console.error(err); - process.exit(1); +main().catch((err) => { + console.error(err); + process.exit(1); }); diff --git a/scripts/set-version.js b/scripts/set-version.js index e0659fd..7d521c4 100644 --- a/scripts/set-version.js +++ b/scripts/set-version.js @@ -9,22 +9,35 @@ const path = require('node:path'); const rootDir = path.join(__dirname, '..'); const packageJsonPath = path.join(rootDir, 'package.json'); -const workspaceServerPackageJsonPath = path.join(rootDir, 'workspace-server', 'package.json'); -const workspaceServerIndexPath = path.join(rootDir, 'workspace-server', 'src', 'index.ts'); +const workspaceServerPackageJsonPath = path.join( + rootDir, + 'workspace-server', + 'package.json', +); +const workspaceServerIndexPath = path.join( + rootDir, + 'workspace-server', + 'src', + 'index.ts', +); const updateJsonFile = (filePath, version) => { try { const content = JSON.parse(fs.readFileSync(filePath, 'utf8')); content.version = version; fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n'); - console.log(`Updated ${path.relative(rootDir, filePath)} to version ${version}`); + console.log( + `Updated ${path.relative(rootDir, filePath)} to version ${version}`, + ); } catch (error) { - console.error(`Failed to update JSON file at ${path.relative(rootDir, filePath)}:`, error); + console.error( + `Failed to update JSON file at ${path.relative(rootDir, filePath)}:`, + error, + ); process.exit(1); } }; - const main = () => { let version = process.argv[2]; @@ -44,7 +57,6 @@ const main = () => { } updateJsonFile(workspaceServerPackageJsonPath, version); - }; main(); diff --git a/scripts/start.js b/scripts/start.js index 26f7fd8..fb55667 100755 --- a/scripts/start.js +++ b/scripts/start.js @@ -19,7 +19,11 @@ function runCommand(command, args, options) { child.on('close', (code) => { if (code !== 0) { - reject(new Error(`Command failed with code ${code}: ${command} ${args.join(' ')}`)); + reject( + new Error( + `Command failed with code ${code}: ${command} ${args.join(' ')}`, + ), + ); } else { resolve(); } @@ -32,9 +36,17 @@ function runCommand(command, args, options) { async function main() { try { - await runCommand('npm', ['install'], { stdio: ['ignore', 'ignore', 'pipe'] }); + await runCommand('npm', ['install'], { + stdio: ['ignore', 'ignore', 'pipe'], + }); - const SERVER_PATH = path.join(__dirname, '..', 'workspace-server', 'dist', 'index.js'); + const SERVER_PATH = path.join( + __dirname, + '..', + 'workspace-server', + 'dist', + 'index.js', + ); await runCommand('node', [SERVER_PATH, '--debug'], { stdio: 'inherit' }); } catch (error) { console.error(error); @@ -42,4 +54,4 @@ async function main() { } } -main(); \ No newline at end of file +main(); diff --git a/scripts/utils/dependencies.js b/scripts/utils/dependencies.js index 30997b0..87181e2 100644 --- a/scripts/utils/dependencies.js +++ b/scripts/utils/dependencies.js @@ -38,7 +38,7 @@ function getTransitiveDependencies(rootDir, startPkgs) { visited.add(pkg); const deps = getDependencies(rootDir, pkg); - deps.forEach(dep => { + deps.forEach((dep) => { if (!visited.has(dep)) { toVisit.push(dep); } @@ -50,5 +50,5 @@ function getTransitiveDependencies(rootDir, startPkgs) { module.exports = { getDependencies, - getTransitiveDependencies + getTransitiveDependencies, }; diff --git a/tsconfig.json b/tsconfig.json index 35cf736..38fe440 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,9 +12,7 @@ "declarationMap": true, "sourceMap": true }, - "include": [ - "workspace-server/src/**/*" - ], + "include": ["workspace-server/src/**/*"], "exclude": [ "node_modules", "**/node_modules", @@ -22,4 +20,4 @@ "**/*.test.ts", "**/*.spec.ts" ] -} \ No newline at end of file +} diff --git a/workspace-server/.github/workflows/ci.yml b/workspace-server/.github/workflows/ci.yml index 99adb15..be5fa32 100644 --- a/workspace-server/.github/workflows/ci.yml +++ b/workspace-server/.github/workflows/ci.yml @@ -2,96 +2,96 @@ name: CI on: push: - branches: [ main, develop ] + branches: [main, develop] pull_request: - branches: [ main ] + branches: [main] jobs: test: runs-on: ubuntu-latest - + strategy: matrix: node-version: [18.x, 20.x] - + steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - cache-dependency-path: workspace-mcp-server/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: workspace-mcp-server - - - name: Run linter - run: npm run lint --if-present - working-directory: workspace-mcp-server - - - name: Run type checking - run: npx tsc --noEmit - working-directory: workspace-mcp-server - - - name: Run tests - run: npm test - working-directory: workspace-mcp-server - - - name: Generate coverage report - run: npm run test:coverage - working-directory: workspace-mcp-server - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - directory: ./workspace-mcp-server/coverage - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: workspace-mcp-server/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: workspace-mcp-server + + - name: Run linter + run: npm run lint --if-present + working-directory: workspace-mcp-server + + - name: Run type checking + run: npx tsc --noEmit + working-directory: workspace-mcp-server + + - name: Run tests + run: npm test + working-directory: workspace-mcp-server + + - name: Generate coverage report + run: npm run test:coverage + working-directory: workspace-mcp-server + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + directory: ./workspace-mcp-server/coverage + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false build: runs-on: ubuntu-latest needs: test - + steps: - - uses: actions/checkout@v4 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - cache-dependency-path: workspace-mcp-server/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: workspace-mcp-server - - - name: Build - run: npm run build - working-directory: workspace-mcp-server - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: workspace-mcp-server/dist/ + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: workspace-mcp-server/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: workspace-mcp-server + + - name: Build + run: npm run build + working-directory: workspace-mcp-server + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: workspace-mcp-server/dist/ security: runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 - - - name: Run security audit - run: npm audit --audit-level=moderate - working-directory: workspace-mcp-server - continue-on-error: true - - - name: Check for known vulnerabilities - run: npx audit-ci --moderate - working-directory: workspace-mcp-server - continue-on-error: true \ No newline at end of file + - uses: actions/checkout@v4 + + - name: Run security audit + run: npm audit --audit-level=moderate + working-directory: workspace-mcp-server + continue-on-error: true + + - name: Check for known vulnerabilities + run: npx audit-ci --moderate + working-directory: workspace-mcp-server + continue-on-error: true diff --git a/workspace-server/.github/workflows/release.yml b/workspace-server/.github/workflows/release.yml index 313e2d6..398e616 100644 --- a/workspace-server/.github/workflows/release.yml +++ b/workspace-server/.github/workflows/release.yml @@ -8,46 +8,46 @@ on: jobs: release: runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - cache-dependency-path: workspace-mcp-server/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: workspace-mcp-server - - - name: Run tests - run: npm test - working-directory: workspace-mcp-server - - - name: Build - run: npm run build - working-directory: workspace-mcp-server - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: false - - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./workspace-mcp-server/dist/index.js - asset_name: workspace-mcp-server.js - asset_content_type: application/javascript \ No newline at end of file + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: workspace-mcp-server/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: workspace-mcp-server + + - name: Run tests + run: npm test + working-directory: workspace-mcp-server + + - name: Build + run: npm run build + working-directory: workspace-mcp-server + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false + + - name: Upload Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./workspace-mcp-server/dist/index.js + asset_name: workspace-mcp-server.js + asset_content_type: application/javascript diff --git a/workspace-server/WORKSPACE-Context.md b/workspace-server/WORKSPACE-Context.md index e5383b5..cfe54c0 100644 --- a/workspace-server/WORKSPACE-Context.md +++ b/workspace-server/WORKSPACE-Context.md @@ -1,25 +1,33 @@ # Google Workspace Extension - Behavioral Guide -This guide provides behavioral instructions for effectively using the Google Workspace Extension tools. For detailed parameter documentation, refer to the tool descriptions in the extension itself. +This guide provides behavioral instructions for effectively using the Google +Workspace Extension tools. For detailed parameter documentation, refer to the +tool descriptions in the extension itself. ## 🎯 Core Principles ### 1. User Context First + **Always establish user context at the beginning of interactions:** + - Use `people.getMe()` to understand who the user is - Use `time.getTimeZone()` to get the user's local timezone - Apply this context throughout all interactions - All time-based operations should respect the user's timezone ### 2. Safety and Transparency + **Never execute write operations without explicit confirmation:** + - Preview all changes before executing - Show complete details in a readable format - Wait for clear user approval - Give users the opportunity to review and cancel ### 3. Smart Tool Usage + **Choose the right approach for each task:** + - Tools automatically handle URL-to-ID conversion - don't extract IDs manually - Batch related operations when possible - Use pagination for large result sets @@ -28,9 +36,11 @@ This guide provides behavioral instructions for effectively using the Google Wor ## 📋 Output Formatting Standards ### Lists and Search Results + Always format multiple items as **numbered lists** for better readability: ✅ **Correct:** + ``` Found 3 documents: 1. Budget Report 2024 @@ -39,6 +49,7 @@ Found 3 documents: ``` ❌ **Incorrect:** + ``` Found 3 documents: - Budget Report 2024 @@ -47,6 +58,7 @@ Found 3 documents: ``` ### Write Operation Previews + Before any write operation, show a clear preview: ``` @@ -63,30 +75,39 @@ Should I create this event? ## 🔄 Multi-Tool Workflows ### Creating and Organizing Documents + When creating documents in specific folders: + 1. Create the document first 2. Then move it to the folder (if specified) 3. Confirm successful completion ### Calendar Scheduling Workflow + 1. Get user's timezone with `time.getTimeZone()` 2. Check availability with `calendar.listEvents()` 3. Create event with proper timezone handling 4. Always show times in user's local timezone ### Email Search and Response + 1. Search with `gmail.search()` using appropriate query syntax 2. Get full content with `gmail.get()` if needed 3. Preview any reply before sending 4. Use threading context when responding ### Adding/Removing Labels from Emails -1. For system labels, including "INBOX", "SPAM", "TRASH", "UNREAD", "STARRED", "IMPORTANT", the ID is the name itself. + +1. For system labels, including "INBOX", "SPAM", "TRASH", "UNREAD", "STARRED", + "IMPORTANT", the ID is the name itself. 2. For user created custom labels, retrieve label ID with `gmail.listLabels()`. -3. Use `gmail.modify()` to add or remove labels from emails with a single call using label IDs. +3. Use `gmail.modify()` to add or remove labels from emails with a single call + using label IDs. ### Event Deletion + When using `calendar.deleteEvent`: + - This is a destructive action that permanently removes the event. - For organizers, this cancels the event for all attendees. - For attendees, this only removes it from their own calendar. @@ -95,8 +116,11 @@ When using `calendar.deleteEvent`: ## 📅 Calendar Best Practices ### Understanding "Next Meeting" + When asked about "next meeting" or "today's schedule": -1. **Fetch the full day's context** - Use start of day (00:00:00) to end of day (23:59:59) + +1. **Fetch the full day's context** - Use start of day (00:00:00) to end of day + (23:59:59) 2. **Filter by response status** - Only show meetings where the user has: - Accepted the invitation - Not yet responded (needs to decide) @@ -108,12 +132,16 @@ When asked about "next meeting" or "today's schedule": - Keep full day context for follow-up questions ### Meeting Response Filtering + - **Default behavior**: Show only accepted and pending meetings -- **Declined meetings**: Exclude unless user asks "show me all meetings" or "including declined" +- **Declined meetings**: Exclude unless user asks "show me all meetings" or + "including declined" - **Use `attendeeResponseStatus`** parameter to filter appropriately -- This respects the user's time by not cluttering their schedule with irrelevant meetings +- This respects the user's time by not cluttering their schedule with irrelevant + meetings ### Timezone Management + - Always display times in the user's timezone - Convert all times appropriately before display - Include timezone abbreviation (EST, PST, etc.) for clarity @@ -121,19 +149,26 @@ When asked about "next meeting" or "today's schedule": ## 📧 Gmail & Chat Guidelines ### Search Strategies + - Use Gmail search syntax: `from:email@example.com is:unread` - Combine multiple criteria for precise results - Include SPAM/TRASH only when explicitly needed ### Threading and Context + - Maintain conversation context in replies - Reference previous messages when relevant - Use appropriate reply vs. new message based on context ### Downloading Attachments -1. **Find Attachment ID**: Use `gmail.get` with `format='full'` to retrieve message details, including `attachments` metadata (IDs and filenames). -2. **Download**: Use `gmail.downloadAttachment` with the specific `messageId` and `attachmentId`. -3. **Absolute Paths**: Always provide an **absolute path** for the `localPath` argument (e.g., `/Users/username/Downloads/file.pdf`). Relative paths will be rejected for security. + +1. **Find Attachment ID**: Use `gmail.get` with `format='full'` to retrieve + message details, including `attachments` metadata (IDs and filenames). +2. **Download**: Use `gmail.downloadAttachment` with the specific `messageId` + and `attachmentId`. +3. **Absolute Paths**: Always provide an **absolute path** for the `localPath` + argument (e.g., `/Users/username/Downloads/file.pdf`). Relative paths will be + rejected for security. ## 💬 Chat Guidelines @@ -143,32 +178,35 @@ syntax. ### Supported Formatting -- *bold* (single asterisks) +- _bold_ (single asterisks) - _italic_ (single underscores) - ~strikethrough~ - `inline code` -- ```code blocks``` -- Bulleted lists ("* " or "- " at line start) +- `code blocks` +- Bulleted lists ("\* " or "- " at line start) - Links: - User mentions: ### Unsupported (convert these) -- **double asterisks** for bold (convert to *single asterisks*) +- **double asterisks** for bold (convert to _single asterisks_) - [text](url) markdown links (convert to ) - Nested lists (flatten with dashes: "- parent", "- -- child") -- # headings (convert to *bold* text) +- # headings (convert to _bold_ text) - > blockquotes (preserve the `>` characters, do not remove them) ## 📄 Docs, Sheets, and Slides ### Format Selection (Sheets) + Choose output format based on use case: + - **text**: Human-readable, good for quick review - **csv**: Data export, analysis in other tools - **json**: Programmatic processing, structured data ### Content Handling + - Docs/Sheets/Slides tools accept URLs directly - no ID extraction needed - Use markdown for initial document creation when appropriate - Preserve formatting when reading/modifying content @@ -176,6 +214,7 @@ Choose output format based on use case: ## 🚫 Common Pitfalls to Avoid ### Don't Do This: + - ❌ Use `extractIdFromUrl` when other tools accept URLs - ❌ Assume timezone without checking - ❌ Execute writes without preview and confirmation @@ -184,26 +223,32 @@ Choose output format based on use case: - ❌ Use relative paths for file downloads (e.g., `downloads/file.txt`) ### Do This Instead: + - ✅ Pass URLs directly to tools that accept them - ✅ Get user timezone at session start - ✅ Preview all changes and wait for approval - ✅ Only create what's requested - ✅ Focus on behavioral guidance and best practices -- ✅ Always use **absolute paths** for file downloads (e.g., `/Users/me/Downloads/file.txt`) +- ✅ Always use **absolute paths** for file downloads (e.g., + `/Users/me/Downloads/file.txt`) ## 🔍 Error Handling Patterns ### Authentication Errors -- If any tool returns `{"error":"invalid_request"}`, it likely indicates an expired or invalid session. + +- If any tool returns `{"error":"invalid_request"}`, it likely indicates an + expired or invalid session. - **Action:** Call `auth.clear` to reset credentials and force a re-login. - Inform the user that you are resetting authentication due to an error. ### Graceful Degradation + - If a folder doesn't exist, offer to create it - If search returns no results, suggest alternatives - If permissions are insufficient, explain clearly ### Validation Before Action + - Verify file/folder existence before moving - Check calendar availability before scheduling - Validate email addresses before sending @@ -211,11 +256,13 @@ Choose output format based on use case: ## ⚡ Performance Optimization ### Batch Operations + - Group related API calls when possible - Use field masks to request only needed data - Implement pagination for large datasets ### Caching Strategy + - Reuse user context throughout session - Cache frequently accessed metadata - Minimize redundant API calls @@ -223,16 +270,19 @@ Choose output format based on use case: ## 📝 Session Management ### Beginning of Session + 1. Get user profile with `people.getMe()` 2. Get timezone with `time.getTimeZone()` 3. Establish any relevant context ### During Interaction + - Maintain context awareness - Apply user preferences consistently - Handle follow-up questions efficiently ### End of Session + - Confirm all requested tasks completed - Provide summary if multiple operations performed - Ensure no pending confirmations @@ -240,28 +290,35 @@ Choose output format based on use case: ## 🎨 Service-Specific Nuances ### Google Docs + - Support for markdown content creation - Automatic HTML conversion from markdown - Position-based text insertion (index 1 for beginning) ### Google Sheets + - Multiple output formats available - Range-based operations with A1 notation - Metadata includes sheet structure information ### Google Calendar + - Event creation requires both start and end times - Support for attendee management - Response status filtering available ### Gmail + - Full threading support - Label-based organization - Draft creation and management ### Google Chat + - Space vs. DM distinction - Thread-aware messaging - Unread message filtering -Remember: This guide focuses on **how to think** about using these tools effectively. For specific parameter details, refer to the tool descriptions themselves. +Remember: This guide focuses on **how to think** about using these tools +effectively. For specific parameter details, refer to the tool descriptions +themselves. diff --git a/workspace-server/esbuild.config.js b/workspace-server/esbuild.config.js index b9668a2..54a7549 100644 --- a/workspace-server/esbuild.config.js +++ b/workspace-server/esbuild.config.js @@ -20,15 +20,13 @@ async function build() { sourcemap: true, // Replace 'open' package with our wrapper alias: { - 'open': path.resolve(__dirname, 'src/utils/open-wrapper.ts') + open: path.resolve(__dirname, 'src/utils/open-wrapper.ts'), }, // External packages that shouldn't be bundled - external: [ - 'jsdom' - ], + external: ['jsdom'], // Add a loader for .node files loader: { - '.node': 'file' + '.node': 'file', }, // Make sure CommonJS modules work properly format: 'cjs', @@ -42,4 +40,4 @@ async function build() { } } -build(); \ No newline at end of file +build(); diff --git a/workspace-server/jest.config.js b/workspace-server/jest.config.js index 11b6c29..84fef28 100644 --- a/workspace-server/jest.config.js +++ b/workspace-server/jest.config.js @@ -9,4 +9,4 @@ module.exports = { // This workspace's tests are configured in the root jest.config.js // as part of the 'projects' array. This file is kept for backwards // compatibility and workspace-specific overrides if needed. -}; \ No newline at end of file +}; diff --git a/workspace-server/src/__tests__/auth/AuthManager.test.ts b/workspace-server/src/__tests__/auth/AuthManager.test.ts index dd00ced..dddaa1d 100644 --- a/workspace-server/src/__tests__/auth/AuthManager.test.ts +++ b/workspace-server/src/__tests__/auth/AuthManager.test.ts @@ -23,7 +23,7 @@ describe('AuthManager', () => { beforeEach(() => { jest.clearAllMocks(); - + // Setup mock OAuth2 client mockOAuth2Client = { setCredentials: jest.fn().mockImplementation((creds) => { @@ -32,44 +32,51 @@ describe('AuthManager', () => { generateAuthUrl: jest.fn(), on: jest.fn(), refreshAccessToken: jest.fn(), - credentials: {} + credentials: {}, }; - (google.auth.OAuth2 as unknown as jest.Mock).mockReturnValue(mockOAuth2Client); + (google.auth.OAuth2 as unknown as jest.Mock).mockReturnValue( + mockOAuth2Client, + ); authManager = new AuthManager(['scope1']); }); it('should set up tokens event listener on client creation', async () => { (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'old_token', - refresh_token: 'old_refresh', - scope: 'scope1' + access_token: 'old_token', + refresh_token: 'old_refresh', + scope: 'scope1', }); await authManager.getAuthenticatedClient(); // Verify 'on' was called for 'tokens' - expect(mockOAuth2Client.on).toHaveBeenCalledWith('tokens', expect.any(Function)); + expect(mockOAuth2Client.on).toHaveBeenCalledWith( + 'tokens', + expect.any(Function), + ); }); it('should save credentials when tokens event is emitted', async () => { (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'old_token', - refresh_token: 'old_refresh', - scope: 'scope1' + access_token: 'old_token', + refresh_token: 'old_refresh', + scope: 'scope1', }); await authManager.getAuthenticatedClient(); // Get the registered callback - const tokensCallback = mockOAuth2Client.on.mock.calls.find((call: any[]) => call[0] === 'tokens')[1]; + const tokensCallback = mockOAuth2Client.on.mock.calls.find( + (call: any[]) => call[0] === 'tokens', + )[1]; expect(tokensCallback).toBeDefined(); // Simulate tokens event const newTokens = { - access_token: 'new_token', - expiry_date: 123456789 + access_token: 'new_token', + expiry_date: 123456789, }; await tokensCallback(newTokens); @@ -77,99 +84,105 @@ describe('AuthManager', () => { // Verify saveCredentials was called with merged tokens // New tokens take precedence, but refresh_token is preserved from old credentials expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({ - access_token: 'new_token', - refresh_token: 'old_refresh', // Preserved from old credentials - expiry_date: 123456789 - // Note: scope is NOT preserved because newTokens didn't include it + access_token: 'new_token', + refresh_token: 'old_refresh', // Preserved from old credentials + expiry_date: 123456789, + // Note: scope is NOT preserved because newTokens didn't include it }); }); it('should preserve refresh token during manual refresh if not returned', async () => { // Setup initial state with a refresh token (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'old_token', - refresh_token: 'old_refresh_token', - scope: 'scope1' + access_token: 'old_token', + refresh_token: 'old_refresh_token', + scope: 'scope1', }); - + // Initialize client to populate this.client await authManager.getAuthenticatedClient(); - + // Mock fetch to simulate cloud function returning new tokens without refresh_token (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expiry_date: 999999999 - }) + ok: true, + json: async () => ({ + access_token: 'new_access_token', + expiry_date: 999999999, + }), }); await authManager.refreshToken(); // Verify saveCredentials was called with BOTH new access token AND old refresh token - expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(expect.objectContaining({ + expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'new_access_token', - refresh_token: 'old_refresh_token' - })); + refresh_token: 'old_refresh_token', + }), + ); }); it('should preserve refresh token when refreshAccessToken mutates credentials in-place', async () => { // Setup initial state with a refresh token (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'old_token', - refresh_token: 'old_refresh_token', - scope: 'scope1' + access_token: 'old_token', + refresh_token: 'old_refresh_token', + scope: 'scope1', }); - + // Initialize client to populate this.client await authManager.getAuthenticatedClient(); - + // Mock fetch to simulate cloud function returning new tokens without refresh_token (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'new_access_token', - expiry_date: 999999999 - }) + ok: true, + json: async () => ({ + access_token: 'new_access_token', + expiry_date: 999999999, + }), }); await authManager.refreshToken(); // This test verifies that the refresh_token is preserved even when // the cloud function doesn't return it in the response - expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith(expect.objectContaining({ + expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith( + expect.objectContaining({ access_token: 'new_access_token', - refresh_token: 'old_refresh_token' - })); + refresh_token: 'old_refresh_token', + }), + ); }); it('should preserve refresh token in tokens event handler', async () => { // Setup initial state with a refresh token in storage (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'old_token', - refresh_token: 'stored_refresh_token', - scope: 'scope1' + access_token: 'old_token', + refresh_token: 'stored_refresh_token', + scope: 'scope1', }); - + await authManager.getAuthenticatedClient(); - + // Get the registered callback - const tokensCallback = mockOAuth2Client.on.mock.calls.find((call: any[]) => call[0] === 'tokens')[1]; - + const tokensCallback = mockOAuth2Client.on.mock.calls.find( + (call: any[]) => call[0] === 'tokens', + )[1]; + // Simulate automatic refresh that doesn't include refresh_token const newTokens = { - access_token: 'auto_refreshed_token', - expiry_date: 999999999 - // Note: no refresh_token + access_token: 'auto_refreshed_token', + expiry_date: 999999999, + // Note: no refresh_token }; - + await tokensCallback(newTokens); - + // Verify saveCredentials was called with BOTH new access token AND stored refresh token expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith({ - access_token: 'auto_refreshed_token', - expiry_date: 999999999, - refresh_token: 'stored_refresh_token' + access_token: 'auto_refreshed_token', + expiry_date: 999999999, + refresh_token: 'stored_refresh_token', }); }); @@ -177,40 +190,40 @@ describe('AuthManager', () => { // Setup: Load credentials with expired token const expiredTime = Date.now() - 1000; // 1 second ago (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'expired_token', - refresh_token: 'valid_refresh', - expiry_date: expiredTime, - scope: 'scope1' + access_token: 'expired_token', + refresh_token: 'valid_refresh', + expiry_date: expiredTime, + scope: 'scope1', }); - + // Mock fetch to simulate cloud function returning fresh tokens (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'fresh_token', - expiry_date: Date.now() + 3600000 - }) + ok: true, + json: async () => ({ + access_token: 'fresh_token', + expiry_date: Date.now() + 3600000, + }), }); - + // First call: load expired credentials from storage, should trigger proactive refresh const firstClient = await authManager.getAuthenticatedClient(); expect(firstClient).toBeDefined(); - + // Verify fetch was called to refresh the token expect(global.fetch).toHaveBeenCalledWith( - 'https://google-workspace-extension.geminicli.com/refreshToken', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('valid_refresh') - }) + 'https://google-workspace-extension.geminicli.com/refreshToken', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('valid_refresh'), + }), ); - + // Verify new token was saved with preserved refresh_token expect(OAuthCredentialStorage.saveCredentials).toHaveBeenCalledWith( - expect.objectContaining({ - access_token: 'fresh_token', - refresh_token: 'valid_refresh' - }) + expect.objectContaining({ + access_token: 'fresh_token', + refresh_token: 'valid_refresh', + }), ); }); @@ -219,32 +232,32 @@ describe('AuthManager', () => { const TEST_EXPIRY_WITHIN_BUFFER = 4 * 60 * 1000; const expiresIn4Minutes = Date.now() + TEST_EXPIRY_WITHIN_BUFFER; (OAuthCredentialStorage.loadCredentials as jest.Mock).mockResolvedValue({ - access_token: 'soon_expiring_token', - refresh_token: 'valid_refresh', - expiry_date: expiresIn4Minutes, - scope: 'scope1' + access_token: 'soon_expiring_token', + refresh_token: 'valid_refresh', + expiry_date: expiresIn4Minutes, + scope: 'scope1', }); - + // Mock fetch to simulate cloud function returning fresh tokens (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'fresh_token', - expiry_date: Date.now() + 60 * 60 * 1000 - }) + ok: true, + json: async () => ({ + access_token: 'fresh_token', + expiry_date: Date.now() + 60 * 60 * 1000, + }), }); - + // Call getAuthenticatedClient const client = await authManager.getAuthenticatedClient(); expect(client).toBeDefined(); - + // Verify fetch was called to refresh the token because it was within buffer expect(global.fetch).toHaveBeenCalledWith( - 'https://google-workspace-extension.geminicli.com/refreshToken', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('valid_refresh') - }) + 'https://google-workspace-extension.geminicli.com/refreshToken', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('valid_refresh'), + }), ); }); }); diff --git a/workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts b/workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts index ac6a1c1..5fcf0c2 100644 --- a/workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts +++ b/workspace-server/src/__tests__/auth/token-storage/base-token-storage.test.ts @@ -6,7 +6,10 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; import { BaseTokenStorage } from '../../../auth/token-storage/base-token-storage'; -import type { OAuthCredentials, OAuthToken } from '../../../auth/token-storage/types'; +import type { + OAuthCredentials, + OAuthToken, +} from '../../../auth/token-storage/types'; class TestTokenStorage extends BaseTokenStorage { private storage = new Map(); @@ -40,8 +43,6 @@ class TestTokenStorage extends BaseTokenStorage { super.validateCredentials(credentials); } - - override sanitizeServerName(serverName: string): string { return super.sanitizeServerName(serverName); } @@ -139,8 +140,6 @@ describe('BaseTokenStorage', () => { }); }); - - describe('sanitizeServerName', () => { it('should keep valid characters', () => { expect(storage.sanitizeServerName('test-server.example_123')).toBe( @@ -160,4 +159,4 @@ describe('BaseTokenStorage', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts b/workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts index 56d0a7f..c659793 100644 --- a/workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts +++ b/workspace-server/src/__tests__/auth/token-storage/file-token-storage.test.ts @@ -388,4 +388,4 @@ describe('FileTokenStorage', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts b/workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts index 036b51a..0e1a09a 100644 --- a/workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts +++ b/workspace-server/src/__tests__/auth/token-storage/hybrid-token-storage.test.ts @@ -4,13 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; -import { type OAuthCredentials, TokenStorageType } from '../../../auth/token-storage/types'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from '@jest/globals'; +import { + type OAuthCredentials, + TokenStorageType, +} from '../../../auth/token-storage/types'; // Mock paths -const KEYCHAIN_TOKEN_STORAGE_PATH = '../../../auth/token-storage/keychain-token-storage'; -const FILE_TOKEN_STORAGE_PATH = '../../../auth/token-storage/file-token-storage'; -const HYBRID_TOKEN_STORAGE_PATH = '../../../auth/token-storage/hybrid-token-storage'; +const KEYCHAIN_TOKEN_STORAGE_PATH = + '../../../auth/token-storage/keychain-token-storage'; +const FILE_TOKEN_STORAGE_PATH = + '../../../auth/token-storage/file-token-storage'; +const HYBRID_TOKEN_STORAGE_PATH = + '../../../auth/token-storage/hybrid-token-storage'; interface MockStorage { isAvailable?: ReturnType; @@ -54,7 +67,9 @@ describe('HybridTokenStorage', () => { }; jest.doMock(KEYCHAIN_TOKEN_STORAGE_PATH, () => ({ - KeychainTokenStorage: jest.fn().mockImplementation(() => mockKeychainStorage), + KeychainTokenStorage: jest + .fn() + .mockImplementation(() => mockKeychainStorage), })); jest.mock(FILE_TOKEN_STORAGE_PATH, () => ({ @@ -258,4 +273,4 @@ describe('HybridTokenStorage', () => { expect(mockKeychainStorage.clearAll).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts b/workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts index 300284d..2583f3d 100644 --- a/workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts +++ b/workspace-server/src/__tests__/auth/token-storage/keychain-token-storage.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from '@jest/globals'; import type { KeychainTokenStorage } from '../../../auth/token-storage/keychain-token-storage'; import type { OAuthCredentials } from '../../../auth/token-storage/types'; import type keytar from 'keytar'; @@ -37,9 +44,8 @@ describe('KeychainTokenStorage', () => { mockKeytar = (await import('keytar')).default as jest.Mocked; // Now import the module we are testing, which will use the mock above. - const { KeychainTokenStorage } = await import( - '../../../auth/token-storage/keychain-token-storage' - ); + const { KeychainTokenStorage } = + await import('../../../auth/token-storage/keychain-token-storage'); storage = new KeychainTokenStorage(mockServiceName); }); @@ -349,4 +355,4 @@ describe('KeychainTokenStorage', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts b/workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts index fb106c2..4d43d40 100644 --- a/workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts +++ b/workspace-server/src/__tests__/auth/token-storage/oauth-credential-storage.test.ts @@ -107,4 +107,4 @@ describe('OAuthCredentialStorage', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/mocks/jsdom.ts b/workspace-server/src/__tests__/mocks/jsdom.ts index 40c5d74..96f3452 100644 --- a/workspace-server/src/__tests__/mocks/jsdom.ts +++ b/workspace-server/src/__tests__/mocks/jsdom.ts @@ -6,156 +6,155 @@ /** * Mock implementation of jsdom for Jest tests - * + * * This mock provides the minimal DOM functionality needed by our code: * 1. DocsService uses JSDOM to create a window object for DOMPurify * 2. markdownToDocsRequests uses JSDOM to parse HTML from marked */ class MockElement { - tagName: string; - nodeType: number; - childNodes: MockNode[]; - nextSibling: MockNode | null; - attributes: { [key: string]: string }; - - constructor(tagName: string) { - this.tagName = tagName; - this.nodeType = 1; // Element node - this.childNodes = []; - this.nextSibling = null; - this.attributes = {}; - } - - get textContent(): string { - return this.childNodes.map(child => child.textContent).join(''); - } - - - toLowerCase() { - return this.tagName.toLowerCase(); - } - - getAttribute(name: string): string | null { - return this.attributes[name] || null; - } + tagName: string; + nodeType: number; + childNodes: MockNode[]; + nextSibling: MockNode | null; + attributes: { [key: string]: string }; + + constructor(tagName: string) { + this.tagName = tagName; + this.nodeType = 1; // Element node + this.childNodes = []; + this.nextSibling = null; + this.attributes = {}; + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(''); + } + + toLowerCase() { + return this.tagName.toLowerCase(); + } + + getAttribute(name: string): string | null { + return this.attributes[name] || null; + } } class MockTextNode { - nodeType: number; - textContent: string; - childNodes: never[]; - nextSibling: MockNode | null; - - constructor(text: string) { - this.nodeType = 3; // Text node - this.textContent = text; - this.childNodes = []; - this.nextSibling = null; - } + nodeType: number; + textContent: string; + childNodes: never[]; + nextSibling: MockNode | null; + + constructor(text: string) { + this.nodeType = 3; // Text node + this.textContent = text; + this.childNodes = []; + this.nextSibling = null; + } } type MockNode = MockElement | MockTextNode; class MockDocument { - body: MockElement; - - constructor() { - this.body = new MockElement('BODY'); - } - - createElement(tagName: string): MockElement { - return new MockElement(tagName.toUpperCase()); - } - - querySelector(selector: string): MockElement | null { - // Simple implementation for our use case (mostly just tag names) - const tagName = selector.toUpperCase(); - - const queue: MockNode[] = [...this.body.childNodes]; - while (queue.length > 0) { - const node = queue.shift()!; - if (node instanceof MockElement) { - if (node.tagName === tagName) { - return node; - } - queue.push(...node.childNodes); - } + body: MockElement; + + constructor() { + this.body = new MockElement('BODY'); + } + + createElement(tagName: string): MockElement { + return new MockElement(tagName.toUpperCase()); + } + + querySelector(selector: string): MockElement | null { + // Simple implementation for our use case (mostly just tag names) + const tagName = selector.toUpperCase(); + + const queue: MockNode[] = [...this.body.childNodes]; + while (queue.length > 0) { + const node = queue.shift()!; + if (node instanceof MockElement) { + if (node.tagName === tagName) { + return node; } - - return null; + queue.push(...node.childNodes); + } } + + return null; + } } class MockWindow { - document: MockDocument; - DOMParser: typeof MockDOMParser; + document: MockDocument; + DOMParser: typeof MockDOMParser; - constructor() { - this.document = new MockDocument(); - this.DOMParser = MockDOMParser; - } + constructor() { + this.document = new MockDocument(); + this.DOMParser = MockDOMParser; + } } class MockDOMParser { - parseFromString(html: string): { body: MockElement } { - const body = new MockElement('BODY'); - this.parseNodes(html, body); - return { body }; - } - - private parseNodes(html: string, parent: MockElement) { - // Parse simple HTML tags and text - // Note: This regex is very simple and won't handle attributes or self-closing tags well - // but it's sufficient for the markdown output we're testing - const tagRegex = /<(\w+)(?:\s+[^>]*)?>(.*?)<\/\1>|([^<]+)/gs; - let match; - - while ((match = tagRegex.exec(html)) !== null) { - if (match[1]) { - // It's a tag - const tagName = match[1].toUpperCase(); - const element = new MockElement(tagName); - const content = match[2]; - - // Handle attributes (simple href for links) - const fullTag = match[0]; - const hrefMatch = fullTag.match(/href=["']([^"']*)["']/); - if (hrefMatch) { - element.attributes['href'] = hrefMatch[1]; - } - - // Recursively parse content - this.parseNodes(content, element); - - parent.childNodes.push(element); - } else if (match[3]) { - // It's text content - const text = match[3]; - if (text) { - const textNode = new MockTextNode(text); - parent.childNodes.push(textNode); - } - } + parseFromString(html: string): { body: MockElement } { + const body = new MockElement('BODY'); + this.parseNodes(html, body); + return { body }; + } + + private parseNodes(html: string, parent: MockElement) { + // Parse simple HTML tags and text + // Note: This regex is very simple and won't handle attributes or self-closing tags well + // but it's sufficient for the markdown output we're testing + const tagRegex = /<(\w+)(?:\s+[^>]*)?>(.*?)<\/\1>|([^<]+)/gs; + let match; + + while ((match = tagRegex.exec(html)) !== null) { + if (match[1]) { + // It's a tag + const tagName = match[1].toUpperCase(); + const element = new MockElement(tagName); + const content = match[2]; + + // Handle attributes (simple href for links) + const fullTag = match[0]; + const hrefMatch = fullTag.match(/href=["']([^"']*)["']/); + if (hrefMatch) { + element.attributes['href'] = hrefMatch[1]; } - - // Set next sibling references - for (let i = 0; i < parent.childNodes.length - 1; i++) { - parent.childNodes[i].nextSibling = parent.childNodes[i + 1]; + + // Recursively parse content + this.parseNodes(content, element); + + parent.childNodes.push(element); + } else if (match[3]) { + // It's text content + const text = match[3]; + if (text) { + const textNode = new MockTextNode(text); + parent.childNodes.push(textNode); } + } + } + + // Set next sibling references + for (let i = 0; i < parent.childNodes.length - 1; i++) { + parent.childNodes[i].nextSibling = parent.childNodes[i + 1]; } + } } export class JSDOM { - window: MockWindow; - - constructor(html?: string) { - this.window = new MockWindow(); - - if (html) { - const parser = new MockDOMParser(); - const parsed = parser.parseFromString(html); - this.window.document.body = parsed.body; - } + window: MockWindow; + + constructor(html?: string) { + this.window = new MockWindow(); + + if (html) { + const parser = new MockDOMParser(); + const parsed = parser.parseFromString(html); + this.window.document.body = parsed.body; } + } } diff --git a/workspace-server/src/__tests__/mocks/marked.js b/workspace-server/src/__tests__/mocks/marked.js index e0eaaf3..b8922af 100644 --- a/workspace-server/src/__tests__/mocks/marked.js +++ b/workspace-server/src/__tests__/mocks/marked.js @@ -18,10 +18,10 @@ markedMock.parse = jest.fn((text) => { markedMock.parseInline = jest.fn((text) => { // Simple markdown to HTML conversion for testing return text - .replace(/\*\*(.*?)\*\*/g, '$1') // **bold** -> bold - .replace(/\*(.*?)\*/g, '$1') // *italic* -> italic - .replace(/_(.*?)_/g, '$1') // _italic_ -> italic - .replace(/`(.*?)`/g, '$1'); // `code` -> code + .replace(/\*\*(.*?)\*\*/g, '$1') // **bold** -> bold + .replace(/\*(.*?)\*/g, '$1') // *italic* -> italic + .replace(/_(.*?)_/g, '$1') // _italic_ -> italic + .replace(/`(.*?)`/g, '$1'); // `code` -> code }); markedMock.use = jest.fn(); markedMock.setOptions = jest.fn(); @@ -53,4 +53,4 @@ module.exports = { setOptions: markedMock.setOptions, getDefaults: markedMock.getDefaults, defaults: markedMock.defaults, -}; \ No newline at end of file +}; diff --git a/workspace-server/src/__tests__/mocks/wasm.js b/workspace-server/src/__tests__/mocks/wasm.js index a275406..e4c6cd7 100644 --- a/workspace-server/src/__tests__/mocks/wasm.js +++ b/workspace-server/src/__tests__/mocks/wasm.js @@ -4,4 +4,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 261f4ad..4179f05 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { CalendarService } from '../../services/CalendarService'; import { google } from 'googleapis'; @@ -75,10 +82,10 @@ describe('CalendarService', () => { const result = await calendarService.listCalendars(); expect(mockCalendarAPI.calendarList.list).toHaveBeenCalledTimes(1); - - const expectedResult = mockCalendars.map(c => ({ - id: c.id, - summary: c.summary + + const expectedResult = mockCalendars.map((c) => ({ + id: c.id, + summary: c.summary, })); expect(JSON.parse(result.content[0].text)).toEqual(expectedResult); }); @@ -102,7 +109,9 @@ describe('CalendarService', () => { const result = await calendarService.listCalendars(); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'Calendar API failed' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'Calendar API failed', + }); }); it('should handle undefined items in response', async () => { @@ -242,7 +251,9 @@ describe('CalendarService', () => { const errorResponse = JSON.parse(result.content[0].text); expect(errorResponse.error).toBe('Invalid input format'); - expect(errorResponse.details).toContain('Invalid ISO 8601 datetime format'); + expect(errorResponse.details).toContain( + 'Invalid ISO 8601 datetime format', + ); }); }); @@ -289,7 +300,8 @@ describe('CalendarService', () => { timeMin: '2024-01-15T00:00:00Z', timeMax: '2024-01-16T00:00:00Z', singleEvents: true, - fields: 'items(id,summary,start,end,description,htmlLink,attendees,status)', + fields: + 'items(id,summary,start,end,description,htmlLink,attendees,status)', }); expect(JSON.parse(result.content[0].text)).toEqual(mockEvents); @@ -330,7 +342,8 @@ describe('CalendarService', () => { timeMin: '2024-01-15T00:00:00Z', timeMax: '2024-01-16T00:00:00Z', singleEvents: true, - fields: 'items(id,summary,start,end,description,htmlLink,attendees,status)', + fields: + 'items(id,summary,start,end,description,htmlLink,attendees,status)', }); expect(JSON.parse(result.content[0].text)).toEqual(mockEvents); @@ -433,7 +446,11 @@ describe('CalendarService', () => { summary: 'Meeting needs response', status: 'confirmed', attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'needsAction' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'needsAction', + }, ], }, ]; @@ -528,7 +545,9 @@ describe('CalendarService', () => { calendarId: 'primary', }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'Events API failed' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'Events API failed', + }); }); it('should handle empty events list', async () => { @@ -570,7 +589,7 @@ describe('CalendarService', () => { expect(mockCalendarAPI.events.list).toHaveBeenCalledWith( expect.objectContaining({ calendarId: 'primary', - }) + }), ); }); }); @@ -605,7 +624,10 @@ describe('CalendarService', () => { const parsedResult = JSON.parse(result.content[0].text); expect(parsedResult.start).toBeDefined(); expect(parsedResult.end).toBeDefined(); - expect(new Date(parsedResult.end).getTime() - new Date(parsedResult.start).getTime()).toBe(60 * 60 * 1000); + expect( + new Date(parsedResult.end).getTime() - + new Date(parsedResult.start).getTime(), + ).toBe(60 * 60 * 1000); }); it('should return an error if no free time is found', async () => { @@ -788,7 +810,11 @@ describe('CalendarService', () => { id: 'event123', summary: 'Team Meeting', attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'needsAction' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'needsAction', + }, { email: 'other@example.com', responseStatus: 'accepted' }, ], }; @@ -840,7 +866,11 @@ describe('CalendarService', () => { id: 'event123', summary: 'Team Meeting', attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'needsAction' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'needsAction', + }, { email: 'other@example.com', responseStatus: 'accepted' }, ], }; @@ -848,7 +878,12 @@ describe('CalendarService', () => { const updatedEvent = { ...mockEvent, attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'declined', comment: 'Sorry, I have a conflict' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'declined', + comment: 'Sorry, I have a conflict', + }, { email: 'other@example.com', responseStatus: 'accepted' }, ], }; @@ -893,12 +928,23 @@ describe('CalendarService', () => { id: 'event123', summary: 'Team Meeting', attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'needsAction' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'needsAction', + }, ], }; mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent }); - mockCalendarAPI.events.patch.mockResolvedValue({ data: { ...mockEvent, attendees: [{ ...mockEvent.attendees[0], responseStatus: 'tentative' }] } }); + mockCalendarAPI.events.patch.mockResolvedValue({ + data: { + ...mockEvent, + attendees: [ + { ...mockEvent.attendees[0], responseStatus: 'tentative' }, + ], + }, + }); const result = await calendarService.respondToEvent({ eventId: 'event123', @@ -971,12 +1017,23 @@ describe('CalendarService', () => { id: 'event123', summary: 'Team Meeting', attendees: [ - { email: 'me@example.com', self: true, responseStatus: 'needsAction' }, + { + email: 'me@example.com', + self: true, + responseStatus: 'needsAction', + }, ], }; mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent }); - mockCalendarAPI.events.patch.mockResolvedValue({ data: { ...mockEvent, attendees: [{ ...mockEvent.attendees[0], responseStatus: 'accepted' }] } }); + mockCalendarAPI.events.patch.mockResolvedValue({ + data: { + ...mockEvent, + attendees: [ + { ...mockEvent.attendees[0], responseStatus: 'accepted' }, + ], + }, + }); await calendarService.respondToEvent({ eventId: 'event123', @@ -992,7 +1049,7 @@ describe('CalendarService', () => { expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith( expect.objectContaining({ calendarId: 'custom-calendar-id', - }) + }), ); }); @@ -1031,7 +1088,10 @@ describe('CalendarService', () => { mockCalendarAPI.events.get.mockResolvedValue({ data: mockEvent }); - const result = await calendarService.getEvent({ eventId: 'event123', calendarId: 'primary' }); + const result = await calendarService.getEvent({ + eventId: 'event123', + calendarId: 'primary', + }); expect(mockCalendarAPI.events.get).toHaveBeenCalledWith({ calendarId: 'primary', @@ -1059,15 +1119,20 @@ describe('CalendarService', () => { }); expect(JSON.parse(result.content[0].text)).toEqual(mockEvent); - }); + }); it('should handle API errors when getting an event', async () => { const apiError = new Error('Event not found'); mockCalendarAPI.events.get.mockRejectedValue(apiError); - const result = await calendarService.getEvent({ eventId: 'non-existent-event', calendarId: 'primary' }); + const result = await calendarService.getEvent({ + eventId: 'non-existent-event', + calendarId: 'primary', + }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'Event not found' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'Event not found', + }); }); }); describe('deleteEvent', () => { @@ -1127,4 +1192,4 @@ describe('CalendarService', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/services/ChatService.test.ts b/workspace-server/src/__tests__/services/ChatService.test.ts index d0031b2..e98b46d 100644 --- a/workspace-server/src/__tests__/services/ChatService.test.ts +++ b/workspace-server/src/__tests__/services/ChatService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { ChatService } from '../../services/ChatService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; @@ -59,7 +66,9 @@ describe('ChatService', () => { chatService = new ChatService(mockAuthManager); const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); }); afterEach(() => { @@ -104,7 +113,9 @@ describe('ChatService', () => { const result = await chatService.listSpaces(); const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('An error occurred while listing chat spaces.'); + expect(response.error).toBe( + 'An error occurred while listing chat spaces.', + ); expect(response.details).toBe('Chat API failed'); }); }); @@ -173,7 +184,9 @@ describe('ChatService', () => { }); const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('An error occurred while sending the message.'); + expect(response.error).toBe( + 'An error occurred while sending the message.', + ); expect(response.details).toBe('Failed to send message'); }); }); @@ -193,7 +206,9 @@ describe('ChatService', () => { }, }); - const result = await chatService.findSpaceByName({ displayName: 'Team Chat' }); + const result = await chatService.findSpaceByName({ + displayName: 'Team Chat', + }); expect(mockChatAPI.spaces.list).toHaveBeenCalled(); const foundSpaces = JSON.parse(result.content[0].text); @@ -225,7 +240,9 @@ describe('ChatService', () => { }, }); - const result = await chatService.findSpaceByName({ displayName: 'Team Chat' }); + const result = await chatService.findSpaceByName({ + displayName: 'Team Chat', + }); expect(mockChatAPI.spaces.list).toHaveBeenCalledTimes(2); const foundSpaces = JSON.parse(result.content[0].text); @@ -236,16 +253,18 @@ describe('ChatService', () => { it('should return error when space not found', async () => { mockChatAPI.spaces.list.mockResolvedValue({ data: { - spaces: [ - { name: 'spaces/space1', displayName: 'Other Chat' }, - ], + spaces: [{ name: 'spaces/space1', displayName: 'Other Chat' }], }, }); - const result = await chatService.findSpaceByName({ displayName: 'Non-existent Chat' }); + const result = await chatService.findSpaceByName({ + displayName: 'Non-existent Chat', + }); const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('No space found with display name: Non-existent Chat'); + expect(response.error).toBe( + 'No space found with display name: Non-existent Chat', + ); }); }); @@ -283,9 +302,7 @@ describe('ChatService', () => { const mockPerson = { data: { metadata: { - sources: [ - { type: 'PROFILE', id: 'user123' }, - ], + sources: [{ type: 'PROFILE', id: 'user123' }], }, }, }; @@ -337,9 +354,7 @@ describe('ChatService', () => { const mockPerson = { data: { metadata: { - sources: [ - { type: 'PROFILE', id: 'user123' }, - ], + sources: [{ type: 'PROFILE', id: 'user123' }], }, }, }; @@ -352,7 +367,10 @@ describe('ChatService', () => { ]; const mockMessages = [ - { name: 'spaces/space1/messages/msg1', text: 'All messages are unread' }, + { + name: 'spaces/space1/messages/msg1', + text: 'All messages are unread', + }, ]; mockPeopleAPI.people.get.mockResolvedValue(mockPerson); @@ -440,9 +458,7 @@ describe('ChatService', () => { const mockPerson = { data: { metadata: { - sources: [ - { type: 'PROFILE', id: 'user123' }, - ], + sources: [{ type: 'PROFILE', id: 'user123' }], }, }, }; @@ -455,7 +471,10 @@ describe('ChatService', () => { ]; const mockMessages = [ - { name: 'spaces/space1/messages/msg1', text: 'Unread message in thread' }, + { + name: 'spaces/space1/messages/msg1', + text: 'Unread message in thread', + }, ]; mockPeopleAPI.people.get.mockResolvedValue(mockPerson); @@ -608,7 +627,9 @@ describe('ChatService', () => { data: mockSpace, }); - const result = await chatService.findDmByEmail({ email: 'user@example.com' }); + const result = await chatService.findDmByEmail({ + email: 'user@example.com', + }); expect(mockChatAPI.spaces.setup).toHaveBeenCalledWith({ requestBody: { @@ -634,10 +655,14 @@ describe('ChatService', () => { const apiError = new Error('Failed to setup DM space'); mockChatAPI.spaces.setup.mockRejectedValue(apiError); - const result = await chatService.findDmByEmail({ email: 'user@example.com' }); + const result = await chatService.findDmByEmail({ + email: 'user@example.com', + }); const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('An error occurred while finding the DM space.'); + expect(response.error).toBe( + 'An error occurred while finding the DM space.', + ); expect(response.details).toBe('Failed to setup DM space'); }); }); @@ -693,7 +718,9 @@ describe('ChatService', () => { }); const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('An error occurred while creating the space.'); + expect(response.error).toBe( + 'An error occurred while creating the space.', + ); expect(response.details).toBe('Failed to create space'); }); }); diff --git a/workspace-server/src/__tests__/services/DocsService.test.ts b/workspace-server/src/__tests__/services/DocsService.test.ts index 027b8a9..b33553a 100644 --- a/workspace-server/src/__tests__/services/DocsService.test.ts +++ b/workspace-server/src/__tests__/services/DocsService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { DocsService } from '../../services/DocsService'; import { DriveService } from '../../services/DriveService'; import { AuthManager } from '../../auth/AuthManager'; @@ -14,782 +21,871 @@ import { google } from 'googleapis'; jest.mock('googleapis'); jest.mock('../../utils/logger'); jest.mock('dompurify', () => { - return jest.fn().mockImplementation(() => ({ - sanitize: jest.fn((content) => content), - })); + return jest.fn().mockImplementation(() => ({ + sanitize: jest.fn((content) => content), + })); }); describe('DocsService', () => { - let docsService: DocsService; - let mockAuthManager: jest.Mocked; - let mockDriveService: jest.Mocked; - let mockDocsAPI: any; - let mockDriveAPI: any; - - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - - // Create mock AuthManager - mockAuthManager = { - getAuthenticatedClient: jest.fn(), - } as any; - - // Create mock DriveService - mockDriveService = { - findFolder: jest.fn(), - } as any; - - // Create mock Docs API - mockDocsAPI = { - documents: { - get: jest.fn(), - create: jest.fn(), - batchUpdate: jest.fn(), - }, - }; - - mockDriveAPI = { - files: { - create: jest.fn(), - list: jest.fn(), - get: jest.fn(), - update: jest.fn(), - }, - }; + let docsService: DocsService; + let mockAuthManager: jest.Mocked; + let mockDriveService: jest.Mocked; + let mockDocsAPI: any; + let mockDriveAPI: any; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Create mock AuthManager + mockAuthManager = { + getAuthenticatedClient: jest.fn(), + } as any; + + // Create mock DriveService + mockDriveService = { + findFolder: jest.fn(), + } as any; + + // Create mock Docs API + mockDocsAPI = { + documents: { + get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), + }, + }; + + mockDriveAPI = { + files: { + create: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + }; + + // Mock the google constructors + (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI); + (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); + + // Create DocsService instance + docsService = new DocsService(mockAuthManager, mockDriveService); + + const mockAuthClient = { access_token: 'test-token' }; + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('create', () => { + it('should create a blank document', async () => { + const mockDoc = { + data: { + documentId: 'test-doc-id', + title: 'Test Title', + }, + }; + mockDocsAPI.documents.create.mockResolvedValue(mockDoc); + + const result = await docsService.create({ title: 'Test Title' }); + + expect(mockDocsAPI.documents.create).toHaveBeenCalledWith({ + requestBody: { title: 'Test Title' }, + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + documentId: 'test-doc-id', + title: 'Test Title', + }); + }); - // Mock the google constructors - (google.docs as jest.Mock) = jest.fn().mockReturnValue(mockDocsAPI); - (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); + it('should create a document with markdown content', async () => { + const mockFile = { + data: { + id: 'test-doc-id', + name: 'Test Title', + }, + }; + mockDriveAPI.files.create.mockResolvedValue(mockFile); + + const result = await docsService.create({ + title: 'Test Title', + markdown: '# Hello', + }); + + expect(mockDriveAPI.files.create).toHaveBeenCalled(); + expect(JSON.parse(result.content[0].text)).toEqual({ + documentId: 'test-doc-id', + title: 'Test Title', + }); + }); - // Create DocsService instance - docsService = new DocsService(mockAuthManager, mockDriveService); + it('should move the document to a folder if folderName is provided', async () => { + const mockDoc = { + data: { + documentId: 'test-doc-id', + title: 'Test Title', + }, + }; + mockDocsAPI.documents.create.mockResolvedValue(mockDoc); + mockDriveService.findFolder.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify([ + { id: 'test-folder-id', name: 'Test Folder' }, + ]), + }, + ], + }); + mockDriveAPI.files.get.mockResolvedValue({ data: { parents: ['root'] } }); + + await docsService.create({ + title: 'Test Title', + folderName: 'Test Folder', + }); + + expect(mockDriveService.findFolder).toHaveBeenCalledWith({ + folderName: 'Test Folder', + }); + expect(mockDriveAPI.files.update).toHaveBeenCalledWith({ + fileId: 'test-doc-id', + addParents: 'test-folder-id', + removeParents: 'root', + fields: 'id, parents', + }); + }); - const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + it('should handle errors during document creation', async () => { + const apiError = new Error('API Error'); + mockDocsAPI.documents.create.mockRejectedValue(apiError); + + const result = await docsService.create({ title: 'Test Title' }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + }); + + describe('insertText', () => { + it('should insert text into a document', async () => { + const mockResponse = { + data: { + documentId: 'test-doc-id', + writeControl: {}, + }, + }; + mockDocsAPI.documents.batchUpdate.mockResolvedValue(mockResponse); + + const result = await docsService.insertText({ + documentId: 'test-doc-id', + text: 'Hello', + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertText: { + location: { index: 1 }, + text: 'Hello', + }, + }, + ], + }, + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + documentId: 'test-doc-id', + writeControl: {}, + }); }); - afterEach(() => { - jest.restoreAllMocks(); + it('should handle errors during text insertion', async () => { + const apiError = new Error('API Error'); + mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError); + + const result = await docsService.insertText({ + documentId: 'test-doc-id', + text: 'Hello', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); }); - describe('create', () => { - it('should create a blank document', async () => { - const mockDoc = { - data: { - documentId: 'test-doc-id', - title: 'Test Title', + it('should insert text into a specific tab', async () => { + const mockResponse = { + data: { + documentId: 'test-doc-id', + writeControl: {}, + }, + }; + mockDocsAPI.documents.batchUpdate.mockResolvedValue(mockResponse); + + await docsService.insertText({ + documentId: 'test-doc-id', + text: 'Hello', + tabId: 'tab-1', + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertText: { + location: { + index: 1, + tabId: 'tab-1', }, - }; - mockDocsAPI.documents.create.mockResolvedValue(mockDoc); - - const result = await docsService.create({ title: 'Test Title' }); - - expect(mockDocsAPI.documents.create).toHaveBeenCalledWith({ - requestBody: { title: 'Test Title' }, - }); - expect(JSON.parse(result.content[0].text)).toEqual({ - documentId: 'test-doc-id', - title: 'Test Title', - }); - }); - - it('should create a document with markdown content', async () => { - const mockFile = { - data: { - id: 'test-doc-id', - name: 'Test Title', - }, - }; - mockDriveAPI.files.create.mockResolvedValue(mockFile); - - const result = await docsService.create({ title: 'Test Title', markdown: '# Hello' }); - - expect(mockDriveAPI.files.create).toHaveBeenCalled(); - expect(JSON.parse(result.content[0].text)).toEqual({ - documentId: 'test-doc-id', - title: 'Test Title', - }); - }); - - it('should move the document to a folder if folderName is provided', async () => { - const mockDoc = { - data: { - documentId: 'test-doc-id', - title: 'Test Title', + text: 'Hello', + }, + }, + ], + }, + }); + }); + }); + + describe('find', () => { + it('should find documents with a given query', async () => { + const mockResponse = { + data: { + files: [{ id: 'test-doc-id', name: 'Test Document' }], + nextPageToken: 'next-page-token', + }, + }; + mockDriveAPI.files.list.mockResolvedValue(mockResponse); + + const result = await docsService.find({ query: 'Test' }); + + expect(mockDriveAPI.files.list).toHaveBeenCalledWith( + expect.objectContaining({ + q: expect.stringContaining("fullText contains 'Test'"), + }), + ); + expect(JSON.parse(result.content[0].text)).toEqual({ + files: [{ id: 'test-doc-id', name: 'Test Document' }], + nextPageToken: 'next-page-token', + }); + }); + + it('should search by title when query starts with title:', async () => { + const mockResponse = { + data: { + files: [{ id: 'test-doc-id', name: 'Test Document' }], + }, + }; + mockDriveAPI.files.list.mockResolvedValue(mockResponse); + + const result = await docsService.find({ query: 'title:Test Document' }); + + expect(mockDriveAPI.files.list).toHaveBeenCalledWith( + expect.objectContaining({ + q: expect.stringContaining("name contains 'Test Document'"), + }), + ); + expect(mockDriveAPI.files.list).toHaveBeenCalledWith( + expect.objectContaining({ + q: expect.not.stringContaining('fullText contains'), + }), + ); + expect(JSON.parse(result.content[0].text)).toEqual({ + files: [{ id: 'test-doc-id', name: 'Test Document' }], + }); + }); + + it('should handle errors during find', async () => { + const apiError = new Error('API Error'); + mockDriveAPI.files.list.mockRejectedValue(apiError); + + const result = await docsService.find({ query: 'Test' }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + }); + + describe('move', () => { + it('should move a document to a folder', async () => { + mockDriveService.findFolder.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify([ + { id: 'test-folder-id', name: 'Test Folder' }, + ]), + }, + ], + }); + mockDriveAPI.files.get.mockResolvedValue({ data: { parents: ['root'] } }); + + const result = await docsService.move({ + documentId: 'test-doc-id', + folderName: 'Test Folder', + }); + + expect(mockDriveService.findFolder).toHaveBeenCalledWith({ + folderName: 'Test Folder', + }); + expect(mockDriveAPI.files.update).toHaveBeenCalledWith({ + fileId: 'test-doc-id', + addParents: 'test-folder-id', + removeParents: 'root', + fields: 'id, parents', + }); + expect(result.content[0].text).toBe( + 'Moved document test-doc-id to folder Test Folder', + ); + }); + + it('should handle errors during move', async () => { + const apiError = new Error('API Error'); + mockDriveService.findFolder.mockRejectedValue(apiError); + + const result = await docsService.move({ + documentId: 'test-doc-id', + folderName: 'Test Folder', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + }); + + describe('getText', () => { + it('should extract text from a document', async () => { + const mockDoc = { + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'Hello World\n', + }, + }, + ], + }, + }, + ], }, - }; - mockDocsAPI.documents.create.mockResolvedValue(mockDoc); - mockDriveService.findFolder.mockResolvedValue({ - content: [{ type: 'text', text: JSON.stringify([{ id: 'test-folder-id', name: 'Test Folder' }]) }], - }); - mockDriveAPI.files.get.mockResolvedValue({ data: { parents: ['root'] } }); - - await docsService.create({ title: 'Test Title', folderName: 'Test Folder' }); - - expect(mockDriveService.findFolder).toHaveBeenCalledWith({ folderName: 'Test Folder' }); - expect(mockDriveAPI.files.update).toHaveBeenCalledWith({ - fileId: 'test-doc-id', - addParents: 'test-folder-id', - removeParents: 'root', - fields: 'id, parents', - }); - }); - - it('should handle errors during document creation', async () => { - const apiError = new Error('API Error'); - mockDocsAPI.documents.create.mockRejectedValue(apiError); - - const result = await docsService.create({ title: 'Test Title' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.getText({ documentId: 'test-doc-id' }); + + expect(result.content[0].text).toBe('Hello World\n'); }); - describe('insertText', () => { - it('should insert text into a document', async () => { - const mockResponse = { - data: { - documentId: 'test-doc-id', - writeControl: {}, + it('should handle errors during getText', async () => { + const apiError = new Error('API Error'); + mockDocsAPI.documents.get.mockRejectedValue(apiError); + + const result = await docsService.getText({ documentId: 'test-doc-id' }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + + it('should extract text from a specific tab', async () => { + const mockDoc = { + data: { + tabs: [ + { + tabProperties: { tabId: 'tab-1', title: 'Tab 1' }, + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [{ textRun: { content: 'Tab 1 Content' } }], + }, + }, + ], }, - }; - mockDocsAPI.documents.batchUpdate.mockResolvedValue(mockResponse); - - const result = await docsService.insertText({ documentId: 'test-doc-id', text: 'Hello' }); - - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: [{ - insertText: { - location: { index: 1 }, - text: 'Hello', - }, - }], + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.getText({ + documentId: 'test-doc-id', + tabId: 'tab-1', + }); + + expect(result.content[0].text).toBe('Tab 1 Content'); + }); + + it('should return all tabs if no tabId provided and tabs exist', async () => { + const mockDoc = { + data: { + tabs: [ + { + tabProperties: { tabId: 'tab-1', title: 'Tab 1' }, + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [{ textRun: { content: 'Tab 1 Content' } }], + }, + }, + ], }, - }); - expect(JSON.parse(result.content[0].text)).toEqual({ - documentId: 'test-doc-id', - writeControl: {}, - }); - }); - - it('should handle errors during text insertion', async () => { - const apiError = new Error('API Error'); - mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError); - - const result = await docsService.insertText({ documentId: 'test-doc-id', text: 'Hello' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); - - it('should insert text into a specific tab', async () => { - const mockResponse = { - data: { - documentId: 'test-doc-id', - writeControl: {}, + }, + }, + { + tabProperties: { tabId: 'tab-2', title: 'Tab 2' }, + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [{ textRun: { content: 'Tab 2 Content' } }], + }, + }, + ], }, - }; - mockDocsAPI.documents.batchUpdate.mockResolvedValue(mockResponse); - - await docsService.insertText({ documentId: 'test-doc-id', text: 'Hello', tabId: 'tab-1' }); - - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: [{ - insertText: { - location: { - index: 1, - tabId: 'tab-1' - }, - text: 'Hello', - }, - }], + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.getText({ documentId: 'test-doc-id' }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed).toHaveLength(2); + expect(parsed[0]).toEqual({ + tabId: 'tab-1', + title: 'Tab 1', + content: 'Tab 1 Content', + index: 0, + }); + expect(parsed[1]).toEqual({ + tabId: 'tab-2', + title: 'Tab 2', + content: 'Tab 2 Content', + index: 1, + }); + }); + }); + + describe('appendText', () => { + it('should append text to a document', async () => { + const mockDoc = { + data: { + tabs: [ + { + documentTab: { + body: { + content: [{ endIndex: 10 }], }, - }); - }); + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + const result = await docsService.appendText({ + documentId: 'test-doc-id', + text: ' Appended', + }); + + expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + fields: 'tabs', + includeTabsContent: true, + }); + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertText: { + location: { index: 9 }, + text: ' Appended', + }, + }, + ], + }, + }); + expect(result.content[0].text).toBe( + 'Successfully appended text to document test-doc-id', + ); + }); + + it('should handle errors during appendText', async () => { + const apiError = new Error('API Error'); + mockDocsAPI.documents.get.mockRejectedValue(apiError); + + const result = await docsService.appendText({ + documentId: 'test-doc-id', + text: ' Appended', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); }); - describe('find', () => { - it('should find documents with a given query', async () => { - const mockResponse = { - data: { - files: [{ id: 'test-doc-id', name: 'Test Document' }], - nextPageToken: 'next-page-token', + it('should append text to a specific tab', async () => { + const mockDoc = { + data: { + tabs: [ + { + tabProperties: { tabId: 'tab-1' }, + documentTab: { + body: { + content: [{ endIndex: 10 }], }, - }; - mockDriveAPI.files.list.mockResolvedValue(mockResponse); - - const result = await docsService.find({ query: 'Test' }); - - expect(mockDriveAPI.files.list).toHaveBeenCalledWith(expect.objectContaining({ - q: expect.stringContaining("fullText contains 'Test'"), - })); - expect(JSON.parse(result.content[0].text)).toEqual({ - files: [{ id: 'test-doc-id', name: 'Test Document' }], - nextPageToken: 'next-page-token', - }); - }); - - it('should search by title when query starts with title:', async () => { - const mockResponse = { - data: { - files: [{ id: 'test-doc-id', name: 'Test Document' }], + }, + }, + ], + }, + }; + mockDocsAPI.documents.get.mockResolvedValue(mockDoc); + + await docsService.appendText({ + documentId: 'test-doc-id', + text: ' Appended', + tabId: 'tab-1', + }); + + expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + fields: 'tabs', + includeTabsContent: true, + }); + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: [ + { + insertText: { + location: { + index: 9, + tabId: 'tab-1', }, - }; - mockDriveAPI.files.list.mockResolvedValue(mockResponse); - - const result = await docsService.find({ query: 'title:Test Document' }); - - expect(mockDriveAPI.files.list).toHaveBeenCalledWith(expect.objectContaining({ - q: expect.stringContaining("name contains 'Test Document'"), - })); - expect(mockDriveAPI.files.list).toHaveBeenCalledWith(expect.objectContaining({ - q: expect.not.stringContaining("fullText contains"), - })); - expect(JSON.parse(result.content[0].text)).toEqual({ - files: [{ id: 'test-doc-id', name: 'Test Document' }], - }); - }); - - it('should handle errors during find', async () => { - const apiError = new Error('API Error'); - mockDriveAPI.files.list.mockRejectedValue(apiError); - - const result = await docsService.find({ query: 'Test' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); + text: ' Appended', + }, + }, + ], + }, + }); }); - - describe('move', () => { - it('should move a document to a folder', async () => { - mockDriveService.findFolder.mockResolvedValue({ - content: [{ type: 'text', text: JSON.stringify([{ id: 'test-folder-id', name: 'Test Folder' }]) }], - }); - mockDriveAPI.files.get.mockResolvedValue({ data: { parents: ['root'] } }); - - const result = await docsService.move({ documentId: 'test-doc-id', folderName: 'Test Folder' }); - - expect(mockDriveService.findFolder).toHaveBeenCalledWith({ folderName: 'Test Folder' }); - expect(mockDriveAPI.files.update).toHaveBeenCalledWith({ - fileId: 'test-doc-id', - addParents: 'test-folder-id', - removeParents: 'root', - fields: 'id, parents', - }); - expect(result.content[0].text).toBe('Moved document test-doc-id to folder Test Folder'); - }); - - it('should handle errors during move', async () => { - const apiError = new Error('API Error'); - mockDriveService.findFolder.mockRejectedValue(apiError); - - const result = await docsService.move({ documentId: 'test-doc-id', folderName: 'Test Folder' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); + }); + + describe('replaceText', () => { + it('should replace text in a document', async () => { + // Mock the document get call that finds occurrences + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [ + { textRun: { content: 'Hello world! Hello again!' } }, + ], + }, + }, + ], + }, + }, + }, + ], + }, + }); + + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ + data: { + documentId: 'test-doc-id', + replies: [], + }, + }); + + const result = await docsService.replaceText({ + documentId: 'test-doc-id', + findText: 'Hello', + replaceText: 'Hi', + }); + + expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + fields: 'tabs', + includeTabsContent: true, + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: expect.arrayContaining([ + expect.objectContaining({ + deleteContentRange: { + range: { + tabId: undefined, + startIndex: 1, + endIndex: 6, + }, + }, + }), + expect.objectContaining({ + insertText: { + location: { + tabId: undefined, + index: 1, + }, + text: 'Hi', + }, + }), + ]), + }, + }); + expect(result.content[0].text).toBe( + 'Successfully replaced text in document test-doc-id', + ); }); - describe('getText', () => { - it('should extract text from a document', async () => { - const mockDoc = { - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { - textRun: { - content: 'Hello World\n', - }, - }, - ], - }, - }, - ], - }, + it('should replace text with markdown formatting', async () => { + // Mock the document get call that finds occurrences + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [ + { + textRun: { + content: 'Replace this text and this text too.', }, - }, - ], + }, + ], + }, + }, + ], + }, + }, + }, + ], + }, + }); + + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ + data: { + documentId: 'test-doc-id', + replies: [], + }, + }); + + const result = await docsService.replaceText({ + documentId: 'test-doc-id', + findText: 'this text', + replaceText: '**bold text**', + }); + + expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + fields: 'tabs', + includeTabsContent: true, + }); + + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: expect.arrayContaining([ + // First occurrence + expect.objectContaining({ + deleteContentRange: { + range: { + tabId: undefined, + startIndex: 9, + endIndex: 18, }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); - - const result = await docsService.getText({ documentId: 'test-doc-id' }); - - expect(result.content[0].text).toBe('Hello World\n'); - }); - - it('should handle errors during getText', async () => { - const apiError = new Error('API Error'); - mockDocsAPI.documents.get.mockRejectedValue(apiError); - - const result = await docsService.getText({ documentId: 'test-doc-id' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); - - it('should extract text from a specific tab', async () => { - const mockDoc = { - data: { - tabs: [ - { - tabProperties: { tabId: 'tab-1', title: 'Tab 1' }, - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Tab 1 Content' } } - ] - } - } - ] - } - } - } - ] + }, + }), + expect.objectContaining({ + insertText: { + location: { + tabId: undefined, + index: 9, }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); - - const result = await docsService.getText({ documentId: 'test-doc-id', tabId: 'tab-1' }); - - expect(result.content[0].text).toBe('Tab 1 Content'); - }); - - it('should return all tabs if no tabId provided and tabs exist', async () => { - const mockDoc = { - data: { - tabs: [ - { - tabProperties: { tabId: 'tab-1', title: 'Tab 1' }, - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Tab 1 Content' } } - ] - } - } - ] - } - } - }, - { - tabProperties: { tabId: 'tab-2', title: 'Tab 2' }, - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Tab 2 Content' } } - ] - } - } - ] - } - } - } - ] + text: 'bold text', + }, + }), + expect.objectContaining({ + updateTextStyle: expect.objectContaining({ + range: { + tabId: undefined, + startIndex: 9, + endIndex: 18, }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); - - const result = await docsService.getText({ documentId: 'test-doc-id' }); - const parsed = JSON.parse(result.content[0].text); - - expect(parsed).toHaveLength(2); - expect(parsed[0]).toEqual({ - tabId: 'tab-1', - title: 'Tab 1', - content: 'Tab 1 Content', - index: 0 - }); - expect(parsed[1]).toEqual({ - tabId: 'tab-2', - title: 'Tab 2', - content: 'Tab 2 Content', - index: 1 - }); - }); - }); - - describe('appendText', () => { - it('should append text to a document', async () => { - const mockDoc = { - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { endIndex: 10 } - ] - } - } - } - ] + textStyle: { bold: true }, + }), + }), + // Second occurrence + expect.objectContaining({ + deleteContentRange: { + range: { + tabId: undefined, + startIndex: 23, + endIndex: 32, }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); - - const result = await docsService.appendText({ documentId: 'test-doc-id', text: ' Appended' }); - - expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - fields: 'tabs', - includeTabsContent: true, - }); - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: [{ - insertText: { - location: { index: 9 }, - text: ' Appended', - }, - }], + }, + }), + expect.objectContaining({ + insertText: { + location: { + tabId: undefined, + index: 23, }, - }); - expect(result.content[0].text).toBe('Successfully appended text to document test-doc-id'); - }); - - it('should handle errors during appendText', async () => { - const apiError = new Error('API Error'); - mockDocsAPI.documents.get.mockRejectedValue(apiError); - - const result = await docsService.appendText({ documentId: 'test-doc-id', text: ' Appended' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); - - it('should append text to a specific tab', async () => { - const mockDoc = { - data: { - tabs: [ - { - tabProperties: { tabId: 'tab-1' }, - documentTab: { - body: { - content: [ - { endIndex: 10 } - ] - } - } - } - ] + text: 'bold text', + }, + }), + expect.objectContaining({ + updateTextStyle: expect.objectContaining({ + range: { + tabId: undefined, + startIndex: 23, + endIndex: 32, }, - }; - mockDocsAPI.documents.get.mockResolvedValue(mockDoc); - - await docsService.appendText({ documentId: 'test-doc-id', text: ' Appended', tabId: 'tab-1' }); - - expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - fields: 'tabs', - includeTabsContent: true, - }); - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: [{ - insertText: { - location: { - index: 9, - tabId: 'tab-1' - }, - text: ' Appended', - }, - }], + textStyle: { bold: true }, + }), + }), + ]), + }, + }); + expect(result.content[0].text).toBe( + 'Successfully replaced text in document test-doc-id', + ); + }); + + it('should handle errors during replaceText', async () => { + // Mock the document get call + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [{ textRun: { content: 'Hello world!' } }], + }, + }, + ], }, - }); - }); + }, + }, + ], + }, + }); + + const apiError = new Error('API Error'); + mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError); + + const result = await docsService.replaceText({ + documentId: 'test-doc-id', + findText: 'Hello', + replaceText: 'Hi', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); }); - describe('replaceText', () => { - it('should replace text in a document', async () => { - // Mock the document get call that finds occurrences - mockDocsAPI.documents.get.mockResolvedValue({ - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Hello world! Hello again!' } } - ] - } - } - ] - } - } - } - ] - } - }); - - mockDocsAPI.documents.batchUpdate.mockResolvedValue({ - data: { - documentId: 'test-doc-id', - replies: [] - } - }); - - const result = await docsService.replaceText({ documentId: 'test-doc-id', findText: 'Hello', replaceText: 'Hi' }); - - expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - fields: 'tabs', - includeTabsContent: true, - }); - - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: expect.arrayContaining([ - expect.objectContaining({ - deleteContentRange: { - range: { - tabId: undefined, - startIndex: 1, - endIndex: 6 - } - } - }), - expect.objectContaining({ - insertText: { - location: { - tabId: undefined, - index: 1 - }, - text: 'Hi' - } - }) - ]), + it('should replace text in a specific tab using delete/insert', async () => { + mockDocsAPI.documents.get.mockResolvedValue({ + data: { + tabs: [ + { + tabProperties: { tabId: 'tab-1' }, + documentTab: { + body: { + content: [ + { + paragraph: { + elements: [{ textRun: { content: 'Hello world!' } }], + }, + }, + ], }, - }); - expect(result.content[0].text).toBe('Successfully replaced text in document test-doc-id'); - }); - - it('should replace text with markdown formatting', async () => { - // Mock the document get call that finds occurrences - mockDocsAPI.documents.get.mockResolvedValue({ - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Replace this text and this text too.' } } - ] - } - } - ] - } - } - } - ] - } - }); - - mockDocsAPI.documents.batchUpdate.mockResolvedValue({ - data: { - documentId: 'test-doc-id', - replies: [] - } - }); - - const result = await docsService.replaceText({ - documentId: 'test-doc-id', - findText: 'this text', - replaceText: '**bold text**' - }); - - expect(mockDocsAPI.documents.get).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - fields: 'tabs', - includeTabsContent: true, - }); - - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: expect.arrayContaining([ - // First occurrence - expect.objectContaining({ - deleteContentRange: { - range: { - tabId: undefined, - startIndex: 9, - endIndex: 18 - } - } - }), - expect.objectContaining({ - insertText: { - location: { - tabId: undefined, - index: 9 - }, - text: 'bold text' - } - }), - expect.objectContaining({ - updateTextStyle: expect.objectContaining({ - range: { - tabId: undefined, - startIndex: 9, - endIndex: 18 - }, - textStyle: { bold: true } - }) - }), - // Second occurrence - expect.objectContaining({ - deleteContentRange: { - range: { - tabId: undefined, - startIndex: 23, - endIndex: 32 - } - } - }), - expect.objectContaining({ - insertText: { - location: { - tabId: undefined, - index: 23 - }, - text: 'bold text' - } - }), - expect.objectContaining({ - updateTextStyle: expect.objectContaining({ - range: { - tabId: undefined, - startIndex: 23, - endIndex: 32 - }, - textStyle: { bold: true } - }) - }) - ]), + }, + }, + ], + }, + }); + + mockDocsAPI.documents.batchUpdate.mockResolvedValue({ + data: { documentId: 'test-doc-id' }, + }); + + await docsService.replaceText({ + documentId: 'test-doc-id', + findText: 'Hello', + replaceText: 'Hi', + tabId: 'tab-1', + }); + + // Should use deleteContentRange and insertText instead of replaceAllText + expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ + documentId: 'test-doc-id', + requestBody: { + requests: expect.arrayContaining([ + expect.objectContaining({ + deleteContentRange: { + range: { + tabId: 'tab-1', + startIndex: 1, + endIndex: 6, }, - }); - expect(result.content[0].text).toBe('Successfully replaced text in document test-doc-id'); - }); - - it('should handle errors during replaceText', async () => { - // Mock the document get call - mockDocsAPI.documents.get.mockResolvedValue({ - data: { - tabs: [ - { - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Hello world!' } } - ] - } - } - ] - } - } - } - ] - } - }); - - const apiError = new Error('API Error'); - mockDocsAPI.documents.batchUpdate.mockRejectedValue(apiError); - - const result = await docsService.replaceText({ documentId: 'test-doc-id', findText: 'Hello', replaceText: 'Hi' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); - - it('should replace text in a specific tab using delete/insert', async () => { - mockDocsAPI.documents.get.mockResolvedValue({ - data: { - tabs: [ - { - tabProperties: { tabId: 'tab-1' }, - documentTab: { - body: { - content: [ - { - paragraph: { - elements: [ - { textRun: { content: 'Hello world!' } } - ] - } - } - ] - } - } - } - ] - } - }); - - mockDocsAPI.documents.batchUpdate.mockResolvedValue({ - data: { documentId: 'test-doc-id' } - }); - - await docsService.replaceText({ - documentId: 'test-doc-id', - findText: 'Hello', - replaceText: 'Hi', - tabId: 'tab-1' - }); - - // Should use deleteContentRange and insertText instead of replaceAllText - expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledWith({ - documentId: 'test-doc-id', - requestBody: { - requests: expect.arrayContaining([ - expect.objectContaining({ - deleteContentRange: { - range: { - tabId: 'tab-1', - startIndex: 1, - endIndex: 6 - } - } - }), - expect.objectContaining({ - insertText: { - location: { - tabId: 'tab-1', - index: 1 - }, - text: 'Hi' - } - }) - ]), + }, + }), + expect.objectContaining({ + insertText: { + location: { + tabId: 'tab-1', + index: 1, }, - }); - }); + text: 'Hi', + }, + }), + ]), + }, + }); }); + }); }); diff --git a/workspace-server/src/__tests__/services/DriveService.test.ts b/workspace-server/src/__tests__/services/DriveService.test.ts index f4e39a7..2fd9887 100644 --- a/workspace-server/src/__tests__/services/DriveService.test.ts +++ b/workspace-server/src/__tests__/services/DriveService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { DriveService } from '../../services/DriveService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; @@ -75,7 +82,9 @@ describe('DriveService', () => { driveService = new DriveService(mockAuthManager); const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); }); afterEach(() => { @@ -95,7 +104,9 @@ describe('DriveService', () => { }, }); - const result = await driveService.findFolder({ folderName: 'TestFolder' }); + const result = await driveService.findFolder({ + folderName: 'TestFolder', + }); expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ q: "mimeType='application/vnd.google-apps.folder' and name = 'TestFolder'", @@ -113,7 +124,9 @@ describe('DriveService', () => { }, }); - const result = await driveService.findFolder({ folderName: 'NonExistentFolder' }); + const result = await driveService.findFolder({ + folderName: 'NonExistentFolder', + }); expect(mockDriveAPI.files.list).toHaveBeenCalledTimes(1); expect(JSON.parse(result.content[0].text)).toEqual([]); @@ -123,9 +136,13 @@ describe('DriveService', () => { const apiError = new Error('API request failed'); mockDriveAPI.files.list.mockRejectedValue(apiError); - const result = await driveService.findFolder({ folderName: 'TestFolder' }); + const result = await driveService.findFolder({ + folderName: 'TestFolder', + }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API request failed' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API request failed', + }); }); }); @@ -157,7 +174,10 @@ describe('DriveService', () => { data: mockFolder, }); - const result = await driveService.createFolder({ name: 'New Folder', parentId: 'parent-id' }); + const result = await driveService.createFolder({ + name: 'New Folder', + parentId: 'parent-id', + }); expect(mockDriveAPI.files.create).toHaveBeenCalledWith({ requestBody: { @@ -177,15 +197,25 @@ describe('DriveService', () => { const result = await driveService.createFolder({ name: 'New Folder' }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API request failed' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API request failed', + }); }); }); describe('search', () => { it('should search files with custom query', async () => { const mockFiles = [ - { id: 'file1', name: 'Document.pdf', modifiedTime: '2024-01-01T00:00:00Z' }, - { id: 'file2', name: 'Spreadsheet.xlsx', modifiedTime: '2024-01-02T00:00:00Z' }, + { + id: 'file1', + name: 'Document.pdf', + modifiedTime: '2024-01-01T00:00:00Z', + }, + { + id: 'file2', + name: 'Spreadsheet.xlsx', + modifiedTime: '2024-01-02T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -205,7 +235,8 @@ describe('DriveService', () => { pageSize: 20, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -215,8 +246,16 @@ describe('DriveService', () => { it('should construct query if no field specifier is present', async () => { const mockFiles = [ - { id: 'file1', name: 'Document.pdf', modifiedTime: '2024-01-01T00:00:00Z' }, - { id: 'file2', name: 'Spreadsheet.xlsx', modifiedTime: '2024-01-02T00:00:00Z' }, + { + id: 'file1', + name: 'Document.pdf', + modifiedTime: '2024-01-01T00:00:00Z', + }, + { + id: 'file2', + name: 'Spreadsheet.xlsx', + modifiedTime: '2024-01-02T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -227,7 +266,7 @@ describe('DriveService', () => { }); const result = await driveService.search({ - query: "My Document", + query: 'My Document', pageSize: 20, }); @@ -236,7 +275,8 @@ describe('DriveService', () => { pageSize: 20, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -246,7 +286,11 @@ describe('DriveService', () => { it('should escape special characters in search query', async () => { const mockFiles = [ - { id: 'file1', name: "John's Report.pdf", modifiedTime: '2024-01-01T00:00:00Z' }, + { + id: 'file1', + name: "John's Report.pdf", + modifiedTime: '2024-01-01T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -266,7 +310,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -275,7 +320,11 @@ describe('DriveService', () => { it('should search by title when query starts with title:', async () => { const mockFiles = [ - { id: 'file1', name: 'My Document.pdf', modifiedTime: '2024-01-01T00:00:00Z' }, + { + id: 'file1', + name: 'My Document.pdf', + modifiedTime: '2024-01-01T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -295,7 +344,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -304,7 +354,11 @@ describe('DriveService', () => { it('should handle quoted title searches', async () => { const mockFiles = [ - { id: 'file1', name: 'Test Document', modifiedTime: '2024-01-01T00:00:00Z' }, + { + id: 'file1', + name: 'Test Document', + modifiedTime: '2024-01-01T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -323,7 +377,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -332,7 +387,11 @@ describe('DriveService', () => { it('should handle sharedWithMe filter', async () => { const mockFiles = [ - { id: 'shared1', name: 'SharedDoc.pdf', modifiedTime: '2024-01-01T00:00:00Z' }, + { + id: 'shared1', + name: 'SharedDoc.pdf', + modifiedTime: '2024-01-01T00:00:00Z', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -350,7 +409,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -359,7 +419,11 @@ describe('DriveService', () => { it('should filter unread files when unreadOnly is true', async () => { const mockFiles = [ - { id: 'file1', name: 'ReadDoc.pdf', viewedByMeTime: '2024-01-01T00:00:00Z' }, + { + id: 'file1', + name: 'ReadDoc.pdf', + viewedByMeTime: '2024-01-01T00:00:00Z', + }, { id: 'file2', name: 'UnreadDoc.pdf', viewedByMeTime: null }, { id: 'file3', name: 'UnreadSpreadsheet.xlsx' }, // No viewedByMeTime property ]; @@ -383,9 +447,7 @@ describe('DriveService', () => { }); it('should use pagination token', async () => { - const mockFiles = [ - { id: 'file3', name: 'Page2Doc.pdf' }, - ]; + const mockFiles = [{ id: 'file3', name: 'Page2Doc.pdf' }]; mockDriveAPI.files.list.mockResolvedValue({ data: { @@ -403,7 +465,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: 'previous-token', corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); }); @@ -424,7 +487,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: 'domain', - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); }); @@ -436,7 +500,9 @@ describe('DriveService', () => { query: 'type = "document"', }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'Search API failed' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'Search API failed', + }); }); it('should use default values when parameters are not provided', async () => { @@ -453,13 +519,18 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); }); it('should handle Google Drive folder URLs', async () => { const mockFiles = [ - { id: 'folder123', name: 'My Folder', mimeType: 'application/vnd.google-apps.folder' }, + { + id: 'folder123', + name: 'My Folder', + mimeType: 'application/vnd.google-apps.folder', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -478,7 +549,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -498,7 +570,8 @@ describe('DriveService', () => { }); const result = await driveService.search({ - query: 'https://drive.google.com/corp/drive/u/0/folders/1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u', + query: + 'https://drive.google.com/corp/drive/u/0/folders/1Ahs8C3GFWBZnrzQ44z0OR07hNQTWlE7u', pageSize: 10, }); @@ -507,7 +580,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -515,7 +589,11 @@ describe('DriveService', () => { }); it('should handle Google Drive file URLs', async () => { - const mockFile = { id: 'file456', name: 'My Document.pdf', mimeType: 'application/pdf' }; + const mockFile = { + id: 'file456', + name: 'My Document.pdf', + mimeType: 'application/pdf', + }; mockDriveAPI.files.get.mockResolvedValue({ data: mockFile, @@ -538,7 +616,11 @@ describe('DriveService', () => { }); it('should handle Google Docs URLs', async () => { - const mockFile = { id: 'doc789', name: 'My Document', mimeType: 'application/vnd.google-apps.document' }; + const mockFile = { + id: 'doc789', + name: 'My Document', + mimeType: 'application/vnd.google-apps.document', + }; mockDriveAPI.files.get.mockResolvedValue({ data: mockFile, @@ -567,15 +649,23 @@ describe('DriveService', () => { }); const responseData = JSON.parse(result.content[0].text); - expect(responseData.error).toBe('Invalid Drive URL. Please provide a valid Google Drive URL or a search query.'); - expect(responseData.details).toBe('Could not extract file or folder ID from the provided URL.'); + expect(responseData.error).toBe( + 'Invalid Drive URL. Please provide a valid Google Drive URL or a search query.', + ); + expect(responseData.details).toBe( + 'Could not extract file or folder ID from the provided URL.', + ); // Should not call the API for invalid URLs expect(mockDriveAPI.files.list).not.toHaveBeenCalled(); }); it('should handle folder URLs with id parameter', async () => { - const mockFolder = { id: 'folder789', name: 'My Folder', mimeType: 'application/vnd.google-apps.folder' }; + const mockFolder = { + id: 'folder789', + name: 'My Folder', + mimeType: 'application/vnd.google-apps.folder', + }; const mockFiles = [ { id: 'file1', name: 'Document.pdf', mimeType: 'application/pdf' }, ]; @@ -603,7 +693,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -611,13 +702,19 @@ describe('DriveService', () => { }); it('should handle file URLs with id parameter', async () => { - const mockFile = { id: 'file123', name: 'My File.pdf', mimeType: 'application/pdf' }; - - mockDriveAPI.files.get.mockResolvedValueOnce({ - data: { mimeType: 'application/pdf' }, - }).mockResolvedValueOnce({ - data: mockFile, - }); + const mockFile = { + id: 'file123', + name: 'My File.pdf', + mimeType: 'application/pdf', + }; + + mockDriveAPI.files.get + .mockResolvedValueOnce({ + data: { mimeType: 'application/pdf' }, + }) + .mockResolvedValueOnce({ + data: mockFile, + }); const result = await driveService.search({ query: 'https://drive.google.com/drive?id=file123', @@ -641,7 +738,11 @@ describe('DriveService', () => { it('should handle raw Drive IDs as folder queries', async () => { const mockFiles = [ { id: 'file1', name: 'Document.pdf', mimeType: 'application/pdf' }, - { id: 'file2', name: 'Spreadsheet.xlsx', mimeType: 'application/vnd.google-apps.spreadsheet' }, + { + id: 'file2', + name: 'Spreadsheet.xlsx', + mimeType: 'application/vnd.google-apps.spreadsheet', + }, ]; mockDriveAPI.files.list.mockResolvedValue({ @@ -660,7 +761,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -687,7 +789,8 @@ describe('DriveService', () => { pageSize: 10, pageToken: undefined, corpus: undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', }); const responseData = JSON.parse(result.content[0].text); @@ -704,16 +807,19 @@ describe('DriveService', () => { mockDriveAPI.files.get.mockImplementation((params: any) => { if (params.alt === 'media') { - return Promise.resolve({ - data: mockBuffer, - }); + return Promise.resolve({ + data: mockBuffer, + }); } return Promise.resolve({ - data: { id: mockFileId, name: 'test.txt', mimeType: 'text/plain' }, + data: { id: mockFileId, name: 'test.txt', mimeType: 'text/plain' }, }); }); - const result = await driveService.downloadFile({ fileId: mockFileId, localPath: mockLocalPath }); + const result = await driveService.downloadFile({ + fileId: mockFileId, + localPath: mockLocalPath, + }); expect(mockDriveAPI.files.get).toHaveBeenCalledWith({ fileId: mockFileId, @@ -722,14 +828,16 @@ describe('DriveService', () => { expect(mockDriveAPI.files.get).toHaveBeenCalledWith( { fileId: mockFileId, alt: 'media' }, - { responseType: 'arraybuffer' } + { responseType: 'arraybuffer' }, ); expect(fs.promises.writeFile).toHaveBeenCalledWith( expect.stringContaining(mockLocalPath), - mockBuffer + mockBuffer, + ); + expect(result.content[0].text).toContain( + `Successfully downloaded file test.txt`, ); - expect(result.content[0].text).toContain(`Successfully downloaded file test.txt`); }); it('should suggest specialized tools for workspace types', async () => { @@ -738,9 +846,14 @@ describe('DriveService', () => { data: { mimeType: 'application/vnd.google-apps.document' }, }); - const result = await driveService.downloadFile({ fileId: mockFileId, localPath: 'any' }); + const result = await driveService.downloadFile({ + fileId: mockFileId, + localPath: 'any', + }); - expect(result.content[0].text).toContain("This is a Google Doc. Direct download is not supported. Please use the 'docs.getText' tool with documentId: doc-id"); + expect(result.content[0].text).toContain( + "This is a Google Doc. Direct download is not supported. Please use the 'docs.getText' tool with documentId: doc-id", + ); expect(mockDriveAPI.files.get).toHaveBeenCalledTimes(1); }); @@ -748,9 +861,14 @@ describe('DriveService', () => { const mockFileId = 'error-file-id'; mockDriveAPI.files.get.mockRejectedValue(new Error('API Error')); - const result = await driveService.downloadFile({ fileId: mockFileId, localPath: 'any' }); + const result = await driveService.downloadFile({ + fileId: mockFileId, + localPath: 'any', + }); - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/services/GmailService.test.ts b/workspace-server/src/__tests__/services/GmailService.test.ts index 3945e83..bb6731a 100644 --- a/workspace-server/src/__tests__/services/GmailService.test.ts +++ b/workspace-server/src/__tests__/services/GmailService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import * as fs from 'node:fs/promises'; import { GmailService } from '../../services/GmailService'; import { AuthManager } from '../../auth/AuthManager'; @@ -75,7 +82,9 @@ describe('GmailService', () => { gmailService = new GmailService(mockAuthManager); const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); }); afterEach(() => { @@ -133,7 +142,7 @@ describe('GmailService', () => { expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith( expect.objectContaining({ pageToken: 'page-2', - }) + }), ); }); @@ -151,7 +160,7 @@ describe('GmailService', () => { expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith( expect.objectContaining({ labelIds: ['INBOX', 'UNREAD'], - }) + }), ); }); @@ -169,7 +178,7 @@ describe('GmailService', () => { expect(mockGmailAPI.users.messages.list).toHaveBeenCalledWith( expect.objectContaining({ includeSpamTrash: true, - }) + }), ); }); @@ -240,48 +249,48 @@ describe('GmailService', () => { }); it('should extract attachments in full format', async () => { - const mockMessage = { - id: 'msg_with_attach', - threadId: 'thread1', - payload: { - headers: [], - filename: '', - body: { size: 0 }, - parts: [ - { - mimeType: 'text/plain', - body: { data: 'SGVsbG8=' }, // Hello - filename: '' - }, - { - mimeType: 'application/pdf', - filename: 'test.pdf', - body: { attachmentId: 'attach1', size: 1000 } - } - ] - }, - }; - - mockGmailAPI.users.messages.get.mockResolvedValue({ - data: mockMessage, - }); - - const result = await gmailService.get({ - messageId: 'msg_with_attach', - format: 'full', - }); - - const response = JSON.parse(result.content[0].text); - expect(response.attachments).toHaveLength(1); - expect(response.attachments[0]).toEqual({ - filename: 'test.pdf', - mimeType: 'application/pdf', - attachmentId: 'attach1', - size: 1000 - }); - expect(response.body).toBe('Hello'); + const mockMessage = { + id: 'msg_with_attach', + threadId: 'thread1', + payload: { + headers: [], + filename: '', + body: { size: 0 }, + parts: [ + { + mimeType: 'text/plain', + body: { data: 'SGVsbG8=' }, // Hello + filename: '', + }, + { + mimeType: 'application/pdf', + filename: 'test.pdf', + body: { attachmentId: 'attach1', size: 1000 }, + }, + ], + }, + }; + + mockGmailAPI.users.messages.get.mockResolvedValue({ + data: mockMessage, + }); + + const result = await gmailService.get({ + messageId: 'msg_with_attach', + format: 'full', }); + const response = JSON.parse(result.content[0].text); + expect(response.attachments).toHaveLength(1); + expect(response.attachments[0]).toEqual({ + filename: 'test.pdf', + mimeType: 'application/pdf', + attachmentId: 'attach1', + size: 1000, + }); + expect(response.body).toBe('Hello'); + }); + it('should handle minimal format', async () => { const mockMessage = { id: 'msg1', @@ -310,9 +319,7 @@ describe('GmailService', () => { data: { id: 'msg1', payload: { - headers: [ - { name: 'Subject', value: 'Test' }, - ], + headers: [{ name: 'Subject', value: 'Test' }], }, }, }); @@ -342,80 +349,80 @@ describe('GmailService', () => { describe('downloadAttachment', () => { it('should download an attachment successfully', async () => { - // Setup mocks - const mockAttachmentData = { - data: 'SGVsbG8gV29ybGQ=', // Base64 for "Hello World" - }; - mockGmailAPI.users.messages.attachments.get.mockResolvedValue({ - data: mockAttachmentData, - }); - - (fs.mkdir as any).mockResolvedValue('/tmp'); - (fs.writeFile as any).mockResolvedValue(undefined); - - // Execute - const result = await gmailService.downloadAttachment({ - messageId: 'msg1', - attachmentId: 'attach1', - localPath: '/tmp/test.txt', - }); - - // Verify - expect(mockGmailAPI.users.messages.attachments.get).toHaveBeenCalledWith({ - userId: 'me', - messageId: 'msg1', - id: 'attach1', - }); - - expect(fs.mkdir).toHaveBeenCalledWith('/tmp', { recursive: true }); - expect(fs.writeFile).toHaveBeenCalledWith( - '/tmp/test.txt', - expect.any(Buffer) // We check if it's a buffer, content verification is implicit via Buffer.from logic - ); - - const response = JSON.parse(result.content[0].text); - expect(response.message).toContain('Attachment downloaded successfully'); - expect(response.path).toBe('/tmp/test.txt'); + // Setup mocks + const mockAttachmentData = { + data: 'SGVsbG8gV29ybGQ=', // Base64 for "Hello World" + }; + mockGmailAPI.users.messages.attachments.get.mockResolvedValue({ + data: mockAttachmentData, + }); + + (fs.mkdir as any).mockResolvedValue('/tmp'); + (fs.writeFile as any).mockResolvedValue(undefined); + + // Execute + const result = await gmailService.downloadAttachment({ + messageId: 'msg1', + attachmentId: 'attach1', + localPath: '/tmp/test.txt', + }); + + // Verify + expect(mockGmailAPI.users.messages.attachments.get).toHaveBeenCalledWith({ + userId: 'me', + messageId: 'msg1', + id: 'attach1', + }); + + expect(fs.mkdir).toHaveBeenCalledWith('/tmp', { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + '/tmp/test.txt', + expect.any(Buffer), // We check if it's a buffer, content verification is implicit via Buffer.from logic + ); + + const response = JSON.parse(result.content[0].text); + expect(response.message).toContain('Attachment downloaded successfully'); + expect(response.path).toBe('/tmp/test.txt'); }); it('should reject relative paths', async () => { - const result = await gmailService.downloadAttachment({ - messageId: 'msg1', - attachmentId: 'attach1', - localPath: 'relative/path.txt', - }); + const result = await gmailService.downloadAttachment({ + messageId: 'msg1', + attachmentId: 'attach1', + localPath: 'relative/path.txt', + }); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('localPath must be an absolute path.'); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('localPath must be an absolute path.'); }); it('should handle empty attachment data', async () => { - mockGmailAPI.users.messages.attachments.get.mockResolvedValue({ - data: {}, // No data - }); + mockGmailAPI.users.messages.attachments.get.mockResolvedValue({ + data: {}, // No data + }); - const result = await gmailService.downloadAttachment({ - messageId: 'msg1', - attachmentId: 'attach1', - localPath: '/tmp/test.txt', - }); + const result = await gmailService.downloadAttachment({ + messageId: 'msg1', + attachmentId: 'attach1', + localPath: '/tmp/test.txt', + }); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('Attachment data is empty'); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Attachment data is empty'); }); it('should handle download errors', async () => { - const error = new Error('Download failed'); - mockGmailAPI.users.messages.attachments.get.mockRejectedValue(error); + const error = new Error('Download failed'); + mockGmailAPI.users.messages.attachments.get.mockRejectedValue(error); - const result = await gmailService.downloadAttachment({ - messageId: 'msg1', - attachmentId: 'attach1', - localPath: '/tmp/test.txt', - }); + const result = await gmailService.downloadAttachment({ + messageId: 'msg1', + attachmentId: 'attach1', + localPath: '/tmp/test.txt', + }); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('Download failed'); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Download failed'); }); }); @@ -580,7 +587,9 @@ describe('GmailService', () => { describe('send', () => { beforeEach(async () => { // Mock MimeHelper - (MimeHelper.createMimeMessage as jest.Mock) = jest.fn().mockReturnValue('base64encodedmessage'); + (MimeHelper.createMimeMessage as jest.Mock) = jest + .fn() + .mockReturnValue('base64encodedmessage'); }); it('should send an email with basic parameters', async () => { @@ -661,7 +670,7 @@ describe('GmailService', () => { expect(MimeHelper.createMimeMessage).toHaveBeenCalledWith( expect.objectContaining({ isHtml: true, - }) + }), ); }); @@ -682,7 +691,9 @@ describe('GmailService', () => { describe('createDraft', () => { beforeEach(async () => { - (MimeHelper.createMimeMessage as jest.Mock) = jest.fn().mockReturnValue('base64encodedmessage'); + (MimeHelper.createMimeMessage as jest.Mock) = jest + .fn() + .mockReturnValue('base64encodedmessage'); }); it('should create a draft email', async () => { diff --git a/workspace-server/src/__tests__/services/PeopleService.test.ts b/workspace-server/src/__tests__/services/PeopleService.test.ts index f1bc8f6..371dd34 100644 --- a/workspace-server/src/__tests__/services/PeopleService.test.ts +++ b/workspace-server/src/__tests__/services/PeopleService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { PeopleService } from '../../services/PeopleService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; @@ -14,262 +21,293 @@ jest.mock('googleapis'); jest.mock('../../utils/logger'); describe('PeopleService', () => { - let peopleService: PeopleService; - let mockAuthManager: jest.Mocked; - let mockPeopleAPI: any; - - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - - // Create mock AuthManager - mockAuthManager = { - getAuthenticatedClient: jest.fn(), - } as any; - - // Create mock People API - mockPeopleAPI = { - people: { - get: jest.fn(), - searchDirectoryPeople: jest.fn(), + let peopleService: PeopleService; + let mockAuthManager: jest.Mocked; + let mockPeopleAPI: any; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Create mock AuthManager + mockAuthManager = { + getAuthenticatedClient: jest.fn(), + } as any; + + // Create mock People API + mockPeopleAPI = { + people: { + get: jest.fn(), + searchDirectoryPeople: jest.fn(), + }, + }; + + // Mock the google constructors + (google.people as jest.Mock) = jest.fn().mockReturnValue(mockPeopleAPI); + + // Create PeopleService instance + peopleService = new PeopleService(mockAuthManager); + + const mockAuthClient = { access_token: 'test-token' }; + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getUserProfile', () => { + it('should return a user profile by userId', async () => { + const mockUser = { + data: { + resourceName: 'people/110001608645105799644', + names: [ + { + displayName: 'Test User', }, - }; + ], + emailAddresses: [ + { + value: 'test@example.com', + }, + ], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockUser); + + const result = await peopleService.getUserProfile({ + userId: '110001608645105799644', + }); + + expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ + resourceName: 'people/110001608645105799644', + personFields: 'names,emailAddresses', + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + results: [{ person: mockUser.data }], + }); + }); - // Mock the google constructors - (google.people as jest.Mock) = jest.fn().mockReturnValue(mockPeopleAPI); + it('should return a user profile by email', async () => { + const mockUser = { + data: { + results: [ + { + person: { + resourceName: 'people/110001608645105799644', + names: [ + { + displayName: 'Test User', + }, + ], + emailAddresses: [ + { + value: 'test@example.com', + }, + ], + }, + }, + ], + }, + }; + mockPeopleAPI.people.searchDirectoryPeople.mockResolvedValue(mockUser); + + const result = await peopleService.getUserProfile({ + email: 'test@example.com', + }); + + expect(mockPeopleAPI.people.searchDirectoryPeople).toHaveBeenCalledWith({ + query: 'test@example.com', + readMask: 'names,emailAddresses', + sources: [ + 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT', + 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE', + ], + }); + expect(JSON.parse(result.content[0].text)).toEqual(mockUser.data); + }); + + it('should handle errors during getUserProfile', async () => { + const apiError = new Error('API Error'); + mockPeopleAPI.people.get.mockRejectedValue(apiError); - // Create PeopleService instance - peopleService = new PeopleService(mockAuthManager); + const result = await peopleService.getUserProfile({ + userId: '110001608645105799644', + }); - const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); }); + }); + + describe('getMe', () => { + it("should return the authenticated user's profile", async () => { + const mockMe = { + data: { + resourceName: 'people/me', + names: [ + { + displayName: 'Me', + }, + ], + emailAddresses: [ + { + value: 'me@example.com', + }, + ], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockMe); + + const result = await peopleService.getMe(); + + expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ + resourceName: 'people/me', + personFields: 'names,emailAddresses', + }); + expect(JSON.parse(result.content[0].text)).toEqual(mockMe.data); + }); + + it('should handle errors during getMe', async () => { + const apiError = new Error('API Error'); + mockPeopleAPI.people.get.mockRejectedValue(apiError); - afterEach(() => { - jest.restoreAllMocks(); + const result = await peopleService.getMe(); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + }); + + describe('getUserRelations', () => { + it('should return all relations when no relationType is specified', async () => { + const mockRelations = { + data: { + relations: [ + { person: 'John Doe', type: 'manager' }, + { person: 'Jane Doe', type: 'spouse' }, + { person: 'Bob Smith', type: 'assistant' }, + ], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + const result = await peopleService.getUserRelations({}); + + expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ + resourceName: 'people/me', + personFields: 'relations', + }); + expect(JSON.parse(result.content[0].text)).toEqual({ + resourceName: 'people/me', + relations: mockRelations.data.relations, + }); }); - describe('getUserProfile', () => { - it('should return a user profile by userId', async () => { - const mockUser = { - data: { - resourceName: 'people/110001608645105799644', - names: [{ - displayName: 'Test User', - }], - emailAddresses: [{ - value: 'test@example.com', - }], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockUser); - - const result = await peopleService.getUserProfile({ userId: '110001608645105799644' }); - - expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ - resourceName: 'people/110001608645105799644', - personFields: 'names,emailAddresses', - }); - expect(JSON.parse(result.content[0].text)).toEqual({ results: [{ person: mockUser.data }] }); - }); - - it('should return a user profile by email', async () => { - const mockUser = { - data: { - results: [ - { - person: { - resourceName: 'people/110001608645105799644', - names: [{ - displayName: 'Test User', - }], - emailAddresses: [{ - value: 'test@example.com', - }], - } - } - ] - }, - }; - mockPeopleAPI.people.searchDirectoryPeople.mockResolvedValue(mockUser); - - const result = await peopleService.getUserProfile({ email: 'test@example.com' }); - - expect(mockPeopleAPI.people.searchDirectoryPeople).toHaveBeenCalledWith({ - query: 'test@example.com', - readMask: 'names,emailAddresses', - sources: ['DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT', 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'], - }); - expect(JSON.parse(result.content[0].text)).toEqual(mockUser.data); - }); - - it('should handle errors during getUserProfile', async () => { - const apiError = new Error('API Error'); - mockPeopleAPI.people.get.mockRejectedValue(apiError); - - const result = await peopleService.getUserProfile({ userId: '110001608645105799644' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); + it('should filter relations by relationType when specified', async () => { + const mockRelations = { + data: { + relations: [ + { person: 'John Doe', type: 'manager' }, + { person: 'Jane Doe', type: 'spouse' }, + { person: 'Bob Smith', type: 'assistant' }, + ], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + const result = await peopleService.getUserRelations({ + relationType: 'manager', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + resourceName: 'people/me', + relationType: 'manager', + relations: [{ person: 'John Doe', type: 'manager' }], + }); }); - describe('getMe', () => { - it('should return the authenticated user\'s profile', async () => { - const mockMe = { - data: { - resourceName: 'people/me', - names: [{ - displayName: 'Me', - }], - emailAddresses: [{ - value: 'me@example.com', - }], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockMe); - - const result = await peopleService.getMe(); - - expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ - resourceName: 'people/me', - personFields: 'names,emailAddresses', - }); - expect(JSON.parse(result.content[0].text)).toEqual(mockMe.data); - }); - - it('should handle errors during getMe', async () => { - const apiError = new Error('API Error'); - mockPeopleAPI.people.get.mockRejectedValue(apiError); - - const result = await peopleService.getMe(); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); + it('should filter relations case-insensitively', async () => { + const mockRelations = { + data: { + relations: [{ person: 'John Doe', type: 'Manager' }], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + const result = await peopleService.getUserRelations({ + relationType: 'MANAGER', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + resourceName: 'people/me', + relationType: 'MANAGER', + relations: [{ person: 'John Doe', type: 'Manager' }], + }); }); - describe('getUserRelations', () => { - it('should return all relations when no relationType is specified', async () => { - const mockRelations = { - data: { - relations: [ - { person: 'John Doe', type: 'manager' }, - { person: 'Jane Doe', type: 'spouse' }, - { person: 'Bob Smith', type: 'assistant' }, - ], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - const result = await peopleService.getUserRelations({}); - - expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ - resourceName: 'people/me', - personFields: 'relations', - }); - expect(JSON.parse(result.content[0].text)).toEqual({ - resourceName: 'people/me', - relations: mockRelations.data.relations, - }); - }); - - it('should filter relations by relationType when specified', async () => { - const mockRelations = { - data: { - relations: [ - { person: 'John Doe', type: 'manager' }, - { person: 'Jane Doe', type: 'spouse' }, - { person: 'Bob Smith', type: 'assistant' }, - ], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - const result = await peopleService.getUserRelations({ relationType: 'manager' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ - resourceName: 'people/me', - relationType: 'manager', - relations: [{ person: 'John Doe', type: 'manager' }], - }); - }); - - it('should filter relations case-insensitively', async () => { - const mockRelations = { - data: { - relations: [ - { person: 'John Doe', type: 'Manager' }, - ], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - const result = await peopleService.getUserRelations({ relationType: 'MANAGER' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ - resourceName: 'people/me', - relationType: 'MANAGER', - relations: [{ person: 'John Doe', type: 'Manager' }], - }); - }); - - it('should return empty relations array when no relations exist', async () => { - const mockRelations = { - data: {}, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - const result = await peopleService.getUserRelations({}); - - expect(JSON.parse(result.content[0].text)).toEqual({ - resourceName: 'people/me', - relations: [], - }); - }); - - it('should return empty array when filtering for non-existent relationType', async () => { - const mockRelations = { - data: { - relations: [ - { person: 'John Doe', type: 'manager' }, - ], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - const result = await peopleService.getUserRelations({ relationType: 'spouse' }); - - expect(JSON.parse(result.content[0].text)).toEqual({ - resourceName: 'people/me', - relationType: 'spouse', - relations: [], - }); - }); - - it('should handle errors during getUserRelations', async () => { - const apiError = new Error('API Error'); - mockPeopleAPI.people.get.mockRejectedValue(apiError); - - const result = await peopleService.getUserRelations({}); - - expect(JSON.parse(result.content[0].text)).toEqual({ error: 'API Error' }); - }); - - it('should call with the correct resourceName when a userId is provided', async () => { - const mockRelations = { - data: { - relations: [ - { person: 'John Doe', type: 'manager' }, - ], - }, - }; - mockPeopleAPI.people.get.mockResolvedValue(mockRelations); - - await peopleService.getUserRelations({ userId: '110001608645105799644' }); - - expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ - resourceName: 'people/110001608645105799644', - personFields: 'relations', - }); - }); + it('should return empty relations array when no relations exist', async () => { + const mockRelations = { + data: {}, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + const result = await peopleService.getUserRelations({}); + + expect(JSON.parse(result.content[0].text)).toEqual({ + resourceName: 'people/me', + relations: [], + }); + }); + + it('should return empty array when filtering for non-existent relationType', async () => { + const mockRelations = { + data: { + relations: [{ person: 'John Doe', type: 'manager' }], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + const result = await peopleService.getUserRelations({ + relationType: 'spouse', + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + resourceName: 'people/me', + relationType: 'spouse', + relations: [], + }); + }); + + it('should handle errors during getUserRelations', async () => { + const apiError = new Error('API Error'); + mockPeopleAPI.people.get.mockRejectedValue(apiError); + + const result = await peopleService.getUserRelations({}); + + expect(JSON.parse(result.content[0].text)).toEqual({ + error: 'API Error', + }); + }); + + it('should call with the correct resourceName when a userId is provided', async () => { + const mockRelations = { + data: { + relations: [{ person: 'John Doe', type: 'manager' }], + }, + }; + mockPeopleAPI.people.get.mockResolvedValue(mockRelations); + + await peopleService.getUserRelations({ userId: '110001608645105799644' }); + + expect(mockPeopleAPI.people.get).toHaveBeenCalledWith({ + resourceName: 'people/110001608645105799644', + personFields: 'relations', + }); }); -}); \ No newline at end of file + }); +}); diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 0137919..e61b675 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { SheetsService } from '../../services/SheetsService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; @@ -14,401 +21,413 @@ jest.mock('googleapis'); jest.mock('../../utils/logger'); describe('SheetsService', () => { - let sheetsService: SheetsService; - let mockAuthManager: jest.Mocked; - let mockSheetsAPI: any; - let mockDriveAPI: any; - - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - - // Create mock AuthManager - mockAuthManager = { - getAuthenticatedClient: jest.fn(), - } as any; - - - // Create mock Sheets API - mockSheetsAPI = { - spreadsheets: { - get: jest.fn(), - values: { - get: jest.fn(), - }, - }, - }; - - mockDriveAPI = { - files: { - list: jest.fn(), - }, - }; - - // Mock the google constructors - (google.sheets as jest.Mock) = jest.fn().mockReturnValue(mockSheetsAPI); - (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); - - // Create SheetsService instance - sheetsService = new SheetsService(mockAuthManager); - - const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); + let sheetsService: SheetsService; + let mockAuthManager: jest.Mocked; + let mockSheetsAPI: any; + let mockDriveAPI: any; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Create mock AuthManager + mockAuthManager = { + getAuthenticatedClient: jest.fn(), + } as any; + + // Create mock Sheets API + mockSheetsAPI = { + spreadsheets: { + get: jest.fn(), + values: { + get: jest.fn(), + }, + }, + }; + + mockDriveAPI = { + files: { + list: jest.fn(), + }, + }; + + // Mock the google constructors + (google.sheets as jest.Mock) = jest.fn().mockReturnValue(mockSheetsAPI); + (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); + + // Create SheetsService instance + sheetsService = new SheetsService(mockAuthManager); + + const mockAuthClient = { access_token: 'test-token' }; + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getText', () => { + it('should extract text from a spreadsheet in default format', async () => { + const mockSpreadsheet = { + data: { + properties: { + title: 'Test Spreadsheet', + }, + sheets: [ + { properties: { title: 'Sheet1' } }, + { properties: { title: 'Sheet2' } }, + ], + }, + }; + + const mockSheet1Data = { + data: { + values: [ + ['Header1', 'Header2', 'Header3'], + ['Row1Col1', 'Row1Col2', 'Row1Col3'], + ['Row2Col1', 'Row2Col2', 'Row2Col3'], + ], + }, + }; + + const mockSheet2Data = { + data: { + values: [ + ['A', 'B'], + ['1', '2'], + ], + }, + }; + + mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); + mockSheetsAPI.spreadsheets.values.get + .mockResolvedValueOnce(mockSheet1Data) + .mockResolvedValueOnce(mockSheet2Data); + + const result = await sheetsService.getText({ + spreadsheetId: 'test-spreadsheet-id', + }); + + expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({ + spreadsheetId: 'test-spreadsheet-id', + includeGridData: false, + }); + + expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledTimes(2); + expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(1, { + spreadsheetId: 'test-spreadsheet-id', + range: "'Sheet1'", + }); + expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(2, { + spreadsheetId: 'test-spreadsheet-id', + range: "'Sheet2'", + }); + + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Test Spreadsheet'); + expect(result.content[0].text).toContain('Sheet1'); + expect(result.content[0].text).toContain('Header1 | Header2 | Header3'); + expect(result.content[0].text).toContain('Sheet2'); + expect(result.content[0].text).toContain('A | B'); }); - afterEach(() => { - jest.restoreAllMocks(); + it('should extract text in CSV format', async () => { + const mockSpreadsheet = { + data: { + properties: { + title: 'CSV Test', + }, + sheets: [{ properties: { title: 'Sheet1' } }], + }, + }; + + const mockSheetData = { + data: { + values: [ + ['Name', 'Age', 'City'], + ['John, Jr.', '25', 'New York'], + ['Jane', '30', 'San Francisco'], + ], + }, + }; + + mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); + mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); + + const result = await sheetsService.getText({ + spreadsheetId: 'test-spreadsheet-id', + format: 'csv', + }); + + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Name,Age,City'); + expect(result.content[0].text).toContain('"John, Jr.",25,New York'); }); - describe('getText', () => { - it('should extract text from a spreadsheet in default format', async () => { - const mockSpreadsheet = { - data: { - properties: { - title: 'Test Spreadsheet', - }, - sheets: [ - { properties: { title: 'Sheet1' } }, - { properties: { title: 'Sheet2' } }, - ], - }, - }; - - const mockSheet1Data = { - data: { - values: [ - ['Header1', 'Header2', 'Header3'], - ['Row1Col1', 'Row1Col2', 'Row1Col3'], - ['Row2Col1', 'Row2Col2', 'Row2Col3'], - ], - }, - }; - - const mockSheet2Data = { - data: { - values: [ - ['A', 'B'], - ['1', '2'], - ], - }, - }; - - mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); - mockSheetsAPI.spreadsheets.values.get - .mockResolvedValueOnce(mockSheet1Data) - .mockResolvedValueOnce(mockSheet2Data); - - const result = await sheetsService.getText({ spreadsheetId: 'test-spreadsheet-id' }); - - expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({ - spreadsheetId: 'test-spreadsheet-id', - includeGridData: false, - }); - - expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledTimes(2); - expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(1, { - spreadsheetId: 'test-spreadsheet-id', - range: "'Sheet1'", - }); - expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenNthCalledWith(2, { - spreadsheetId: 'test-spreadsheet-id', - range: "'Sheet2'", - }); - - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Test Spreadsheet'); - expect(result.content[0].text).toContain('Sheet1'); - expect(result.content[0].text).toContain('Header1 | Header2 | Header3'); - expect(result.content[0].text).toContain('Sheet2'); - expect(result.content[0].text).toContain('A | B'); - }); - - it('should extract text in CSV format', async () => { - const mockSpreadsheet = { - data: { - properties: { - title: 'CSV Test', - }, - sheets: [ - { properties: { title: 'Sheet1' } }, - ], - }, - }; - - const mockSheetData = { - data: { - values: [ - ['Name', 'Age', 'City'], - ['John, Jr.', '25', 'New York'], - ['Jane', '30', 'San Francisco'], - ], - }, - }; - - mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); - mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); - - const result = await sheetsService.getText({ - spreadsheetId: 'test-spreadsheet-id', - format: 'csv' - }); - - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Name,Age,City'); - expect(result.content[0].text).toContain('"John, Jr.",25,New York'); - }); - - it('should extract text in JSON format', async () => { - const mockSpreadsheet = { - data: { - properties: { - title: 'JSON Test', - }, - sheets: [ - { properties: { title: 'Sheet1' } }, - ], - }, - }; - - const mockSheetData = { - data: { - values: [ - ['A', 'B'], - ['1', '2'], - ], - }, - }; - - mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); - mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); - - const result = await sheetsService.getText({ - spreadsheetId: 'test-spreadsheet-id', - format: 'json' - }); - - expect(result.content[0].type).toBe('text'); - const jsonResult = JSON.parse(result.content[0].text); - expect(jsonResult.Sheet1).toEqual([['A', 'B'], ['1', '2']]); - }); - - it('should handle empty sheets', async () => { - const mockSpreadsheet = { - data: { - properties: { - title: 'Empty Test', - }, - sheets: [ - { properties: { title: 'EmptySheet' } }, - ], - }, - }; - - const mockSheetData = { - data: { - values: [], - }, - }; - - mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); - mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); - - const result = await sheetsService.getText({ spreadsheetId: 'test-spreadsheet-id' }); - - expect(result.content[0].text).toContain('EmptySheet'); - expect(result.content[0].text).toContain('(Empty sheet)'); - }); - - it('should handle errors gracefully', async () => { - mockSheetsAPI.spreadsheets.get.mockRejectedValue(new Error('API Error')); - - const result = await sheetsService.getText({ spreadsheetId: 'error-spreadsheet-id' }); + it('should extract text in JSON format', async () => { + const mockSpreadsheet = { + data: { + properties: { + title: 'JSON Test', + }, + sheets: [{ properties: { title: 'Sheet1' } }], + }, + }; + + const mockSheetData = { + data: { + values: [ + ['A', 'B'], + ['1', '2'], + ], + }, + }; + + mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); + mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); + + const result = await sheetsService.getText({ + spreadsheetId: 'test-spreadsheet-id', + format: 'json', + }); + + expect(result.content[0].type).toBe('text'); + const jsonResult = JSON.parse(result.content[0].text); + expect(jsonResult.Sheet1).toEqual([ + ['A', 'B'], + ['1', '2'], + ]); + }); - expect(result.content[0].type).toBe('text'); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('API Error'); - }); + it('should handle empty sheets', async () => { + const mockSpreadsheet = { + data: { + properties: { + title: 'Empty Test', + }, + sheets: [{ properties: { title: 'EmptySheet' } }], + }, + }; + + const mockSheetData = { + data: { + values: [], + }, + }; + + mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); + mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockSheetData); + + const result = await sheetsService.getText({ + spreadsheetId: 'test-spreadsheet-id', + }); + + expect(result.content[0].text).toContain('EmptySheet'); + expect(result.content[0].text).toContain('(Empty sheet)'); }); - describe('getRange', () => { - it('should get values from a specific range', async () => { - const mockRangeData = { - data: { - range: 'Sheet1!A1:B3', - values: [ - ['A1', 'B1'], - ['A2', 'B2'], - ['A3', 'B3'], - ], - }, - }; - - mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData); - - const result = await sheetsService.getRange({ - spreadsheetId: 'test-spreadsheet-id', - range: 'Sheet1!A1:B3' - }); - - expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledWith({ - spreadsheetId: 'test-spreadsheet-id', - range: 'Sheet1!A1:B3', - }); - - const response = JSON.parse(result.content[0].text); - expect(response.range).toBe('Sheet1!A1:B3'); - expect(response.values).toHaveLength(3); - expect(response.values[0]).toEqual(['A1', 'B1']); - }); - - it('should handle empty ranges', async () => { - const mockRangeData = { - data: { - range: 'Sheet1!Z100:Z200', - values: [], - }, - }; + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.get.mockRejectedValue(new Error('API Error')); - mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData); + const result = await sheetsService.getText({ + spreadsheetId: 'error-spreadsheet-id', + }); - const result = await sheetsService.getRange({ - spreadsheetId: 'test-spreadsheet-id', - range: 'Sheet1!Z100:Z200' - }); + expect(result.content[0].type).toBe('text'); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('API Error'); + }); + }); + + describe('getRange', () => { + it('should get values from a specific range', async () => { + const mockRangeData = { + data: { + range: 'Sheet1!A1:B3', + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ['A3', 'B3'], + ], + }, + }; + + mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData); + + const result = await sheetsService.getRange({ + spreadsheetId: 'test-spreadsheet-id', + range: 'Sheet1!A1:B3', + }); + + expect(mockSheetsAPI.spreadsheets.values.get).toHaveBeenCalledWith({ + spreadsheetId: 'test-spreadsheet-id', + range: 'Sheet1!A1:B3', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.range).toBe('Sheet1!A1:B3'); + expect(response.values).toHaveLength(3); + expect(response.values[0]).toEqual(['A1', 'B1']); + }); - const response = JSON.parse(result.content[0].text); - expect(response.values).toEqual([]); - }); + it('should handle empty ranges', async () => { + const mockRangeData = { + data: { + range: 'Sheet1!Z100:Z200', + values: [], + }, + }; - it('should handle errors gracefully', async () => { - mockSheetsAPI.spreadsheets.values.get.mockRejectedValue(new Error('Range Error')); + mockSheetsAPI.spreadsheets.values.get.mockResolvedValue(mockRangeData); - const result = await sheetsService.getRange({ - spreadsheetId: 'error-id', - range: 'InvalidRange' - }); + const result = await sheetsService.getRange({ + spreadsheetId: 'test-spreadsheet-id', + range: 'Sheet1!Z100:Z200', + }); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('Range Error'); - }); + const response = JSON.parse(result.content[0].text); + expect(response.values).toEqual([]); }); - describe('find', () => { - it('should find spreadsheets by query', async () => { - const mockResponse = { - data: { - files: [ - { id: 'sheet1', name: 'Spreadsheet 1' }, - { id: 'sheet2', name: 'Spreadsheet 2' }, - ], - nextPageToken: 'next-token', - }, - }; - - mockDriveAPI.files.list.mockResolvedValue(mockResponse); + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.get.mockRejectedValue( + new Error('Range Error'), + ); - const result = await sheetsService.find({ query: 'budget' }); - const response = JSON.parse(result.content[0].text); + const result = await sheetsService.getRange({ + spreadsheetId: 'error-id', + range: 'InvalidRange', + }); - expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ - pageSize: 10, - fields: 'nextPageToken, files(id, name)', - q: "mimeType='application/vnd.google-apps.spreadsheet' and fullText contains 'budget'", - pageToken: undefined, - }); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Range Error'); + }); + }); + + describe('find', () => { + it('should find spreadsheets by query', async () => { + const mockResponse = { + data: { + files: [ + { id: 'sheet1', name: 'Spreadsheet 1' }, + { id: 'sheet2', name: 'Spreadsheet 2' }, + ], + nextPageToken: 'next-token', + }, + }; + + mockDriveAPI.files.list.mockResolvedValue(mockResponse); + + const result = await sheetsService.find({ query: 'budget' }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ + pageSize: 10, + fields: 'nextPageToken, files(id, name)', + q: "mimeType='application/vnd.google-apps.spreadsheet' and fullText contains 'budget'", + pageToken: undefined, + }); + + expect(response.files).toHaveLength(2); + expect(response.files[0].name).toBe('Spreadsheet 1'); + expect(response.nextPageToken).toBe('next-token'); + }); - expect(response.files).toHaveLength(2); - expect(response.files[0].name).toBe('Spreadsheet 1'); - expect(response.nextPageToken).toBe('next-token'); - }); + it('should handle title-specific searches', async () => { + const mockResponse = { + data: { + files: [{ id: 'sheet1', name: 'Q4 Budget' }], + }, + }; - it('should handle title-specific searches', async () => { - const mockResponse = { - data: { - files: [{ id: 'sheet1', name: 'Q4 Budget' }], - }, - }; + mockDriveAPI.files.list.mockResolvedValue(mockResponse); - mockDriveAPI.files.list.mockResolvedValue(mockResponse); + const result = await sheetsService.find({ query: 'title:"Q4 Budget"' }); + const response = JSON.parse(result.content[0].text); - const result = await sheetsService.find({ query: 'title:"Q4 Budget"' }); - const response = JSON.parse(result.content[0].text); + expect(mockDriveAPI.files.list).toHaveBeenCalledWith( + expect.objectContaining({ + q: "mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Q4 Budget'", + }), + ); - expect(mockDriveAPI.files.list).toHaveBeenCalledWith( - expect.objectContaining({ - q: "mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Q4 Budget'", - }) - ); - - expect(response.files).toHaveLength(1); - expect(response.files[0].name).toBe('Q4 Budget'); - }); + expect(response.files).toHaveLength(1); + expect(response.files[0].name).toBe('Q4 Budget'); }); - - describe('getMetadata', () => { - it('should retrieve spreadsheet metadata', async () => { - const mockSpreadsheet = { - data: { - spreadsheetId: 'test-id', - properties: { - title: 'Test Spreadsheet', - locale: 'en_US', - timeZone: 'America/New_York', - }, - sheets: [ - { - properties: { - sheetId: 0, - title: 'Sheet1', - index: 0, - gridProperties: { - rowCount: 1000, - columnCount: 26, - }, - }, - }, - { - properties: { - sheetId: 1, - title: 'Sheet2', - index: 1, - gridProperties: { - rowCount: 500, - columnCount: 10, - }, - }, - }, - ], + }); + + describe('getMetadata', () => { + it('should retrieve spreadsheet metadata', async () => { + const mockSpreadsheet = { + data: { + spreadsheetId: 'test-id', + properties: { + title: 'Test Spreadsheet', + locale: 'en_US', + timeZone: 'America/New_York', + }, + sheets: [ + { + properties: { + sheetId: 0, + title: 'Sheet1', + index: 0, + gridProperties: { + rowCount: 1000, + columnCount: 26, }, - }; - - mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); - - const result = await sheetsService.getMetadata({ spreadsheetId: 'test-id' }); - const metadata = JSON.parse(result.content[0].text); - - expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({ - spreadsheetId: 'test-id', - includeGridData: false, - }); - - expect(metadata.spreadsheetId).toBe('test-id'); - expect(metadata.title).toBe('Test Spreadsheet'); - expect(metadata.locale).toBe('en_US'); - expect(metadata.timeZone).toBe('America/New_York'); - expect(metadata.sheets).toHaveLength(2); - expect(metadata.sheets[0].title).toBe('Sheet1'); - expect(metadata.sheets[0].rowCount).toBe(1000); - expect(metadata.sheets[0].columnCount).toBe(26); - }); + }, + }, + { + properties: { + sheetId: 1, + title: 'Sheet2', + index: 1, + gridProperties: { + rowCount: 500, + columnCount: 10, + }, + }, + }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.get.mockResolvedValue(mockSpreadsheet); + + const result = await sheetsService.getMetadata({ + spreadsheetId: 'test-id', + }); + const metadata = JSON.parse(result.content[0].text); + + expect(mockSheetsAPI.spreadsheets.get).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + includeGridData: false, + }); + + expect(metadata.spreadsheetId).toBe('test-id'); + expect(metadata.title).toBe('Test Spreadsheet'); + expect(metadata.locale).toBe('en_US'); + expect(metadata.timeZone).toBe('America/New_York'); + expect(metadata.sheets).toHaveLength(2); + expect(metadata.sheets[0].title).toBe('Sheet1'); + expect(metadata.sheets[0].rowCount).toBe(1000); + expect(metadata.sheets[0].columnCount).toBe(26); + }); - it('should handle errors gracefully', async () => { - mockSheetsAPI.spreadsheets.get.mockRejectedValue(new Error('Metadata Error')); + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.get.mockRejectedValue( + new Error('Metadata Error'), + ); - const result = await sheetsService.getMetadata({ spreadsheetId: 'error-id' }); - const response = JSON.parse(result.content[0].text); + const result = await sheetsService.getMetadata({ + spreadsheetId: 'error-id', + }); + const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('Metadata Error'); - }); + expect(response.error).toBe('Metadata Error'); }); + }); }); diff --git a/workspace-server/src/__tests__/services/SlidesService.test.ts b/workspace-server/src/__tests__/services/SlidesService.test.ts index 55e88ca..a7799b2 100644 --- a/workspace-server/src/__tests__/services/SlidesService.test.ts +++ b/workspace-server/src/__tests__/services/SlidesService.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import { SlidesService } from '../../services/SlidesService'; import { AuthManager } from '../../auth/AuthManager'; import { google } from 'googleapis'; @@ -14,236 +21,253 @@ jest.mock('googleapis'); jest.mock('../../utils/logger'); describe('SlidesService', () => { - let slidesService: SlidesService; - let mockAuthManager: jest.Mocked; - let mockSlidesAPI: any; - let mockDriveAPI: any; - - beforeEach(() => { - // Clear all mocks before each test - jest.clearAllMocks(); - - // Create mock AuthManager - mockAuthManager = { - getAuthenticatedClient: jest.fn(), - } as any; - - - // Create mock Slides API - mockSlidesAPI = { - presentations: { - get: jest.fn(), - }, - }; - - mockDriveAPI = { - files: { - list: jest.fn(), + let slidesService: SlidesService; + let mockAuthManager: jest.Mocked; + let mockSlidesAPI: any; + let mockDriveAPI: any; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Create mock AuthManager + mockAuthManager = { + getAuthenticatedClient: jest.fn(), + } as any; + + // Create mock Slides API + mockSlidesAPI = { + presentations: { + get: jest.fn(), + }, + }; + + mockDriveAPI = { + files: { + list: jest.fn(), + }, + }; + + // Mock the google constructors + (google.slides as jest.Mock) = jest.fn().mockReturnValue(mockSlidesAPI); + (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); + + // Create SlidesService instance + slidesService = new SlidesService(mockAuthManager); + + const mockAuthClient = { access_token: 'test-token' }; + mockAuthManager.getAuthenticatedClient.mockResolvedValue( + mockAuthClient as any, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getText', () => { + it('should extract text from a presentation', async () => { + const mockPresentation = { + data: { + title: 'Test Presentation', + slides: [ + { + pageElements: [ + { + shape: { + text: { + textElements: [ + { textRun: { content: 'Slide 1 Title' } }, + { paragraphMarker: {} }, + { textRun: { content: 'Slide 1 Content' } }, + ], + }, + }, + }, + ], }, - }; - - // Mock the google constructors - (google.slides as jest.Mock) = jest.fn().mockReturnValue(mockSlidesAPI); - (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI); - - // Create SlidesService instance - slidesService = new SlidesService(mockAuthManager); - - const mockAuthClient = { access_token: 'test-token' }; - mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - describe('getText', () => { - it('should extract text from a presentation', async () => { - const mockPresentation = { - data: { - title: 'Test Presentation', - slides: [ - { - pageElements: [ - { - shape: { - text: { - textElements: [ - { textRun: { content: 'Slide 1 Title' } }, - { paragraphMarker: {} }, - { textRun: { content: 'Slide 1 Content' } }, - ], - }, - }, - }, - ], - }, - { - pageElements: [ - { - table: { - tableRows: [ - { - tableCells: [ - { - text: { - textElements: [ - { textRun: { content: 'Cell 1' } }, - ], - }, - }, - { - text: { - textElements: [ - { textRun: { content: 'Cell 2' } }, - ], - }, - }, - ], - }, - ], - }, - }, - ], - }, + { + pageElements: [ + { + table: { + tableRows: [ + { + tableCells: [ + { + text: { + textElements: [ + { textRun: { content: 'Cell 1' } }, + ], + }, + }, + { + text: { + textElements: [ + { textRun: { content: 'Cell 2' } }, + ], + }, + }, + ], + }, ], + }, }, - }; - - mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); - - const result = await slidesService.getText({ presentationId: 'test-presentation-id' }); - - expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({ - presentationId: 'test-presentation-id', - fields: 'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))', - }); - - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Test Presentation'); - expect(result.content[0].text).toContain('Slide 1 Title'); - expect(result.content[0].text).toContain('Slide 1 Content'); - expect(result.content[0].text).toContain('Cell 1 | Cell 2'); - }); - - it('should handle presentations with no slides', async () => { - const mockPresentation = { - data: { - title: 'Empty Presentation', - slides: [], - }, - }; - - mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); - - const result = await slidesService.getText({ presentationId: 'empty-presentation-id' }); - - expect(result.content[0].type).toBe('text'); - expect(result.content[0].text).toContain('Empty Presentation'); - }); - - it('should handle errors gracefully', async () => { - mockSlidesAPI.presentations.get.mockRejectedValue(new Error('API Error')); - - const result = await slidesService.getText({ presentationId: 'error-presentation-id' }); - - expect(result.content[0].type).toBe('text'); - const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('API Error'); - }); + ], + }, + ], + }, + }; + + mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); + + const result = await slidesService.getText({ + presentationId: 'test-presentation-id', + }); + + expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({ + presentationId: 'test-presentation-id', + fields: + 'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))', + }); + + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Test Presentation'); + expect(result.content[0].text).toContain('Slide 1 Title'); + expect(result.content[0].text).toContain('Slide 1 Content'); + expect(result.content[0].text).toContain('Cell 1 | Cell 2'); }); - describe('find', () => { - it('should find presentations by query', async () => { - const mockResponse = { - data: { - files: [ - { id: 'pres1', name: 'Presentation 1' }, - { id: 'pres2', name: 'Presentation 2' }, - ], - nextPageToken: 'next-token', - }, - }; + it('should handle presentations with no slides', async () => { + const mockPresentation = { + data: { + title: 'Empty Presentation', + slides: [], + }, + }; - mockDriveAPI.files.list.mockResolvedValue(mockResponse); + mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); - const result = await slidesService.find({ query: 'test query' }); - const response = JSON.parse(result.content[0].text); + const result = await slidesService.getText({ + presentationId: 'empty-presentation-id', + }); - expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ - pageSize: 10, - fields: 'nextPageToken, files(id, name)', - q: "mimeType='application/vnd.google-apps.presentation' and fullText contains 'test query'", - pageToken: undefined, - }); - - expect(response.files).toHaveLength(2); - expect(response.files[0].name).toBe('Presentation 1'); - expect(response.nextPageToken).toBe('next-token'); - }); - - it('should handle title-specific searches', async () => { - const mockResponse = { - data: { - files: [{ id: 'pres1', name: 'Specific Title' }], - }, - }; - - mockDriveAPI.files.list.mockResolvedValue(mockResponse); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Empty Presentation'); + }); - const result = await slidesService.find({ query: 'title:"Specific Title"' }); - const response = JSON.parse(result.content[0].text); + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.get.mockRejectedValue(new Error('API Error')); - expect(mockDriveAPI.files.list).toHaveBeenCalledWith( - expect.objectContaining({ - q: "mimeType='application/vnd.google-apps.presentation' and name contains 'Specific Title'", - }) - ); + const result = await slidesService.getText({ + presentationId: 'error-presentation-id', + }); - expect(response.files).toHaveLength(1); - expect(response.files[0].name).toBe('Specific Title'); - }); + expect(result.content[0].type).toBe('text'); + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('API Error'); + }); + }); + + describe('find', () => { + it('should find presentations by query', async () => { + const mockResponse = { + data: { + files: [ + { id: 'pres1', name: 'Presentation 1' }, + { id: 'pres2', name: 'Presentation 2' }, + ], + nextPageToken: 'next-token', + }, + }; + + mockDriveAPI.files.list.mockResolvedValue(mockResponse); + + const result = await slidesService.find({ query: 'test query' }); + const response = JSON.parse(result.content[0].text); + + expect(mockDriveAPI.files.list).toHaveBeenCalledWith({ + pageSize: 10, + fields: 'nextPageToken, files(id, name)', + q: "mimeType='application/vnd.google-apps.presentation' and fullText contains 'test query'", + pageToken: undefined, + }); + + expect(response.files).toHaveLength(2); + expect(response.files[0].name).toBe('Presentation 1'); + expect(response.nextPageToken).toBe('next-token'); }); - describe('getMetadata', () => { - it('should retrieve presentation metadata', async () => { - const mockPresentation = { - data: { - presentationId: 'test-id', - title: 'Test Presentation', - slides: [{ objectId: 'slide1' }, { objectId: 'slide2' }], - pageSize: { width: { magnitude: 10 }, height: { magnitude: 7.5 } }, - masters: [{ objectId: 'master1' }], - layouts: [{ objectId: 'layout1' }], - notesMaster: { objectId: 'notesMaster1' }, - }, - }; + it('should handle title-specific searches', async () => { + const mockResponse = { + data: { + files: [{ id: 'pres1', name: 'Specific Title' }], + }, + }; - mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); + mockDriveAPI.files.list.mockResolvedValue(mockResponse); - const result = await slidesService.getMetadata({ presentationId: 'test-id' }); - const metadata = JSON.parse(result.content[0].text); + const result = await slidesService.find({ + query: 'title:"Specific Title"', + }); + const response = JSON.parse(result.content[0].text); - expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({ - presentationId: 'test-id', - fields: 'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts', - }); + expect(mockDriveAPI.files.list).toHaveBeenCalledWith( + expect.objectContaining({ + q: "mimeType='application/vnd.google-apps.presentation' and name contains 'Specific Title'", + }), + ); - expect(metadata.presentationId).toBe('test-id'); - expect(metadata.title).toBe('Test Presentation'); - expect(metadata.slideCount).toBe(2); - expect(metadata.hasMasters).toBe(true); - expect(metadata.hasLayouts).toBe(true); - expect(metadata.hasNotesMaster).toBe(true); - }); + expect(response.files).toHaveLength(1); + expect(response.files[0].name).toBe('Specific Title'); + }); + }); + + describe('getMetadata', () => { + it('should retrieve presentation metadata', async () => { + const mockPresentation = { + data: { + presentationId: 'test-id', + title: 'Test Presentation', + slides: [{ objectId: 'slide1' }, { objectId: 'slide2' }], + pageSize: { width: { magnitude: 10 }, height: { magnitude: 7.5 } }, + masters: [{ objectId: 'master1' }], + layouts: [{ objectId: 'layout1' }], + notesMaster: { objectId: 'notesMaster1' }, + }, + }; + + mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation); + + const result = await slidesService.getMetadata({ + presentationId: 'test-id', + }); + const metadata = JSON.parse(result.content[0].text); + + expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({ + presentationId: 'test-id', + fields: + 'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts', + }); + + expect(metadata.presentationId).toBe('test-id'); + expect(metadata.title).toBe('Test Presentation'); + expect(metadata.slideCount).toBe(2); + expect(metadata.hasMasters).toBe(true); + expect(metadata.hasLayouts).toBe(true); + expect(metadata.hasNotesMaster).toBe(true); + }); - it('should handle errors gracefully', async () => { - mockSlidesAPI.presentations.get.mockRejectedValue(new Error('Metadata Error')); + it('should handle errors gracefully', async () => { + mockSlidesAPI.presentations.get.mockRejectedValue( + new Error('Metadata Error'), + ); - const result = await slidesService.getMetadata({ presentationId: 'error-id' }); - const response = JSON.parse(result.content[0].text); + const result = await slidesService.getMetadata({ + presentationId: 'error-id', + }); + const response = JSON.parse(result.content[0].text); - expect(response.error).toBe('Metadata Error'); - }); + expect(response.error).toBe('Metadata Error'); }); + }); }); diff --git a/workspace-server/src/__tests__/services/TimeService.test.ts b/workspace-server/src/__tests__/services/TimeService.test.ts index 4e77e3d..383a93f 100644 --- a/workspace-server/src/__tests__/services/TimeService.test.ts +++ b/workspace-server/src/__tests__/services/TimeService.test.ts @@ -25,7 +25,7 @@ describe('TimeService', () => { const result = await timeService.getCurrentDate(); const parsed = JSON.parse(result.content[0].text); const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - + expect(parsed.utc).toEqual('2025-08-19'); expect(parsed.local).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(parsed.timeZone).toEqual(expectedTimeZone); @@ -37,7 +37,7 @@ describe('TimeService', () => { const result = await timeService.getCurrentTime(); const parsed = JSON.parse(result.content[0].text); const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - + expect(parsed.utc).toEqual('12:34:56'); expect(parsed.local).toMatch(/^\d{2}:\d{2}:\d{2}$/); expect(parsed.timeZone).toEqual(expectedTimeZone); @@ -48,7 +48,9 @@ describe('TimeService', () => { it('should return the local timezone', async () => { const result = await timeService.getTimeZone(); const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - expect(result.content[0].text).toEqual(JSON.stringify({ timeZone: expectedTimeZone })); + expect(result.content[0].text).toEqual( + JSON.stringify({ timeZone: expectedTimeZone }), + ); }); }); }); diff --git a/workspace-server/src/__tests__/setup.ts b/workspace-server/src/__tests__/setup.ts index 7274d64..c316bc3 100644 --- a/workspace-server/src/__tests__/setup.ts +++ b/workspace-server/src/__tests__/setup.ts @@ -30,4 +30,4 @@ jest.setTimeout(10000); afterAll(() => { jest.clearAllMocks(); jest.restoreAllMocks(); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts b/workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts index ff4a78d..e90cc0c 100644 --- a/workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts +++ b/workspace-server/src/__tests__/utils/DriveQueryBuilder.test.ts @@ -5,73 +5,120 @@ */ import { describe, it, expect } from '@jest/globals'; -import { buildDriveSearchQuery, MIME_TYPES } from '../../utils/DriveQueryBuilder'; +import { + buildDriveSearchQuery, + MIME_TYPES, +} from '../../utils/DriveQueryBuilder'; describe('DriveQueryBuilder', () => { - describe('buildDriveSearchQuery', () => { - it('should build fullText query for regular search', () => { - const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test query'); - expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains 'test query'"); - }); + describe('buildDriveSearchQuery', () => { + it('should build fullText query for regular search', () => { + const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test query'); + expect(query).toBe( + "mimeType='application/vnd.google-apps.document' and fullText contains 'test query'", + ); + }); - it('should build name query for title-prefixed search', () => { - const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, 'title:My Presentation'); - expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'My Presentation'"); - }); + it('should build name query for title-prefixed search', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.PRESENTATION, + 'title:My Presentation', + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.presentation' and name contains 'My Presentation'", + ); + }); - it('should handle quoted title searches', () => { - const query = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, 'title:"Budget 2024"'); - expect(query).toBe("mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Budget 2024'"); - }); + it('should handle quoted title searches', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.SPREADSHEET, + 'title:"Budget 2024"', + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Budget 2024'", + ); + }); - it('should handle single-quoted title searches', () => { - const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, "title:'Q4 Report'"); - expect(query).toBe("mimeType='application/vnd.google-apps.document' and name contains 'Q4 Report'"); - }); + it('should handle single-quoted title searches', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.DOCUMENT, + "title:'Q4 Report'", + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.document' and name contains 'Q4 Report'", + ); + }); - it('should escape special characters in query', () => { - const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, "test's query\\path"); - expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains 'test\\'s query\\\\path'"); - }); + it('should escape special characters in query', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.DOCUMENT, + "test's query\\path", + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.document' and fullText contains 'test\\'s query\\\\path'", + ); + }); - it('should escape special characters in title search', () => { - const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, "title:John's Presentation\\2024"); - expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'John\\'s Presentation\\\\2024'"); - }); + it('should escape special characters in title search', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.PRESENTATION, + "title:John's Presentation\\2024", + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.presentation' and name contains 'John\\'s Presentation\\\\2024'", + ); + }); - it('should handle empty strings', () => { - const query = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, ''); - expect(query).toBe("mimeType='application/vnd.google-apps.spreadsheet' and fullText contains ''"); - }); + it('should handle empty strings', () => { + const query = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, ''); + expect(query).toBe( + "mimeType='application/vnd.google-apps.spreadsheet' and fullText contains ''", + ); + }); - it('should handle whitespace-only queries', () => { - const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, ' '); - expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains ' '"); - }); + it('should handle whitespace-only queries', () => { + const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, ' '); + expect(query).toBe( + "mimeType='application/vnd.google-apps.document' and fullText contains ' '", + ); + }); - it('should handle title prefix with whitespace', () => { - const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, ' title: "My Doc" '); - expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'My Doc'"); - }); + it('should handle title prefix with whitespace', () => { + const query = buildDriveSearchQuery( + MIME_TYPES.PRESENTATION, + ' title: "My Doc" ', + ); + expect(query).toBe( + "mimeType='application/vnd.google-apps.presentation' and name contains 'My Doc'", + ); + }); - it('should work with all MIME types', () => { - expect(buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test')) - .toContain('application/vnd.google-apps.document'); - expect(buildDriveSearchQuery(MIME_TYPES.PRESENTATION, 'test')) - .toContain('application/vnd.google-apps.presentation'); - expect(buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, 'test')) - .toContain('application/vnd.google-apps.spreadsheet'); - expect(buildDriveSearchQuery(MIME_TYPES.FOLDER, 'test')) - .toContain('application/vnd.google-apps.folder'); - }); + it('should work with all MIME types', () => { + expect(buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test')).toContain( + 'application/vnd.google-apps.document', + ); + expect(buildDriveSearchQuery(MIME_TYPES.PRESENTATION, 'test')).toContain( + 'application/vnd.google-apps.presentation', + ); + expect(buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, 'test')).toContain( + 'application/vnd.google-apps.spreadsheet', + ); + expect(buildDriveSearchQuery(MIME_TYPES.FOLDER, 'test')).toContain( + 'application/vnd.google-apps.folder', + ); }); + }); - describe('MIME_TYPES constants', () => { - it('should have correct MIME type values', () => { - expect(MIME_TYPES.DOCUMENT).toBe('application/vnd.google-apps.document'); - expect(MIME_TYPES.PRESENTATION).toBe('application/vnd.google-apps.presentation'); - expect(MIME_TYPES.SPREADSHEET).toBe('application/vnd.google-apps.spreadsheet'); - expect(MIME_TYPES.FOLDER).toBe('application/vnd.google-apps.folder'); - }); + describe('MIME_TYPES constants', () => { + it('should have correct MIME type values', () => { + expect(MIME_TYPES.DOCUMENT).toBe('application/vnd.google-apps.document'); + expect(MIME_TYPES.PRESENTATION).toBe( + 'application/vnd.google-apps.presentation', + ); + expect(MIME_TYPES.SPREADSHEET).toBe( + 'application/vnd.google-apps.spreadsheet', + ); + expect(MIME_TYPES.FOLDER).toBe('application/vnd.google-apps.folder'); }); + }); }); diff --git a/workspace-server/src/__tests__/utils/IdUtils.test.ts b/workspace-server/src/__tests__/utils/IdUtils.test.ts index 9e52d48..6929346 100644 --- a/workspace-server/src/__tests__/utils/IdUtils.test.ts +++ b/workspace-server/src/__tests__/utils/IdUtils.test.ts @@ -10,13 +10,15 @@ import { extractDocId } from '../../utils/IdUtils'; describe('IdUtils', () => { describe('extractDocId', () => { it('should extract document ID from a full Google Docs URL', () => { - const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f7g8h9i0j/edit'; + const url = + 'https://docs.google.com/document/d/1a2b3c4d5e6f7g8h9i0j/edit'; const result = extractDocId(url); expect(result).toBe('1a2b3c4d5e6f7g8h9i0j'); }); it('should extract document ID from URL with additional parameters', () => { - const url = 'https://docs.google.com/document/d/abc123-XYZ_789/edit?usp=sharing'; + const url = + 'https://docs.google.com/document/d/abc123-XYZ_789/edit?usp=sharing'; const result = extractDocId(url); expect(result).toBe('abc123-XYZ_789'); }); @@ -102,13 +104,15 @@ describe('IdUtils', () => { }); it('should extract document ID from a complex URL with resourcekey', () => { - const url = 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI/edit?resourcekey=0-X_p2TPxpk0visLTHHMF7Yg&tab=t.0'; + const url = + 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI/edit?resourcekey=0-X_p2TPxpk0visLTHHMF7Yg&tab=t.0'; const result = extractDocId(url); expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI'); }); it('should extract document ID from a URL without a trailing slash', () => { - const url = 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI'; + const url = + 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI'; const result = extractDocId(url); expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI'); }); diff --git a/workspace-server/src/__tests__/utils/MimeHelper.test.ts b/workspace-server/src/__tests__/utils/MimeHelper.test.ts index c5106b8..b869bf1 100644 --- a/workspace-server/src/__tests__/utils/MimeHelper.test.ts +++ b/workspace-server/src/__tests__/utils/MimeHelper.test.ts @@ -18,7 +18,7 @@ describe('MimeHelper', () => { // Decode the message to verify its structure const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('To: recipient@example.com'); expect(decoded).toContain('Subject: =?utf-8?B?VGVzdCBTdWJqZWN0?='); expect(decoded).toContain('Content-Type: text/plain; charset=utf-8'); @@ -34,7 +34,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('Content-Type: text/html; charset=utf-8'); expect(decoded).toContain('

Hello World

'); }); @@ -51,7 +51,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('From: sender@example.com'); expect(decoded).toContain('To: recipient@example.com'); expect(decoded).toContain('Cc: cc@example.com'); @@ -67,14 +67,16 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + // The subject should be base64 encoded expect(decoded).toContain('Subject: =?utf-8?B?'); - + // Decode the subject to verify it's correct const subjectMatch = decoded.match(/Subject: =\?utf-8\?B\?([^?]+)\?=/); if (subjectMatch) { - const decodedSubject = Buffer.from(subjectMatch[1], 'base64').toString('utf-8'); + const decodedSubject = Buffer.from(subjectMatch[1], 'base64').toString( + 'utf-8', + ); expect(decodedSubject).toBe('Test with emoji 🎉 and special chars é ñ'); } }); @@ -87,7 +89,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + // Should use CRLF (\r\n) as line separators expect(decoded).toContain('\r\n'); expect(decoded.split('\r\n').length).toBeGreaterThan(3); @@ -101,8 +103,10 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - - expect(decoded).toContain('To: recipient1@example.com, recipient2@example.com'); + + expect(decoded).toContain( + 'To: recipient1@example.com, recipient2@example.com', + ); }); it('should encode to base64url format (no padding, URL-safe characters)', () => { @@ -116,7 +120,7 @@ describe('MimeHelper', () => { expect(encoded).not.toContain('+'); expect(encoded).not.toContain('/'); expect(encoded).not.toContain('='); - + // Should only contain base64url characters expect(encoded).toMatch(/^[A-Za-z0-9\-_]+$/); }); @@ -131,7 +135,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + // Should not contain multipart boundary expect(decoded).not.toContain('Content-Type: multipart/mixed'); expect(decoded).toContain('Content-Type: text/plain; charset=utf-8'); @@ -154,9 +158,11 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('Content-Type: multipart/mixed; boundary='); - expect(decoded).toContain('Content-Disposition: attachment; filename="test.txt"'); + expect(decoded).toContain( + 'Content-Disposition: attachment; filename="test.txt"', + ); expect(decoded).toContain('Content-Type: text/plain'); expect(decoded).toContain('Content-Transfer-Encoding: base64'); }); @@ -183,7 +189,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('filename="file1.txt"'); expect(decoded).toContain('filename="file2.pdf"'); expect(decoded).toContain('Content-Type: text/plain'); @@ -206,7 +212,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('Content-Type: application/octet-stream'); }); @@ -227,13 +233,13 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + // Find the base64 encoded attachment content const lines = decoded.split('\r\n'); - const attachmentStart = lines.findIndex(line => - line.includes('Content-Transfer-Encoding: base64') + const attachmentStart = lines.findIndex((line) => + line.includes('Content-Transfer-Encoding: base64'), ); - + if (attachmentStart !== -1) { // Check lines after the attachment header for (let i = attachmentStart + 2; i < lines.length; i++) { @@ -264,9 +270,11 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + // Body should be HTML - expect(decoded).toMatch(/Content-Type: text\/html; charset=utf-8\r\n\r\n

HTML Message Body<\/p>/); + expect(decoded).toMatch( + /Content-Type: text\/html; charset=utf-8\r\n\r\n

HTML Message Body<\/p>/, + ); // Attachment should also be present expect(decoded).toContain('filename="doc.html"'); }); @@ -290,7 +298,7 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(encoded); - + expect(decoded).toContain('From: sender@example.com'); expect(decoded).toContain('Cc: cc@example.com'); expect(decoded).toContain('Bcc: bcc@example.com'); @@ -321,10 +329,10 @@ describe('MimeHelper', () => { const decoded1 = MimeHelper.decodeBase64Url(encoded1); const decoded2 = MimeHelper.decodeBase64Url(encoded2); - + const boundary1Match = decoded1.match(/boundary="([^"]+)"/); const boundary2Match = decoded2.match(/boundary="([^"]+)"/); - + expect(boundary1Match).toBeTruthy(); expect(boundary2Match).toBeTruthy(); expect(boundary1Match![1]).not.toBe(boundary2Match![1]); @@ -339,30 +347,30 @@ describe('MimeHelper', () => { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); - + const decoded = MimeHelper.decodeBase64Url(base64url); - + expect(decoded).toBe(original); }); it('should handle strings without padding', () => { const base64url = 'SGVsbG8'; // "Hello" without padding const decoded = MimeHelper.decodeBase64Url(base64url); - + expect(decoded).toBe('Hello'); }); it('should convert URL-safe characters back to standard base64', () => { const base64url = 'SGVsbG8-V29ybGRfIQ'; // Contains - and _ const decoded = MimeHelper.decodeBase64Url(base64url); - + expect(decoded).toBeTruthy(); expect(typeof decoded).toBe('string'); }); it('should handle empty strings', () => { const decoded = MimeHelper.decodeBase64Url(''); - + expect(decoded).toBe(''); }); @@ -374,9 +382,9 @@ describe('MimeHelper', () => { }); const decoded = MimeHelper.decodeBase64Url(mimeMessage); - + expect(decoded).toContain('To: test@example.com'); expect(decoded).toContain('Test body'); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/utils/logger.test.ts b/workspace-server/src/__tests__/utils/logger.test.ts index 6d00ac1..df7f1f6 100644 --- a/workspace-server/src/__tests__/utils/logger.test.ts +++ b/workspace-server/src/__tests__/utils/logger.test.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; import * as path from 'node:path'; // Mock fs/promises module BEFORE any imports that use it @@ -34,10 +41,10 @@ describe('logger', () => { beforeEach(() => { // Clear all mocks jest.clearAllMocks(); - + // Clear module cache to ensure fresh imports jest.resetModules(); - + // Spy on console.error consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -53,40 +60,39 @@ describe('logger', () => { mkdir: jest.fn(() => Promise.resolve()), appendFile: jest.fn(() => Promise.resolve()), })); - + // Import the module (this triggers initialization) await import('../../utils/logger'); - + // Get the mocked fs module fs = await import('node:fs/promises'); - + // Wait for async initialization - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(fs.mkdir).toHaveBeenCalledWith( - expect.stringContaining('logs'), - { recursive: true } - ); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('logs'), { + recursive: true, + }); }); it('should handle directory creation errors gracefully', async () => { const mkdirError = new Error('Permission denied'); - + // Set up mocks jest.doMock('fs/promises', () => ({ mkdir: jest.fn(() => Promise.reject(mkdirError)), appendFile: jest.fn(() => Promise.resolve()), })); - + // Import the module await import('../../utils/logger'); - + // Wait for async initialization - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(consoleErrorSpy).toHaveBeenCalledWith( 'Could not create log directory:', - mkdirError + mkdirError, ); }); }); @@ -100,15 +106,15 @@ describe('logger', () => { const testMessage = 'Test log message'; const mockDate = new Date('2024-01-01T12:00:00.000Z'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any); - + logToFile(testMessage); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fs.appendFile).toHaveBeenCalledWith( expect.stringContaining('server.log'), - '2024-01-01T12:00:00.000Z - Test log message\n' + '2024-01-01T12:00:00.000Z - Test log message\n', ); }); @@ -116,56 +122,56 @@ describe('logger', () => { logToFile('First message'); logToFile('Second message'); logToFile('Third message'); - + // Wait for async operations - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fs.appendFile).toHaveBeenCalledTimes(3); expect(fs.appendFile).toHaveBeenNthCalledWith( 1, expect.stringContaining('server.log'), - expect.stringContaining('First message') + expect.stringContaining('First message'), ); expect(fs.appendFile).toHaveBeenNthCalledWith( 2, expect.stringContaining('server.log'), - expect.stringContaining('Second message') + expect.stringContaining('Second message'), ); expect(fs.appendFile).toHaveBeenNthCalledWith( 3, expect.stringContaining('server.log'), - expect.stringContaining('Third message') + expect.stringContaining('Third message'), ); }); it('should log to console.error when file write fails', async () => { const writeError = new Error('Disk full'); await setupLogger(jest.fn(() => Promise.reject(writeError))); - + logToFile('Failed write test'); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(consoleErrorSpy).toHaveBeenCalledWith( 'Failed to write to log file:', - writeError + writeError, ); }); it('should format log message correctly', async () => { const mockDate = new Date('2024-12-25T18:30:45.123Z'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any); - + logToFile('Holiday log entry'); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + const expectedMessage = '2024-12-25T18:30:45.123Z - Holiday log entry\n'; expect(fs.appendFile).toHaveBeenCalledWith( expect.any(String), - expectedMessage + expectedMessage, ); }); @@ -173,62 +179,64 @@ describe('logger', () => { const mockDate = new Date('2024-01-01T12:00:00.000Z'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any); logToFile(''); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fs.appendFile).toHaveBeenCalledWith( expect.stringContaining('server.log'), - '2024-01-01T12:00:00.000Z - \n' + '2024-01-01T12:00:00.000Z - \n', ); }); it('should handle special characters in messages', async () => { const specialMessage = 'Message with \n newline, \t tab, and "quotes"'; - + logToFile(specialMessage); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fs.appendFile).toHaveBeenCalledWith( expect.stringContaining('server.log'), - expect.stringContaining(specialMessage) + expect.stringContaining(specialMessage), ); }); it('should use correct log file path', async () => { logToFile('Path test'); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + const callArgs = (fs.appendFile as jest.Mock).mock.calls[0]; const logPath = callArgs[0] as string; - + expect(logPath).toContain('logs'); expect(logPath).toContain('server.log'); expect(path.isAbsolute(logPath)).toBe(true); }); it('should not throw when appendFile fails', async () => { - await setupLogger(jest.fn(() => Promise.reject(new Error('Write failed')))); - + await setupLogger( + jest.fn(() => Promise.reject(new Error('Write failed'))), + ); + // Should not throw expect(() => logToFile('Test message')).not.toThrow(); - + // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(consoleErrorSpy).toHaveBeenCalled(); }); it('should not log when logging is disabled', () => { setLoggingEnabled(false); const testMessage = 'Test log message'; - + logToFile(testMessage); - + expect(fs.appendFile).not.toHaveBeenCalled(); }); }); diff --git a/workspace-server/src/__tests__/utils/markdownToDocsRequests.test.ts b/workspace-server/src/__tests__/utils/markdownToDocsRequests.test.ts index 4301be4..5ba2369 100644 --- a/workspace-server/src/__tests__/utils/markdownToDocsRequests.test.ts +++ b/workspace-server/src/__tests__/utils/markdownToDocsRequests.test.ts @@ -5,243 +5,277 @@ */ import { describe, it, expect } from '@jest/globals'; -import { parseMarkdownToDocsRequests, processMarkdownLineBreaks } from '../../utils/markdownToDocsRequests'; +import { + parseMarkdownToDocsRequests, + processMarkdownLineBreaks, +} from '../../utils/markdownToDocsRequests'; describe('markdownToDocsRequests', () => { - describe('parseMarkdownToDocsRequests', () => { - // Skip tests that rely on marked working if it's not functioning in test environment - // We'll check markedWorks inside each test instead of using a variable - - it('should handle bold text', () => { - const markdown = 'This is **bold** text'; - const startIndex = 10; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('This is bold text'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0]).toEqual({ - updateTextStyle: { - range: { - startIndex: 18, // 10 + 8 (position of "bold") - endIndex: 22, // 10 + 12 (end of "bold") - }, - textStyle: { - bold: true, - }, - fields: 'bold' - } - }); - }); - - it('should handle italic text with asterisks', () => { - const markdown = 'This is *italic* text'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('This is italic text'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0]).toEqual({ - updateTextStyle: { - range: { - startIndex: 8, - endIndex: 14, - }, - textStyle: { - italic: true, - }, - fields: 'italic' - } - }); - }); - - it('should handle italic text with underscores', () => { - const markdown = 'This is _italic_ text'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('This is italic text'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0].updateTextStyle?.textStyle?.italic).toBe(true); - }); - - it('should handle inline code', () => { - const markdown = 'This is `code` text'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('This is code text'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0]).toEqual({ - updateTextStyle: { - range: { - startIndex: 8, - endIndex: 12, - }, - textStyle: { - weightedFontFamily: { - fontFamily: 'Courier New', - weight: 400 - }, - backgroundColor: { - color: { - rgbColor: { - red: 0.95, - green: 0.95, - blue: 0.95 - } - } - } - }, - fields: 'weightedFontFamily,backgroundColor' - } - }); - }); - - it('should handle multiple formatting in one text', () => { - const markdown = 'Text with **bold**, *italic*, and `code` formatting'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Text with bold, italic, and code formatting'); - expect(result.formattingRequests).toHaveLength(3); - }); - - it('should handle text with no formatting', () => { - const markdown = 'Plain text without any formatting'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Plain text without any formatting'); - expect(result.formattingRequests).toHaveLength(0); - }); - - it('should handle overlapping formatting (keeps first)', () => { - const markdown = '**bold and text**'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - // The bold formatting should be applied - expect(result.plainText).toBe('bold and text'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0].updateTextStyle?.textStyle?.bold).toBe(true); - }); - - it('should respect the startIndex parameter', () => { - const markdown = '**bold**'; - const startIndex = 100; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('bold'); - expect(result.formattingRequests[0]).toEqual({ - updateTextStyle: { - range: { - startIndex: 100, - endIndex: 104, - }, - textStyle: { - bold: true, - }, - fields: 'bold' - } - }); - }); - - it('should handle heading 1', () => { - const markdown = '# Main Title'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Main Title'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_1'); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(10); - }); - - it('should handle heading 2', () => { - const markdown = '## Section Title'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Section Title'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_2'); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(13); - }); - - it('should handle heading 3', () => { - const markdown = '### Subsection'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Subsection'); - expect(result.formattingRequests).toHaveLength(1); - expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_3'); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0); - expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(10); - }); - - it('should handle mixed headings and text', () => { - const markdown = '# Title\n\nSome text\n\n## Section\n\nMore text'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toContain('Title'); - expect(result.plainText).toContain('Some text'); - expect(result.plainText).toContain('Section'); - expect(result.plainText).toContain('More text'); - - // Should have formatting for both headings - const headingFormats = result.formattingRequests.filter(req => - req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== undefined - ); - expect(headingFormats).toHaveLength(2); - }); - - it('should handle inline formatting within headings', () => { - const markdown = '# Main **bold** Title'; - const startIndex = 0; - const result = parseMarkdownToDocsRequests(markdown, startIndex); - - expect(result.plainText).toBe('Main bold Title'); - - // Should have both heading and bold formatting - const headingFormat = result.formattingRequests.find(req => - req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== undefined - ); - const boldFormat = result.formattingRequests.find(req => - req.updateTextStyle?.textStyle?.bold === true - ); - - expect(headingFormat).toBeDefined(); - expect(boldFormat).toBeDefined(); - }); - }); - - describe('processMarkdownLineBreaks', () => { - it('should preserve single line breaks', () => { - const text = 'Line 1\nLine 2'; - const result = processMarkdownLineBreaks(text); - expect(result).toBe('Line 1\nLine 2'); - }); - - it('should convert double line breaks to double', () => { - const text = 'Paragraph 1\n\nParagraph 2'; - const result = processMarkdownLineBreaks(text); - expect(result).toBe('Paragraph 1\n\nParagraph 2'); - }); - - it('should convert multiple line breaks to double', () => { - const text = 'Paragraph 1\n\n\n\nParagraph 2'; - const result = processMarkdownLineBreaks(text); - expect(result).toBe('Paragraph 1\n\nParagraph 2'); - }); - - it('should handle text without line breaks', () => { - const text = 'Single line of text'; - const result = processMarkdownLineBreaks(text); - expect(result).toBe('Single line of text'); - }); - }); -}); \ No newline at end of file + describe('parseMarkdownToDocsRequests', () => { + // Skip tests that rely on marked working if it's not functioning in test environment + // We'll check markedWorks inside each test instead of using a variable + + it('should handle bold text', () => { + const markdown = 'This is **bold** text'; + const startIndex = 10; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('This is bold text'); + expect(result.formattingRequests).toHaveLength(1); + expect(result.formattingRequests[0]).toEqual({ + updateTextStyle: { + range: { + startIndex: 18, // 10 + 8 (position of "bold") + endIndex: 22, // 10 + 12 (end of "bold") + }, + textStyle: { + bold: true, + }, + fields: 'bold', + }, + }); + }); + + it('should handle italic text with asterisks', () => { + const markdown = 'This is *italic* text'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('This is italic text'); + expect(result.formattingRequests).toHaveLength(1); + expect(result.formattingRequests[0]).toEqual({ + updateTextStyle: { + range: { + startIndex: 8, + endIndex: 14, + }, + textStyle: { + italic: true, + }, + fields: 'italic', + }, + }); + }); + + it('should handle italic text with underscores', () => { + const markdown = 'This is _italic_ text'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('This is italic text'); + expect(result.formattingRequests).toHaveLength(1); + expect( + result.formattingRequests[0].updateTextStyle?.textStyle?.italic, + ).toBe(true); + }); + + it('should handle inline code', () => { + const markdown = 'This is `code` text'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('This is code text'); + expect(result.formattingRequests).toHaveLength(1); + expect(result.formattingRequests[0]).toEqual({ + updateTextStyle: { + range: { + startIndex: 8, + endIndex: 12, + }, + textStyle: { + weightedFontFamily: { + fontFamily: 'Courier New', + weight: 400, + }, + backgroundColor: { + color: { + rgbColor: { + red: 0.95, + green: 0.95, + blue: 0.95, + }, + }, + }, + }, + fields: 'weightedFontFamily,backgroundColor', + }, + }); + }); + + it('should handle multiple formatting in one text', () => { + const markdown = 'Text with **bold**, *italic*, and `code` formatting'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe( + 'Text with bold, italic, and code formatting', + ); + expect(result.formattingRequests).toHaveLength(3); + }); + + it('should handle text with no formatting', () => { + const markdown = 'Plain text without any formatting'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('Plain text without any formatting'); + expect(result.formattingRequests).toHaveLength(0); + }); + + it('should handle overlapping formatting (keeps first)', () => { + const markdown = '**bold and text**'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + // The bold formatting should be applied + expect(result.plainText).toBe('bold and text'); + expect(result.formattingRequests).toHaveLength(1); + expect( + result.formattingRequests[0].updateTextStyle?.textStyle?.bold, + ).toBe(true); + }); + + it('should respect the startIndex parameter', () => { + const markdown = '**bold**'; + const startIndex = 100; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('bold'); + expect(result.formattingRequests[0]).toEqual({ + updateTextStyle: { + range: { + startIndex: 100, + endIndex: 104, + }, + textStyle: { + bold: true, + }, + fields: 'bold', + }, + }); + }); + + it('should handle heading 1', () => { + const markdown = '# Main Title'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('Main Title'); + expect(result.formattingRequests).toHaveLength(1); + expect( + result.formattingRequests[0].updateParagraphStyle?.paragraphStyle + ?.namedStyleType, + ).toBe('HEADING_1'); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.startIndex, + ).toBe(0); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.endIndex, + ).toBe(10); + }); + + it('should handle heading 2', () => { + const markdown = '## Section Title'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('Section Title'); + expect(result.formattingRequests).toHaveLength(1); + expect( + result.formattingRequests[0].updateParagraphStyle?.paragraphStyle + ?.namedStyleType, + ).toBe('HEADING_2'); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.startIndex, + ).toBe(0); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.endIndex, + ).toBe(13); + }); + + it('should handle heading 3', () => { + const markdown = '### Subsection'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('Subsection'); + expect(result.formattingRequests).toHaveLength(1); + expect( + result.formattingRequests[0].updateParagraphStyle?.paragraphStyle + ?.namedStyleType, + ).toBe('HEADING_3'); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.startIndex, + ).toBe(0); + expect( + result.formattingRequests[0].updateParagraphStyle?.range?.endIndex, + ).toBe(10); + }); + + it('should handle mixed headings and text', () => { + const markdown = '# Title\n\nSome text\n\n## Section\n\nMore text'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toContain('Title'); + expect(result.plainText).toContain('Some text'); + expect(result.plainText).toContain('Section'); + expect(result.plainText).toContain('More text'); + + // Should have formatting for both headings + const headingFormats = result.formattingRequests.filter( + (req) => + req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== + undefined, + ); + expect(headingFormats).toHaveLength(2); + }); + + it('should handle inline formatting within headings', () => { + const markdown = '# Main **bold** Title'; + const startIndex = 0; + const result = parseMarkdownToDocsRequests(markdown, startIndex); + + expect(result.plainText).toBe('Main bold Title'); + + // Should have both heading and bold formatting + const headingFormat = result.formattingRequests.find( + (req) => + req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== + undefined, + ); + const boldFormat = result.formattingRequests.find( + (req) => req.updateTextStyle?.textStyle?.bold === true, + ); + + expect(headingFormat).toBeDefined(); + expect(boldFormat).toBeDefined(); + }); + }); + + describe('processMarkdownLineBreaks', () => { + it('should preserve single line breaks', () => { + const text = 'Line 1\nLine 2'; + const result = processMarkdownLineBreaks(text); + expect(result).toBe('Line 1\nLine 2'); + }); + + it('should convert double line breaks to double', () => { + const text = 'Paragraph 1\n\nParagraph 2'; + const result = processMarkdownLineBreaks(text); + expect(result).toBe('Paragraph 1\n\nParagraph 2'); + }); + + it('should convert multiple line breaks to double', () => { + const text = 'Paragraph 1\n\n\n\nParagraph 2'; + const result = processMarkdownLineBreaks(text); + expect(result).toBe('Paragraph 1\n\nParagraph 2'); + }); + + it('should handle text without line breaks', () => { + const text = 'Single line of text'; + const result = processMarkdownLineBreaks(text); + expect(result).toBe('Single line of text'); + }); + }); +}); diff --git a/workspace-server/src/__tests__/utils/paths.test.ts b/workspace-server/src/__tests__/utils/paths.test.ts index 1566aae..743998d 100644 --- a/workspace-server/src/__tests__/utils/paths.test.ts +++ b/workspace-server/src/__tests__/utils/paths.test.ts @@ -14,9 +14,12 @@ describe('paths utils', () => { // The project root should contain gemini-extension.json // Since we are searching for gemini-extension.json which is in the root 'workspace', // not 'workspace-server', the path should NOT end with 'workspace-server'. - const extensionConfigPath = path.join(PROJECT_ROOT, 'gemini-extension.json'); + const extensionConfigPath = path.join( + PROJECT_ROOT, + 'gemini-extension.json', + ); expect(fs.existsSync(extensionConfigPath)).toBe(true); - + // The root should be the parent of workspace-server in this monorepo setup // PROJECT_ROOT = .../workspace // __dirname = .../workspace/workspace-server/src/__tests__/utils diff --git a/workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts b/workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts index 141e621..af86d59 100644 --- a/workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts +++ b/workspace-server/src/__tests__/utils/secure-browser-launcher.test.ts @@ -28,7 +28,7 @@ describe('secure-browser-launcher', () => { beforeEach(() => { mockChild = new EventEmitter(); mockExecFile = jest.fn().mockReturnValue(mockChild as ChildProcess); - mockPlatform.mockReturnValue('darwin'); // Default to macOS + mockPlatform.mockReturnValue('darwin'); // Default to macOS }); afterEach(() => { @@ -51,7 +51,7 @@ describe('secure-browser-launcher', () => { it('should allow valid HTTP URLs', async () => { const openPromise = openBrowserSecurely( 'http://example.com', - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -59,14 +59,14 @@ describe('secure-browser-launcher', () => { 'open', ['http://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); it('should allow valid HTTPS URLs', async () => { const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -74,28 +74,28 @@ describe('secure-browser-launcher', () => { 'open', ['https://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); it('should reject non-HTTP(S) protocols', async () => { await expect( - openBrowserSecurely('file:///etc/passwd', mockExecFile as any) + openBrowserSecurely('file:///etc/passwd', mockExecFile as any), ).rejects.toThrow('Unsafe protocol'); await expect( - openBrowserSecurely('javascript:alert(1)', mockExecFile as any) + openBrowserSecurely('javascript:alert(1)', mockExecFile as any), ).rejects.toThrow('Unsafe protocol'); await expect( - openBrowserSecurely('ftp://example.com', mockExecFile as any) + openBrowserSecurely('ftp://example.com', mockExecFile as any), ).rejects.toThrow('Unsafe protocol'); }); it('should reject invalid URLs', async () => { await expect( - openBrowserSecurely('not-a-url', mockExecFile as any) + openBrowserSecurely('not-a-url', mockExecFile as any), ).rejects.toThrow('Invalid URL'); await expect( - openBrowserSecurely('', mockExecFile as any) + openBrowserSecurely('', mockExecFile as any), ).rejects.toThrow('Invalid URL'); }); @@ -103,17 +103,17 @@ describe('secure-browser-launcher', () => { await expect( openBrowserSecurely( 'http://example.com\nmalicious-command', - mockExecFile as any - ) + mockExecFile as any, + ), ).rejects.toThrow('invalid characters'); await expect( openBrowserSecurely( 'http://example.com\rmalicious-command', - mockExecFile as any - ) + mockExecFile as any, + ), ).rejects.toThrow('invalid characters'); await expect( - openBrowserSecurely('http://example.com\x00', mockExecFile as any) + openBrowserSecurely('http://example.com\x00', mockExecFile as any), ).rejects.toThrow('invalid characters'); }); }); @@ -124,7 +124,10 @@ describe('secure-browser-launcher', () => { const maliciousUrl = "http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))"; - const openPromise = openBrowserSecurely(maliciousUrl, mockExecFile as any); + const openPromise = openBrowserSecurely( + maliciousUrl, + mockExecFile as any, + ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -139,7 +142,7 @@ describe('secure-browser-launcher', () => { `Start-Process '${maliciousUrl.replace(/'/g, "''")}'`, ], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); @@ -161,7 +164,7 @@ describe('secure-browser-launcher', () => { 'open', [url], expect.any(Object), - expect.any(Function) + expect.any(Function), ); } }); @@ -173,7 +176,7 @@ describe('secure-browser-launcher', () => { const openPromise = openBrowserSecurely( urlWithSingleQuotes, - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -189,7 +192,7 @@ describe('secure-browser-launcher', () => { `Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`, ], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); }); @@ -198,7 +201,7 @@ describe('secure-browser-launcher', () => { it('should use correct command on macOS', async () => { const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -206,7 +209,7 @@ describe('secure-browser-launcher', () => { 'open', ['https://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); @@ -214,7 +217,7 @@ describe('secure-browser-launcher', () => { mockPlatform.mockReturnValue('win32'); const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -229,7 +232,7 @@ describe('secure-browser-launcher', () => { `Start-Process 'https://example.com'`, ], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); @@ -237,7 +240,7 @@ describe('secure-browser-launcher', () => { mockPlatform.mockReturnValue('linux'); const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); simulateSuccess(); await expect(openPromise).resolves.toBeUndefined(); @@ -245,14 +248,14 @@ describe('secure-browser-launcher', () => { 'xdg-open', ['https://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); it('should throw on unsupported platforms', async () => { mockPlatform.mockReturnValue('aix'); await expect( - openBrowserSecurely('https://example.com', mockExecFile as any) + openBrowserSecurely('https://example.com', mockExecFile as any), ).rejects.toThrow('Unsupported platform'); }); }); @@ -261,7 +264,7 @@ describe('secure-browser-launcher', () => { it('should handle browser launch failures gracefully', async () => { const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); simulateFailure(); await expect(openPromise).rejects.toThrow('Failed to open browser'); @@ -287,7 +290,7 @@ describe('secure-browser-launcher', () => { const openPromise = openBrowserSecurely( 'https://example.com', - mockExecFile as any + mockExecFile as any, ); await expect(openPromise).resolves.toBeUndefined(); @@ -298,15 +301,15 @@ describe('secure-browser-launcher', () => { 'xdg-open', ['https://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); expect(mockExecFile).toHaveBeenNthCalledWith( 2, 'gnome-open', ['https://example.com'], expect.any(Object), - expect.any(Function) + expect.any(Function), ); }); }); -}); \ No newline at end of file +}); diff --git a/workspace-server/src/__tests__/utils/validation.test.ts b/workspace-server/src/__tests__/utils/validation.test.ts index 10fb167..6f66d92 100644 --- a/workspace-server/src/__tests__/utils/validation.test.ts +++ b/workspace-server/src/__tests__/utils/validation.test.ts @@ -6,143 +6,176 @@ import { describe, it, expect } from '@jest/globals'; import { - validateEmail, - validateDateTime, - validateDocumentId, - extractDocumentId, - emailSchema, - emailArraySchema, - searchQuerySchema, - ValidationError + validateEmail, + validateDateTime, + validateDocumentId, + extractDocumentId, + emailSchema, + emailArraySchema, + searchQuerySchema, + ValidationError, } from '../../utils/validation'; describe('Validation Utilities', () => { - describe('Email Validation', () => { - it('should validate correct email addresses', () => { - expect(validateEmail('user@example.com')).toEqual({ success: true }); - expect(validateEmail('john.doe+tag@company.co.uk')).toEqual({ success: true }); - }); - - it('should reject invalid email addresses', () => { - expect(validateEmail('invalid')).toMatchObject({ success: false }); - expect(validateEmail('@example.com')).toMatchObject({ success: false }); - expect(validateEmail('user@')).toMatchObject({ success: false }); - expect(validateEmail('user @example.com')).toMatchObject({ success: false }); - }); - - it('should handle email arrays', () => { - const result1 = emailSchema.safeParse('user@example.com'); - expect(result1.success).toBe(true); - - const result2 = emailSchema.safeParse(['user1@example.com', 'user2@example.com']); - expect(result2.success).toBe(false); // Single schema doesn't accept arrays - }); - - it('should validate emailArraySchema with single email', () => { - const result = emailArraySchema.safeParse('user@example.com'); - expect(result.success).toBe(true); - }); - - it('should validate emailArraySchema with array of emails', () => { - const result = emailArraySchema.safeParse(['user1@example.com', 'user2@example.com']); - expect(result.success).toBe(true); - }); - - it('should reject emailArraySchema with invalid emails in array', () => { - const result = emailArraySchema.safeParse(['valid@example.com', 'invalid-email']); - expect(result.success).toBe(false); - }); - }); - - describe('DateTime Validation', () => { - it('should validate correct ISO 8601 datetime formats', () => { - expect(validateDateTime('2024-01-15T10:30:00Z')).toEqual({ success: true }); - expect(validateDateTime('2024-01-15T10:30:00.000Z')).toEqual({ success: true }); - expect(validateDateTime('2024-01-15T10:30:00-05:00')).toEqual({ success: true }); - expect(validateDateTime('2024-01-15T10:30:00+09:30')).toEqual({ success: true }); - }); - - it('should reject invalid datetime formats', () => { - expect(validateDateTime('2024-01-15')).toMatchObject({ success: false }); - expect(validateDateTime('10:30:00')).toMatchObject({ success: false }); - expect(validateDateTime('2024-01-15 10:30:00')).toMatchObject({ success: false }); - expect(validateDateTime('not a date')).toMatchObject({ success: false }); - }); - - it('should reject invalid dates', () => { - expect(validateDateTime('2024-13-01T10:30:00Z')).toMatchObject({ success: false }); // Invalid month - // Note: JavaScript Date constructor accepts Feb 30 and converts it to March 1st or 2nd - // So this test would pass as valid. We'd need more complex validation for this. - expect(validateDateTime('2024-00-01T10:30:00Z')).toMatchObject({ success: false }); // Invalid month (0) - }); - }); - - describe('Document ID Validation', () => { - it('should validate correct document IDs', () => { - expect(validateDocumentId('1a2b3c4d5e6f7g8h9i0j')).toEqual({ success: true }); - expect(validateDocumentId('abc-123_XYZ')).toEqual({ success: true }); - expect(validateDocumentId('Document_ID-123')).toEqual({ success: true }); - }); - - it('should reject invalid document IDs', () => { - expect(validateDocumentId('doc id with spaces')).toMatchObject({ success: false }); - expect(validateDocumentId('doc#id')).toMatchObject({ success: false }); - expect(validateDocumentId('doc/id')).toMatchObject({ success: false }); - expect(validateDocumentId('')).toMatchObject({ success: false }); - }); - }); - - describe('Document ID Extraction', () => { - it('should extract ID from Google Docs URLs', () => { - const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f/edit'; - expect(extractDocumentId(url)).toBe('1a2b3c4d5e6f'); - }); - - it('should extract ID from Google Drive URLs', () => { - const url = 'https://drive.google.com/file/d/abc123XYZ/view'; - expect(extractDocumentId(url)).toBe('abc123XYZ'); - }); - - it('should extract ID from Google Sheets URLs', () => { - const url = 'https://sheets.google.com/spreadsheets/d/sheet_id_123/edit'; - expect(extractDocumentId(url)).toBe('sheet_id_123'); - }); - - it('should return ID if already valid', () => { - const id = 'valid_document_id_123'; - expect(extractDocumentId(id)).toBe(id); - }); - - it('should throw error for invalid input', () => { - expect(() => extractDocumentId('not a valid url or id')).toThrow(); - expect(() => extractDocumentId('https://example.com/doc')).toThrow(); - }); - }); - - describe('Search Query Sanitization', () => { - it('should escape potentially dangerous characters', () => { - const result = searchQuerySchema.parse("test' OR '1'='1"); - expect(result).toBe("test\\' OR \\'1\\'=\\'1"); // Quotes are escaped - }); - - it('should escape quotes while preserving search functionality', () => { - const result = searchQuerySchema.parse('search for "exact phrase"'); - expect(result).toBe('search for \\"exact phrase\\"'); - }); - - it('should preserve safe characters', () => { - const result = searchQuerySchema.parse('test query with spaces and-dashes'); - expect(result).toBe('test query with spaces and-dashes'); - }); - }); - - describe('ValidationError', () => { - it('should create proper error with field and value', () => { - const error = new ValidationError('Invalid email', 'email', 'bad@'); - expect(error.message).toBe('Invalid email'); - expect(error.field).toBe('email'); - expect(error.value).toBe('bad@'); - expect(error.name).toBe('ValidationError'); - }); - }); -}); \ No newline at end of file + describe('Email Validation', () => { + it('should validate correct email addresses', () => { + expect(validateEmail('user@example.com')).toEqual({ success: true }); + expect(validateEmail('john.doe+tag@company.co.uk')).toEqual({ + success: true, + }); + }); + + it('should reject invalid email addresses', () => { + expect(validateEmail('invalid')).toMatchObject({ success: false }); + expect(validateEmail('@example.com')).toMatchObject({ success: false }); + expect(validateEmail('user@')).toMatchObject({ success: false }); + expect(validateEmail('user @example.com')).toMatchObject({ + success: false, + }); + }); + + it('should handle email arrays', () => { + const result1 = emailSchema.safeParse('user@example.com'); + expect(result1.success).toBe(true); + + const result2 = emailSchema.safeParse([ + 'user1@example.com', + 'user2@example.com', + ]); + expect(result2.success).toBe(false); // Single schema doesn't accept arrays + }); + + it('should validate emailArraySchema with single email', () => { + const result = emailArraySchema.safeParse('user@example.com'); + expect(result.success).toBe(true); + }); + + it('should validate emailArraySchema with array of emails', () => { + const result = emailArraySchema.safeParse([ + 'user1@example.com', + 'user2@example.com', + ]); + expect(result.success).toBe(true); + }); + + it('should reject emailArraySchema with invalid emails in array', () => { + const result = emailArraySchema.safeParse([ + 'valid@example.com', + 'invalid-email', + ]); + expect(result.success).toBe(false); + }); + }); + + describe('DateTime Validation', () => { + it('should validate correct ISO 8601 datetime formats', () => { + expect(validateDateTime('2024-01-15T10:30:00Z')).toEqual({ + success: true, + }); + expect(validateDateTime('2024-01-15T10:30:00.000Z')).toEqual({ + success: true, + }); + expect(validateDateTime('2024-01-15T10:30:00-05:00')).toEqual({ + success: true, + }); + expect(validateDateTime('2024-01-15T10:30:00+09:30')).toEqual({ + success: true, + }); + }); + + it('should reject invalid datetime formats', () => { + expect(validateDateTime('2024-01-15')).toMatchObject({ success: false }); + expect(validateDateTime('10:30:00')).toMatchObject({ success: false }); + expect(validateDateTime('2024-01-15 10:30:00')).toMatchObject({ + success: false, + }); + expect(validateDateTime('not a date')).toMatchObject({ success: false }); + }); + + it('should reject invalid dates', () => { + expect(validateDateTime('2024-13-01T10:30:00Z')).toMatchObject({ + success: false, + }); // Invalid month + // Note: JavaScript Date constructor accepts Feb 30 and converts it to March 1st or 2nd + // So this test would pass as valid. We'd need more complex validation for this. + expect(validateDateTime('2024-00-01T10:30:00Z')).toMatchObject({ + success: false, + }); // Invalid month (0) + }); + }); + + describe('Document ID Validation', () => { + it('should validate correct document IDs', () => { + expect(validateDocumentId('1a2b3c4d5e6f7g8h9i0j')).toEqual({ + success: true, + }); + expect(validateDocumentId('abc-123_XYZ')).toEqual({ success: true }); + expect(validateDocumentId('Document_ID-123')).toEqual({ success: true }); + }); + + it('should reject invalid document IDs', () => { + expect(validateDocumentId('doc id with spaces')).toMatchObject({ + success: false, + }); + expect(validateDocumentId('doc#id')).toMatchObject({ success: false }); + expect(validateDocumentId('doc/id')).toMatchObject({ success: false }); + expect(validateDocumentId('')).toMatchObject({ success: false }); + }); + }); + + describe('Document ID Extraction', () => { + it('should extract ID from Google Docs URLs', () => { + const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f/edit'; + expect(extractDocumentId(url)).toBe('1a2b3c4d5e6f'); + }); + + it('should extract ID from Google Drive URLs', () => { + const url = 'https://drive.google.com/file/d/abc123XYZ/view'; + expect(extractDocumentId(url)).toBe('abc123XYZ'); + }); + + it('should extract ID from Google Sheets URLs', () => { + const url = 'https://sheets.google.com/spreadsheets/d/sheet_id_123/edit'; + expect(extractDocumentId(url)).toBe('sheet_id_123'); + }); + + it('should return ID if already valid', () => { + const id = 'valid_document_id_123'; + expect(extractDocumentId(id)).toBe(id); + }); + + it('should throw error for invalid input', () => { + expect(() => extractDocumentId('not a valid url or id')).toThrow(); + expect(() => extractDocumentId('https://example.com/doc')).toThrow(); + }); + }); + + describe('Search Query Sanitization', () => { + it('should escape potentially dangerous characters', () => { + const result = searchQuerySchema.parse("test' OR '1'='1"); + expect(result).toBe("test\\' OR \\'1\\'=\\'1"); // Quotes are escaped + }); + + it('should escape quotes while preserving search functionality', () => { + const result = searchQuerySchema.parse('search for "exact phrase"'); + expect(result).toBe('search for \\"exact phrase\\"'); + }); + + it('should preserve safe characters', () => { + const result = searchQuerySchema.parse( + 'test query with spaces and-dashes', + ); + expect(result).toBe('test query with spaces and-dashes'); + }); + }); + + describe('ValidationError', () => { + it('should create proper error with field and value', () => { + const error = new ValidationError('Invalid email', 'email', 'bad@'); + expect(error.message).toBe('Invalid email'); + expect(error.field).toBe('email'); + expect(error.value).toBe('bad@'); + expect(error.name).toBe('ValidationError'); + }); + }); +}); diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index ab25ad1..431407c 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -16,7 +16,8 @@ import { OAuthCredentialStorage } from './token-storage/oauth-credential-storage // The Client ID for the OAuth flow. // The secret is handled by the cloud function, not in the client. -const CLIENT_ID = '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com'; +const CLIENT_ID = + '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com'; const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes /** @@ -30,367 +31,385 @@ interface OauthWebLogin { } export class AuthManager { - private client: Auth.OAuth2Client | null = null; - private scopes: string[]; - private onStatusUpdate: ((message: string) => void) | null = null; + private client: Auth.OAuth2Client | null = null; + private scopes: string[]; + private onStatusUpdate: ((message: string) => void) | null = null; - constructor(scopes: string[]) { - this.scopes = scopes; - } - - public setOnStatusUpdate(callback: (message: string) => void) { - this.onStatusUpdate = callback; - } + constructor(scopes: string[]) { + this.scopes = scopes; + } - private isTokenExpiringSoon(credentials: Auth.Credentials): boolean { - return !!(credentials.expiry_date && - credentials.expiry_date < Date.now() + TOKEN_EXPIRY_BUFFER_MS); - } + public setOnStatusUpdate(callback: (message: string) => void) { + this.onStatusUpdate = callback; + } - private async loadCachedCredentials(client: Auth.OAuth2Client): Promise { - const credentials = await OAuthCredentialStorage.loadCredentials(); - - if (credentials) { - // Check if saved token has required scopes - const savedScopes = new Set(credentials.scope?.split(' ') ?? []); - logToFile(`Cached token has scopes: ${[...savedScopes].join(', ')}`); - logToFile(`Required scopes: ${this.scopes.join(', ')}`); - - const missingScopes = this.scopes.filter(scope => !savedScopes.has(scope)); - - if (missingScopes.length > 0) { - logToFile(`Token cache missing required scopes: ${missingScopes.join(', ')}`); - logToFile('Removing cached token to force re-authentication...'); - await OAuthCredentialStorage.clearCredentials(); - return false; - } else { - client.setCredentials(credentials); - return true; - } - } + private isTokenExpiringSoon(credentials: Auth.Credentials): boolean { + return !!( + credentials.expiry_date && + credentials.expiry_date < Date.now() + TOKEN_EXPIRY_BUFFER_MS + ); + } + private async loadCachedCredentials( + client: Auth.OAuth2Client, + ): Promise { + const credentials = await OAuthCredentialStorage.loadCredentials(); + + if (credentials) { + // Check if saved token has required scopes + const savedScopes = new Set(credentials.scope?.split(' ') ?? []); + logToFile(`Cached token has scopes: ${[...savedScopes].join(', ')}`); + logToFile(`Required scopes: ${this.scopes.join(', ')}`); + + const missingScopes = this.scopes.filter( + (scope) => !savedScopes.has(scope), + ); + + if (missingScopes.length > 0) { + logToFile( + `Token cache missing required scopes: ${missingScopes.join(', ')}`, + ); + logToFile('Removing cached token to force re-authentication...'); + await OAuthCredentialStorage.clearCredentials(); return false; + } else { + client.setCredentials(credentials); + return true; + } } - public async getAuthenticatedClient(): Promise { - logToFile('getAuthenticatedClient called'); - - // Check if we have a cached client with valid credentials - if (this.client && this.client.credentials && this.client.credentials.refresh_token) { - logToFile('Returning existing cached client with valid credentials'); - logToFile(`Access token exists: ${!!this.client.credentials.access_token}`); - logToFile(`Expiry date: ${this.client.credentials.expiry_date}`); - logToFile(`Current time: ${Date.now()}`); - - const isExpired = this.isTokenExpiringSoon(this.client.credentials); - logToFile(`Token expired: ${isExpired}`); - - // Proactively refresh if expired - if (isExpired) { - logToFile('Token is expired, refreshing proactively...'); - try { - await this.refreshToken(); - logToFile('Token refreshed successfully'); - } catch (error) { - logToFile(`Failed to refresh token: ${error}`); - // Clear the client and fall through to re-authenticate - this.client = null; - await OAuthCredentialStorage.clearCredentials(); - } - } - - // Return the client (either still valid or just refreshed) - if (this.client) { - return this.client; - } - } - - // Note: No clientSecret is provided here. The secret is only known by the cloud function. - const options: Auth.OAuth2ClientOptions = { - clientId: CLIENT_ID, - }; - const oAuth2Client = new google.auth.OAuth2(options); - - oAuth2Client.on('tokens', async (tokens) => { - logToFile('Tokens refreshed event received'); - if (tokens.refresh_token) { - logToFile('New refresh token received in event'); - } - - try { - // Create a copy to preserve refresh_token from storage - const current = await OAuthCredentialStorage.loadCredentials() || {}; - const merged = { - ...tokens, - refresh_token: tokens.refresh_token || current.refresh_token - }; - await OAuthCredentialStorage.saveCredentials(merged); - logToFile('Credentials saved after refresh'); - } catch (e) { - logToFile(`Error saving refreshed credentials: ${e}`); - } - }); + return false; + } - logToFile('No valid cached client, checking for saved credentials...'); - if (await this.loadCachedCredentials(oAuth2Client)) { - logToFile('Loaded saved credentials, caching and returning client'); - this.client = oAuth2Client; - - // Check if the loaded token is expired and refresh proactively - const isExpired = this.isTokenExpiringSoon(this.client.credentials); - logToFile(`Token expired: ${isExpired}`); - - if (isExpired) { - logToFile('Loaded token is expired, refreshing proactively...'); - try { - await this.refreshToken(); - logToFile('Token refreshed successfully after loading from storage'); - } catch (error) { - logToFile(`Failed to refresh loaded token: ${error}`); - // Clear the client and fall through to re-authenticate - this.client = null; - await OAuthCredentialStorage.clearCredentials(); - } - } - - // Return the client if refresh succeeded or token was still valid - if (this.client) { - return this.client; - } + public async getAuthenticatedClient(): Promise { + logToFile('getAuthenticatedClient called'); + + // Check if we have a cached client with valid credentials + if ( + this.client && + this.client.credentials && + this.client.credentials.refresh_token + ) { + logToFile('Returning existing cached client with valid credentials'); + logToFile( + `Access token exists: ${!!this.client.credentials.access_token}`, + ); + logToFile(`Expiry date: ${this.client.credentials.expiry_date}`); + logToFile(`Current time: ${Date.now()}`); + + const isExpired = this.isTokenExpiringSoon(this.client.credentials); + logToFile(`Token expired: ${isExpired}`); + + // Proactively refresh if expired + if (isExpired) { + logToFile('Token is expired, refreshing proactively...'); + try { + await this.refreshToken(); + logToFile('Token refreshed successfully'); + } catch (error) { + logToFile(`Failed to refresh token: ${error}`); + // Clear the client and fall through to re-authenticate + this.client = null; + await OAuthCredentialStorage.clearCredentials(); } + } - const webLogin = await this.authWithWeb(oAuth2Client); - await open(webLogin.authUrl); - const msg = 'Waiting for authentication... Check your browser.'; - logToFile(msg); - if (this.onStatusUpdate) { - this.onStatusUpdate(msg); - } + // Return the client (either still valid or just refreshed) + if (this.client) { + return this.client; + } + } - // Add timeout to prevent infinite waiting when browser tab gets stuck - const authTimeout = 5 * 60 * 1000; // 5 minutes timeout - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject( - new Error( - 'User is not authenticated. Authentication timed out after 5 minutes. The user did not complete the login process in the browser. ' + - 'Please ask the user to check their browser and try again.', - ), - ); - }, authTimeout); - }); - await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + // Note: No clientSecret is provided here. The secret is only known by the cloud function. + const options: Auth.OAuth2ClientOptions = { + clientId: CLIENT_ID, + }; + const oAuth2Client = new google.auth.OAuth2(options); + + oAuth2Client.on('tokens', async (tokens) => { + logToFile('Tokens refreshed event received'); + if (tokens.refresh_token) { + logToFile('New refresh token received in event'); + } + + try { + // Create a copy to preserve refresh_token from storage + const current = (await OAuthCredentialStorage.loadCredentials()) || {}; + const merged = { + ...tokens, + refresh_token: tokens.refresh_token || current.refresh_token, + }; + await OAuthCredentialStorage.saveCredentials(merged); + logToFile('Credentials saved after refresh'); + } catch (e) { + logToFile(`Error saving refreshed credentials: ${e}`); + } + }); + + logToFile('No valid cached client, checking for saved credentials...'); + if (await this.loadCachedCredentials(oAuth2Client)) { + logToFile('Loaded saved credentials, caching and returning client'); + this.client = oAuth2Client; + + // Check if the loaded token is expired and refresh proactively + const isExpired = this.isTokenExpiringSoon(this.client.credentials); + logToFile(`Token expired: ${isExpired}`); + + if (isExpired) { + logToFile('Loaded token is expired, refreshing proactively...'); + try { + await this.refreshToken(); + logToFile('Token refreshed successfully after loading from storage'); + } catch (error) { + logToFile(`Failed to refresh loaded token: ${error}`); + // Clear the client and fall through to re-authenticate + this.client = null; + await OAuthCredentialStorage.clearCredentials(); + } + } - await OAuthCredentialStorage.saveCredentials(oAuth2Client.credentials); - this.client = oAuth2Client; + // Return the client if refresh succeeded or token was still valid + if (this.client) { return this.client; + } } - public async clearAuth(): Promise { - logToFile('Clearing authentication...'); - this.client = null; - await OAuthCredentialStorage.clearCredentials(); - logToFile('Authentication cleared.'); + const webLogin = await this.authWithWeb(oAuth2Client); + await open(webLogin.authUrl); + const msg = 'Waiting for authentication... Check your browser.'; + logToFile(msg); + if (this.onStatusUpdate) { + this.onStatusUpdate(msg); } - public async refreshToken(): Promise { - logToFile('Manual token refresh triggered'); - if (!this.client) { - logToFile('No client available to refresh, getting new client'); - this.client = await this.getAuthenticatedClient(); - } - try { - const currentCredentials = { ...this.client.credentials }; - - if (!currentCredentials.refresh_token) { - throw new Error('No refresh token available'); - } - - logToFile('Calling cloud function to refresh token...'); - - // Call the cloud function refresh endpoint - // The cloud function has the client secret needed for token refresh - const response = await fetch('https://google-workspace-extension.geminicli.com/refreshToken', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - refresh_token: currentCredentials.refresh_token - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Token refresh failed: ${response.status} ${errorText}`); - } - - const newTokens = await response.json(); - - // Merge new tokens with existing credentials, preserving refresh_token - // Note: Google does NOT return a new refresh_token on refresh - const mergedCredentials = { - ...newTokens, - refresh_token: currentCredentials.refresh_token // Always preserve original - }; + // Add timeout to prevent infinite waiting when browser tab gets stuck + const authTimeout = 5 * 60 * 1000; // 5 minutes timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + 'User is not authenticated. Authentication timed out after 5 minutes. The user did not complete the login process in the browser. ' + + 'Please ask the user to check their browser and try again.', + ), + ); + }, authTimeout); + }); + await Promise.race([webLogin.loginCompletePromise, timeoutPromise]); + + await OAuthCredentialStorage.saveCredentials(oAuth2Client.credentials); + this.client = oAuth2Client; + return this.client; + } - this.client.setCredentials(mergedCredentials); - await OAuthCredentialStorage.saveCredentials(mergedCredentials); - logToFile('Token refreshed and saved successfully via cloud function'); - } catch (error) { - logToFile(`Error during token refresh: ${error}`); - throw error; - } + public async clearAuth(): Promise { + logToFile('Clearing authentication...'); + this.client = null; + await OAuthCredentialStorage.clearCredentials(); + logToFile('Authentication cleared.'); + } + + public async refreshToken(): Promise { + logToFile('Manual token refresh triggered'); + if (!this.client) { + logToFile('No client available to refresh, getting new client'); + this.client = await this.getAuthenticatedClient(); } + try { + const currentCredentials = { ...this.client.credentials }; + + if (!currentCredentials.refresh_token) { + throw new Error('No refresh token available'); + } + + logToFile('Calling cloud function to refresh token...'); + + // Call the cloud function refresh endpoint + // The cloud function has the client secret needed for token refresh + const response = await fetch( + 'https://google-workspace-extension.geminicli.com/refreshToken', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token: currentCredentials.refresh_token, + }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Token refresh failed: ${response.status} ${errorText}`, + ); + } + + const newTokens = await response.json(); + + // Merge new tokens with existing credentials, preserving refresh_token + // Note: Google does NOT return a new refresh_token on refresh + const mergedCredentials = { + ...newTokens, + refresh_token: currentCredentials.refresh_token, // Always preserve original + }; + + this.client.setCredentials(mergedCredentials); + await OAuthCredentialStorage.saveCredentials(mergedCredentials); + logToFile('Token refreshed and saved successfully via cloud function'); + } catch (error) { + logToFile(`Error during token refresh: ${error}`); + throw error; + } + } - private async getAvailablePort(): Promise { - return new Promise((resolve, reject) => { - let port = 0; - try { - const portStr = process.env['OAUTH_CALLBACK_PORT']; - if (portStr) { - port = parseInt(portStr, 10); - if (isNaN(port) || port <= 0 || port > 65535) { - return reject( - new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`), - ); - } - return resolve(port); - } - const server = net.createServer(); - server.listen(0, () => { - const address = server.address()! as net.AddressInfo; - port = address.port; - }); - server.on('listening', () => { - server.close(); - server.unref(); - }); - server.on('error', (e) => reject(e)); - server.on('close', () => resolve(port)); - } catch (e) { - reject(e); - } - }); + private async getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + let port = 0; + try { + const portStr = process.env['OAUTH_CALLBACK_PORT']; + if (portStr) { + port = parseInt(portStr, 10); + if (isNaN(port) || port <= 0 || port > 65535) { + return reject( + new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`), + ); + } + return resolve(port); } + const server = net.createServer(); + server.listen(0, () => { + const address = server.address()! as net.AddressInfo; + port = address.port; + }); + server.on('listening', () => { + server.close(); + server.unref(); + }); + server.on('error', (e) => reject(e)); + server.on('close', () => resolve(port)); + } catch (e) { + reject(e); + } + }); + } - private async authWithWeb(client: Auth.OAuth2Client): Promise { - logToFile(`Requesting authentication with scopes: ${this.scopes.join(', ')}`); + private async authWithWeb(client: Auth.OAuth2Client): Promise { + logToFile( + `Requesting authentication with scopes: ${this.scopes.join(', ')}`, + ); - const port = await this.getAvailablePort(); - const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; - - const localRedirectUri = `http://${host}:${port}/oauth2callback`; + const port = await this.getAvailablePort(); + const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost'; - const isGuiAvailable = shouldLaunchBrowser(); + const localRedirectUri = `http://${host}:${port}/oauth2callback`; - // SECURITY: Generate a random token for CSRF protection. - const csrfToken = crypto.randomBytes(32).toString('hex'); + const isGuiAvailable = shouldLaunchBrowser(); - // The state now contains a JSON payload indicating the flow mode and CSRF token. - const statePayload = { - uri: isGuiAvailable ? localRedirectUri : undefined, - manual: !isGuiAvailable, - csrf: csrfToken, - }; - const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); + // SECURITY: Generate a random token for CSRF protection. + const csrfToken = crypto.randomBytes(32).toString('hex'); - // The redirect URI for Google's auth server is the cloud function - const cloudFunctionRedirectUri = 'https://google-workspace-extension.geminicli.com'; + // The state now contains a JSON payload indicating the flow mode and CSRF token. + const statePayload = { + uri: isGuiAvailable ? localRedirectUri : undefined, + manual: !isGuiAvailable, + csrf: csrfToken, + }; + const state = Buffer.from(JSON.stringify(statePayload)).toString('base64'); - const authUrl = client.generateAuthUrl({ - redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function - access_type: 'offline', - scope: this.scopes, - state: state, // Pass our JSON payload in the state - prompt: 'consent', // Make sure we get a refresh token - }); + // The redirect URI for Google's auth server is the cloud function + const cloudFunctionRedirectUri = + 'https://google-workspace-extension.geminicli.com'; - const loginCompletePromise = new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - try { - // Use startsWith for more robust path checking. - if (!req.url || !req.url.startsWith('/oauth2callback')) { - res.end(); - reject( - new Error( - 'OAuth callback not received. Unexpected request: ' + req.url, - ), - ); - return; - } - - const qs = new url.URL(req.url, `http://${host}:${port}`) - .searchParams; - - // SECURITY: Validate the state parameter to prevent CSRF attacks. - const returnedState = qs.get('state'); - if (returnedState !== csrfToken) { - res.end('State mismatch. Possible CSRF attack.'); - reject(new Error('OAuth state mismatch. Possible CSRF attack.')); - return; - } - - if (qs.get('error')) { - const errorCode = qs.get('error'); - const errorDescription = - qs.get('error_description') || 'No additional details provided'; - res.end(); - reject( - new Error( - `Google OAuth error: ${errorCode}. ${errorDescription}`, - ), - ); - return; - } - - const access_token = qs.get('access_token'); - const refresh_token = qs.get('refresh_token'); - const scope = qs.get('scope'); - const token_type = qs.get('token_type'); - const expiry_date_str = qs.get('expiry_date'); - - if (access_token && expiry_date_str) { - const tokens: Auth.Credentials = { - access_token: access_token, - refresh_token: refresh_token || null, - scope: scope || undefined, - token_type: (token_type as 'Bearer') || undefined, - expiry_date: parseInt(expiry_date_str, 10), - }; - client.setCredentials(tokens); - res.end('Authentication successful! Please return to the console.'); - resolve(); - } else { - reject( - new Error( - 'Authentication failed: Did not receive tokens from callback.', - ), - ); - } - } catch (e) { - reject(e); - } finally { - server.close(); - } - }) - - server.listen(port, host, () => { - // Server started successfully - }); - - server.on('error', (err) => { - reject( - new Error( - `OAuth callback server error: ${err}`, - ), - ); - }); - }); + const authUrl = client.generateAuthUrl({ + redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function + access_type: 'offline', + scope: this.scopes, + state: state, // Pass our JSON payload in the state + prompt: 'consent', // Make sure we get a refresh token + }); - return { - authUrl, - loginCompletePromise, - }; + const loginCompletePromise = new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + // Use startsWith for more robust path checking. + if (!req.url || !req.url.startsWith('/oauth2callback')) { + res.end(); + reject( + new Error( + 'OAuth callback not received. Unexpected request: ' + req.url, + ), + ); + return; + } + + const qs = new url.URL(req.url, `http://${host}:${port}`) + .searchParams; + + // SECURITY: Validate the state parameter to prevent CSRF attacks. + const returnedState = qs.get('state'); + if (returnedState !== csrfToken) { + res.end('State mismatch. Possible CSRF attack.'); + reject(new Error('OAuth state mismatch. Possible CSRF attack.')); + return; + } + + if (qs.get('error')) { + const errorCode = qs.get('error'); + const errorDescription = + qs.get('error_description') || 'No additional details provided'; + res.end(); + reject( + new Error( + `Google OAuth error: ${errorCode}. ${errorDescription}`, + ), + ); + return; + } + + const access_token = qs.get('access_token'); + const refresh_token = qs.get('refresh_token'); + const scope = qs.get('scope'); + const token_type = qs.get('token_type'); + const expiry_date_str = qs.get('expiry_date'); + + if (access_token && expiry_date_str) { + const tokens: Auth.Credentials = { + access_token: access_token, + refresh_token: refresh_token || null, + scope: scope || undefined, + token_type: (token_type as 'Bearer') || undefined, + expiry_date: parseInt(expiry_date_str, 10), + }; + client.setCredentials(tokens); + res.end('Authentication successful! Please return to the console.'); + resolve(); + } else { + reject( + new Error( + 'Authentication failed: Did not receive tokens from callback.', + ), + ); + } + } catch (e) { + reject(e); + } finally { + server.close(); + } + }); + + server.listen(port, host, () => { + // Server started successfully + }); + + server.on('error', (err) => { + reject(new Error(`OAuth callback server error: ${err}`)); + }); + }); + + return { + authUrl, + loginCompletePromise, + }; } } diff --git a/workspace-server/src/auth/token-storage/base-token-storage.ts b/workspace-server/src/auth/token-storage/base-token-storage.ts index 8a9c44f..89b926d 100644 --- a/workspace-server/src/auth/token-storage/base-token-storage.ts +++ b/workspace-server/src/auth/token-storage/base-token-storage.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - OAuthCredentials, - TokenStorage -} from './types'; +import { OAuthCredentials, TokenStorage } from './types'; export abstract class BaseTokenStorage implements TokenStorage { protected readonly serviceName: string; @@ -38,9 +35,7 @@ export abstract class BaseTokenStorage implements TokenStorage { } } - - protected sanitizeServerName(serverName: string): string { return serverName.replace(/[^a-zA-Z0-9-_.]/g, '_'); } -} \ No newline at end of file +} diff --git a/workspace-server/src/auth/token-storage/file-token-storage.ts b/workspace-server/src/auth/token-storage/file-token-storage.ts index d0a590d..cb7e737 100644 --- a/workspace-server/src/auth/token-storage/file-token-storage.ts +++ b/workspace-server/src/auth/token-storage/file-token-storage.ts @@ -140,8 +140,6 @@ export class FileTokenStorage extends BaseTokenStorage { return null; } - - return credentials; } @@ -212,4 +210,4 @@ export class FileTokenStorage extends BaseTokenStorage { } } } -} \ No newline at end of file +} diff --git a/workspace-server/src/auth/token-storage/hybrid-token-storage.ts b/workspace-server/src/auth/token-storage/hybrid-token-storage.ts index 39f3962..d502318 100644 --- a/workspace-server/src/auth/token-storage/hybrid-token-storage.ts +++ b/workspace-server/src/auth/token-storage/hybrid-token-storage.ts @@ -25,9 +25,8 @@ export class HybridTokenStorage extends BaseTokenStorage { if (!forceFileStorage) { try { - const { KeychainTokenStorage } = await import( - './keychain-token-storage' - ); + const { KeychainTokenStorage } = + await import('./keychain-token-storage'); const keychainStorage = new KeychainTokenStorage(this.serviceName); const isAvailable = await keychainStorage.isAvailable(); @@ -38,7 +37,10 @@ export class HybridTokenStorage extends BaseTokenStorage { } } catch (e) { // Fallback to file storage if keychain fails to initialize. - console.warn('Keychain initialization failed, falling back to file storage:', e); + console.warn( + 'Keychain initialization failed, falling back to file storage:', + e, + ); } } @@ -95,4 +97,4 @@ export class HybridTokenStorage extends BaseTokenStorage { await this.getStorage(); return this.storageType!; } -} \ No newline at end of file +} diff --git a/workspace-server/src/auth/token-storage/keychain-token-storage.ts b/workspace-server/src/auth/token-storage/keychain-token-storage.ts index c12bca3..7fb09f8 100644 --- a/workspace-server/src/auth/token-storage/keychain-token-storage.ts +++ b/workspace-server/src/auth/token-storage/keychain-token-storage.ts @@ -42,7 +42,6 @@ export class KeychainTokenStorage extends BaseTokenStorage { const module = await import(moduleName); this.keytarModule = module.default || module; } catch (error) { - console.error(error); } return this.keytarModule; @@ -68,8 +67,6 @@ export class KeychainTokenStorage extends BaseTokenStorage { const credentials = JSON.parse(data) as OAuthCredentials; - - return credentials; } catch (error) { if (error instanceof SyntaxError) { @@ -246,4 +243,4 @@ export class KeychainTokenStorage extends BaseTokenStorage { async isAvailable(): Promise { return this.checkKeychainAvailability(); } -} \ No newline at end of file +} diff --git a/workspace-server/src/auth/token-storage/oauth-credential-storage.ts b/workspace-server/src/auth/token-storage/oauth-credential-storage.ts index b314ea6..e96d1e6 100644 --- a/workspace-server/src/auth/token-storage/oauth-credential-storage.ts +++ b/workspace-server/src/auth/token-storage/oauth-credential-storage.ts @@ -77,4 +77,4 @@ export class OAuthCredentialStorage { throw error; } } -} \ No newline at end of file +} diff --git a/workspace-server/src/auth/token-storage/types.ts b/workspace-server/src/auth/token-storage/types.ts index 83f4666..ab7374d 100644 --- a/workspace-server/src/auth/token-storage/types.ts +++ b/workspace-server/src/auth/token-storage/types.ts @@ -39,4 +39,4 @@ export interface TokenStorage { listServers(): Promise; getAllCredentials(): Promise>; clearAll(): Promise; -} \ No newline at end of file +} diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 3df4a90..d2f4bd5 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -6,626 +6,924 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import { AuthManager } from './auth/AuthManager'; import { DocsService } from './services/DocsService'; -import { DriveService } from "./services/DriveService"; -import { CalendarService } from "./services/CalendarService"; -import { ChatService } from "./services/ChatService"; -import { GmailService } from "./services/GmailService"; -import { TimeService } from "./services/TimeService"; -import { PeopleService } from "./services/PeopleService"; -import { SlidesService } from "./services/SlidesService"; -import { SheetsService } from "./services/SheetsService"; -import { GMAIL_SEARCH_MAX_RESULTS } from "./utils/constants"; -import { extractDocId } from "./utils/IdUtils"; - -import { setLoggingEnabled } from "./utils/logger"; +import { DriveService } from './services/DriveService'; +import { CalendarService } from './services/CalendarService'; +import { ChatService } from './services/ChatService'; +import { GmailService } from './services/GmailService'; +import { TimeService } from './services/TimeService'; +import { PeopleService } from './services/PeopleService'; +import { SlidesService } from './services/SlidesService'; +import { SheetsService } from './services/SheetsService'; +import { GMAIL_SEARCH_MAX_RESULTS } from './utils/constants'; +import { extractDocId } from './utils/IdUtils'; + +import { setLoggingEnabled } from './utils/logger'; // Shared schemas for Gmail tools const emailComposeSchema = { - to: z.union([z.string(), z.array(z.string())]).describe('Recipient email address(es).'), - subject: z.string().describe('Email subject.'), - body: z.string().describe('Email body content.'), - cc: z.union([z.string(), z.array(z.string())]).optional().describe('CC recipient email address(es).'), - bcc: z.union([z.string(), z.array(z.string())]).optional().describe('BCC recipient email address(es).'), - isHtml: z.boolean().optional().describe('Whether the body is HTML (default: false).'), + to: z + .union([z.string(), z.array(z.string())]) + .describe('Recipient email address(es).'), + subject: z.string().describe('Email subject.'), + body: z.string().describe('Email body content.'), + cc: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('CC recipient email address(es).'), + bcc: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe('BCC recipient email address(es).'), + isHtml: z + .boolean() + .optional() + .describe('Whether the body is HTML (default: false).'), }; const SCOPES = [ - 'https://www.googleapis.com/auth/documents', - 'https://www.googleapis.com/auth/drive', - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/chat.spaces', - 'https://www.googleapis.com/auth/chat.messages', - 'https://www.googleapis.com/auth/chat.memberships', - 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/directory.readonly', - 'https://www.googleapis.com/auth/presentations.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly', + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/calendar', + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/directory.readonly', + 'https://www.googleapis.com/auth/presentations.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly', ]; // Dynamically import version from package.json import { version } from '../package.json'; async function main() { - // 1. Initialize services - if (process.argv.includes('--debug')) { - setLoggingEnabled(true); - } - - const authManager = new AuthManager(SCOPES); - - // 2. Create the server instance - const server = new McpServer({ - name: "google-workspace-server", - version, - }); - - authManager.setOnStatusUpdate((message) => { - server.sendLoggingMessage({ - level: 'info', - data: message - }).catch(err => { - console.error('Failed to send logging message:', err); - }); - }); - - const driveService = new DriveService(authManager); - const docsService = new DocsService(authManager, driveService); - const peopleService = new PeopleService(authManager); - const calendarService = new CalendarService(authManager); - const chatService = new ChatService(authManager); - const gmailService = new GmailService(authManager); - const timeService = new TimeService(); - const slidesService = new SlidesService(authManager); - const sheetsService = new SheetsService(authManager); - - - // 3. Register tools directly on the server - server.registerTool( - "auth.clear", - { - description: 'Clears the authentication credentials, forcing a re-login on the next request.', - inputSchema: {} - }, - async () => { - await authManager.clearAuth(); - return { - content: [{ - type: "text", - text: "Authentication credentials cleared. You will be prompted to log in again on the next request." - }] - }; - } - ); - - server.registerTool( - "auth.refreshToken", - { - description: 'Manually triggers the token refresh process.', - inputSchema: {} - }, - async () => { - await authManager.refreshToken(); - return { - content: [{ - type: "text", - text: "Token refresh process triggered successfully." - }] - }; - } - ); - - server.registerTool( - "docs.create", - { - description: 'Creates a new Google Doc. Can be blank or with Markdown content.', - inputSchema: { - title: z.string().describe('The title for the new Google Doc.'), - folderName: z.string().optional().describe('The name of the folder to create the document in.'), - markdown: z.string().optional().describe('The Markdown content to create the document from.'), - } - }, - docsService.create - ); - - server.registerTool( - "docs.insertText", - { - description: 'Inserts text at the beginning of a Google Doc.', - inputSchema: { - documentId: z.string().describe('The ID of the document to modify.'), - text: z.string().describe('The text to insert at the beginning of the document.'), - tabId: z.string().optional().describe('The ID of the tab to modify. If not provided, modifies the first tab.'), - } - }, - docsService.insertText - ); - - server.registerTool( - "docs.find", - { - description: 'Finds Google Docs by searching for a query in their title. Supports pagination.', - inputSchema: { - query: z.string().describe('The text to search for in the document titles.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - pageSize: z.number().optional().describe('The maximum number of results to return.'), - } - }, - docsService.find - ); - - server.registerTool( - "drive.findFolder", - { - description: 'Finds a folder by name in Google Drive.', - inputSchema: { - folderName: z.string().describe('The name of the folder to find.'), - } - }, - driveService.findFolder - ); - - server.registerTool( - "drive.createFolder", - { - description: 'Creates a new folder in Google Drive.', - inputSchema: { - name: z.string().trim().min(1).describe('The name of the new folder.'), - parentId: z.string().trim().min(1).optional().describe('The ID of the parent folder. If not provided, creates in the root directory.'), - } - }, - driveService.createFolder - ); - - server.registerTool( - "docs.move", - { - description: 'Moves a document to a specified folder.', - inputSchema: { - documentId: z.string().describe('The ID of the document to move.'), - folderName: z.string().describe('The name of the destination folder.'), - } - }, - docsService.move - ); - - server.registerTool( - "docs.getText", - { - description: 'Retrieves the text content of a Google Doc.', - inputSchema: { - documentId: z.string().describe('The ID of the document to read.'), - tabId: z.string().optional().describe('The ID of the tab to read. If not provided, returns all tabs.'), - } - }, - docsService.getText - ); - - server.registerTool( - "docs.appendText", - { - description: 'Appends text to the end of a Google Doc.', - inputSchema: { - documentId: z.string().describe('The ID of the document to modify.'), - text: z.string().describe('The text to append to the document.'), - tabId: z.string().optional().describe('The ID of the tab to modify. If not provided, modifies the first tab.'), - } - }, - docsService.appendText - ); - - server.registerTool( - "docs.replaceText", - { - description: 'Replaces all occurrences of a given text with new text in a Google Doc.', - inputSchema: { - documentId: z.string().describe('The ID of the document to modify.'), - findText: z.string().describe('The text to find in the document.'), - replaceText: z.string().describe('The text to replace the found text with.'), - tabId: z.string().optional().describe('The ID of the tab to modify. If not provided, replaces in all tabs (legacy behavior).'), - } - }, - docsService.replaceText - ); - - server.registerTool( - "docs.extractIdFromUrl", - { - description: 'Extracts the document ID from a Google Workspace URL.', - inputSchema: { - url: z.string().describe('The URL of the Google Workspace document.'), - } - }, - async (input: { url: string }) => { - const result = extractDocId(input.url); - return { - content: [{ - type: "text" as const, - text: result || '' - }] - }; - } - ); - - // Slides tools - server.registerTool( - "slides.getText", - { - description: 'Retrieves the text content of a Google Slides presentation.', - inputSchema: { - presentationId: z.string().describe('The ID or URL of the presentation to read.'), - } - }, - slidesService.getText - ); - - server.registerTool( - "slides.find", - { - description: 'Finds Google Slides presentations by searching for a query. Supports pagination.', - inputSchema: { - query: z.string().describe('The text to search for in presentations.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - pageSize: z.number().optional().describe('The maximum number of results to return.'), - } - }, - slidesService.find - ); - - server.registerTool( - "slides.getMetadata", - { - description: 'Gets metadata about a Google Slides presentation.', - inputSchema: { - presentationId: z.string().describe('The ID or URL of the presentation.'), - } - }, - slidesService.getMetadata - ); - - // Sheets tools - server.registerTool( - "sheets.getText", - { - description: 'Retrieves the content of a Google Sheets spreadsheet.', - inputSchema: { - spreadsheetId: z.string().describe('The ID or URL of the spreadsheet to read.'), - format: z.enum(['text', 'csv', 'json']).optional().describe('Output format (default: text).'), - } - }, - sheetsService.getText - ); - - server.registerTool( - "sheets.getRange", - { - description: 'Gets values from a specific range in a Google Sheets spreadsheet.', - inputSchema: { - spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), - range: z.string().describe('The A1 notation range to get (e.g., "Sheet1!A1:B10").'), - } - }, - sheetsService.getRange - ); - - server.registerTool( - "sheets.find", - { - description: 'Finds Google Sheets spreadsheets by searching for a query. Supports pagination.', - inputSchema: { - query: z.string().describe('The text to search for in spreadsheets.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - pageSize: z.number().optional().describe('The maximum number of results to return.'), - } - }, - sheetsService.find - ); - - server.registerTool( - "sheets.getMetadata", - { - description: 'Gets metadata about a Google Sheets spreadsheet.', - inputSchema: { - spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), - } - }, - sheetsService.getMetadata - ); - - server.registerTool( - "drive.search", - { - description: 'Searches for files and folders in Google Drive. The query can be a simple search term, a Google Drive URL, or a full query string. For more information on query strings see: https://developers.google.com/drive/api/guides/search-files', - inputSchema: { - query: z.string().optional().describe('A simple search term (e.g., "Budget Q3"), a Google Drive URL, or a full query string (e.g., "name contains \'Budget\' and owners in \'user@example.com\'").'), - pageSize: z.number().optional().describe('The maximum number of results to return.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - corpus: z.string().optional().describe('The corpus of files to search (e.g., "user", "domain").'), - unreadOnly: z.boolean().optional().describe('Whether to filter for unread files only.'), - sharedWithMe: z.boolean().optional().describe('Whether to search for files shared with the user.'), - } - }, - driveService.search - ); - - server.registerTool( - "drive.downloadFile", - { - description: 'Downloads the content of a file from Google Drive to a local path. Note: Google Docs, Sheets, and Slides require specialized handling.', - inputSchema: { - fileId: z.string().describe('The ID of the file to download.'), - localPath: z.string().describe('The local file path where the content should be saved (e.g., "downloads/report.pdf").'), - } - }, - driveService.downloadFile - ); - - server.registerTool( - "calendar.list", - { - description: 'Lists all of the user\'s calendars.', - inputSchema: {} - }, - calendarService.listCalendars - ); - - server.registerTool( - "calendar.createEvent", - { - description: 'Creates a new event in a calendar.', - inputSchema: { - calendarId: z.string().describe('The ID of the calendar to create the event in.'), - summary: z.string().describe('The summary or title of the event.'), - description: z.string().optional().describe('The description of the event.'), - start: z.object({ - dateTime: z.string().describe('The start time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T10:30:00Z or 2024-01-15T10:30:00-05:00).'), - }), - end: z.object({ - dateTime: z.string().describe('The end time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T11:30:00Z or 2024-01-15T11:30:00-05:00).'), - }), - attendees: z.array(z.string()).optional().describe('The email addresses of the attendees.'), - } - }, - calendarService.createEvent - ); - - server.registerTool( - "calendar.listEvents", - { - description: 'Lists events from a calendar. Defaults to upcoming events.', - inputSchema: { - calendarId: z.string().describe('The ID of the calendar to list events from.'), - timeMin: z.string().optional().describe('The start time for the event search. Defaults to the current time.'), - timeMax: z.string().optional().describe('The end time for the event search.'), - attendeeResponseStatus: z.array(z.string()).optional().describe('The response status of the attendee.'), - } - }, - calendarService.listEvents - ); - - server.registerTool( - "calendar.getEvent", - { - description: 'Gets the details of a specific calendar event.', - inputSchema: { - eventId: z.string().describe('The ID of the event to retrieve.'), - calendarId: z.string().optional().describe('The ID of the calendar the event belongs to. Defaults to the primary calendar.'), - } - }, - calendarService.getEvent - ); - - server.registerTool( - "calendar.findFreeTime", - { - description: 'Finds a free time slot for multiple people to meet.', - inputSchema: { - attendees: z.array(z.string()).describe('The email addresses of the attendees.'), - timeMin: z.string().describe('The start time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T09:00:00Z or 2024-01-15T09:00:00-05:00).'), - timeMax: z.string().describe('The end time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T18:00:00Z or 2024-01-15T18:00:00-05:00).'), - duration: z.number().describe('The duration of the meeting in minutes.'), - } - }, - calendarService.findFreeTime - ); - - server.registerTool( - "calendar.updateEvent", - { - description: 'Updates an existing event in a calendar.', - inputSchema: { - eventId: z.string().describe('The ID of the event to update.'), - calendarId: z.string().optional().describe('The ID of the calendar to update the event in.'), - summary: z.string().optional().describe('The new summary or title of the event.'), - description: z.string().optional().describe('The new description of the event.'), - start: z.object({ - dateTime: z.string().describe('The new start time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T10:30:00Z or 2024-01-15T10:30:00-05:00).'), - }).optional(), - end: z.object({ - dateTime: z.string().describe('The new end time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T11:30:00Z or 2024-01-15T11:30:00-05:00).'), - }).optional(), - attendees: z.array(z.string()).optional().describe('The new list of attendees for the event.'), - } - }, - calendarService.updateEvent - ); - - server.registerTool( - "calendar.respondToEvent", - { - description: 'Responds to a meeting invitation (accept, decline, or tentative).', - inputSchema: { - eventId: z.string().describe('The ID of the event to respond to.'), - calendarId: z.string().optional().describe('The ID of the calendar containing the event.'), - responseStatus: z.enum(['accepted', 'declined', 'tentative']).describe('Your response to the invitation.'), - sendNotification: z.boolean().optional().describe('Whether to send a notification to the organizer (default: true).'), - responseMessage: z.string().optional().describe('Optional message to include with your response.'), - } - }, - calendarService.respondToEvent - ); - - server.registerTool( - "calendar.deleteEvent", - { - description: 'Deletes an event from a calendar.', - inputSchema: { - eventId: z.string().describe('The ID of the event to delete.'), - calendarId: z.string().optional().describe('The ID of the calendar to delete the event from. Defaults to the primary calendar.'), - } - }, - calendarService.deleteEvent - ); - - server.registerTool( - "chat.listSpaces", - { - description: 'Lists the spaces the user is a member of.', - inputSchema: {} - }, - chatService.listSpaces - ); - - server.registerTool( - "chat.findSpaceByName", - { - description: 'Finds a Google Chat space by its display name.', - inputSchema: { - displayName: z.string().describe('The display name of the space to find.'), - } - }, - chatService.findSpaceByName - ); - - server.registerTool( - "chat.sendMessage", - { - description: 'Sends a message to a Google Chat space.', - inputSchema: { - spaceName: z.string().describe('The name of the space to send the message to (e.g., spaces/AAAAN2J52O8).'), - message: z.string().describe('The message to send.'), - threadName: z.string().optional().describe('The resource name of the thread to reply to. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"'), - } - }, - chatService.sendMessage - ); - - server.registerTool( - "chat.getMessages", - { - description: 'Gets messages from a Google Chat space.', - inputSchema: { - spaceName: z.string().describe('The name of the space to get messages from (e.g., spaces/AAAAN2J52O8).'), - threadName: z.string().optional().describe('The resource name of the thread to filter messages by. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"'), - unreadOnly: z.boolean().optional().describe('Whether to return only unread messages.'), - pageSize: z.number().optional().describe('The maximum number of messages to return.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - orderBy: z.string().optional().describe('The order to list messages in (e.g., "createTime desc").'), - } - }, - chatService.getMessages - ); - - server.registerTool( - "chat.sendDm", - { - description: 'Sends a direct message to a user.', - inputSchema: { - email: z.string().email().describe('The email address of the user to send the message to.'), - message: z.string().describe('The message to send.'), - threadName: z.string().optional().describe('The resource name of the thread to reply to. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"'), - } - }, - chatService.sendDm - ); - - server.registerTool( - "chat.findDmByEmail", - { - description: 'Finds a Google Chat DM space by a user\'s email address.', - inputSchema: { - email: z.string().email().describe('The email address of the user to find the DM space with.'), - } - }, - chatService.findDmByEmail - ); - - server.registerTool( - "chat.listThreads", - { - description: 'Lists threads from a Google Chat space in reverse chronological order.', - inputSchema: { - spaceName: z.string().describe('The name of the space to get threads from (e.g., spaces/AAAAN2J52O8).'), - pageSize: z.number().optional().describe('The maximum number of threads to return.'), - pageToken: z.string().optional().describe('The token for the next page of results.'), - } - }, - chatService.listThreads - ); - - server.registerTool( - 'chat.setUpSpace', - { - description: 'Sets up a new Google Chat space with a display name and a list of members.', - inputSchema: { - displayName: z.string().describe('The display name of the space.'), - userNames: z.array(z.string()).describe('The user names of the members to add to the space (e.g. users/12345678)'), - } - }, - chatService.setUpSpace - ); - - - // Gmail tools - server.registerTool( - "gmail.search", - { - description: 'Search for emails in Gmail using query parameters.', - inputSchema: { - query: z.string().optional().describe('Search query (same syntax as Gmail search box, e.g., "from:someone@example.com is:unread").'), - maxResults: z.number().optional().describe(`Maximum number of results to return (default: ${GMAIL_SEARCH_MAX_RESULTS}).`), - pageToken: z.string().optional().describe('Token for the next page of results.'), - labelIds: z.array(z.string()).optional().describe('Filter by label IDs (e.g., ["INBOX", "UNREAD"]).'), - includeSpamTrash: z.boolean().optional().describe('Include messages from SPAM and TRASH (default: false).'), - } - }, - gmailService.search - ); - - server.registerTool( - "gmail.get", - { - description: 'Get the full content of a specific email message.', - inputSchema: { - messageId: z.string().describe('The ID of the message to retrieve.'), - format: z.enum(['minimal', 'full', 'raw', 'metadata']).optional().describe('Format of the message (default: full).'), - } - }, - gmailService.get - ); - - server.registerTool( - "gmail.downloadAttachment", - { - description: 'Downloads an attachment from a Gmail message to a local file.', - inputSchema: { - messageId: z.string().describe('The ID of the message containing the attachment.'), - attachmentId: z.string().describe('The ID of the attachment to download.'), - localPath: z.string().describe('The absolute local path where the attachment should be saved (e.g., "/Users/name/downloads/report.pdf").'), - } - }, - gmailService.downloadAttachment - ); - - server.registerTool( - "gmail.modify", - { - description: `Modify a Gmail message. Supported modifications include: + // 1. Initialize services + if (process.argv.includes('--debug')) { + setLoggingEnabled(true); + } + + const authManager = new AuthManager(SCOPES); + + // 2. Create the server instance + const server = new McpServer({ + name: 'google-workspace-server', + version, + }); + + authManager.setOnStatusUpdate((message) => { + server + .sendLoggingMessage({ + level: 'info', + data: message, + }) + .catch((err) => { + console.error('Failed to send logging message:', err); + }); + }); + + const driveService = new DriveService(authManager); + const docsService = new DocsService(authManager, driveService); + const peopleService = new PeopleService(authManager); + const calendarService = new CalendarService(authManager); + const chatService = new ChatService(authManager); + const gmailService = new GmailService(authManager); + const timeService = new TimeService(); + const slidesService = new SlidesService(authManager); + const sheetsService = new SheetsService(authManager); + + // 3. Register tools directly on the server + server.registerTool( + 'auth.clear', + { + description: + 'Clears the authentication credentials, forcing a re-login on the next request.', + inputSchema: {}, + }, + async () => { + await authManager.clearAuth(); + return { + content: [ + { + type: 'text', + text: 'Authentication credentials cleared. You will be prompted to log in again on the next request.', + }, + ], + }; + }, + ); + + server.registerTool( + 'auth.refreshToken', + { + description: 'Manually triggers the token refresh process.', + inputSchema: {}, + }, + async () => { + await authManager.refreshToken(); + return { + content: [ + { + type: 'text', + text: 'Token refresh process triggered successfully.', + }, + ], + }; + }, + ); + + server.registerTool( + 'docs.create', + { + description: + 'Creates a new Google Doc. Can be blank or with Markdown content.', + inputSchema: { + title: z.string().describe('The title for the new Google Doc.'), + folderName: z + .string() + .optional() + .describe('The name of the folder to create the document in.'), + markdown: z + .string() + .optional() + .describe('The Markdown content to create the document from.'), + }, + }, + docsService.create, + ); + + server.registerTool( + 'docs.insertText', + { + description: 'Inserts text at the beginning of a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + text: z + .string() + .describe('The text to insert at the beginning of the document.'), + tabId: z + .string() + .optional() + .describe( + 'The ID of the tab to modify. If not provided, modifies the first tab.', + ), + }, + }, + docsService.insertText, + ); + + server.registerTool( + 'docs.find', + { + description: + 'Finds Google Docs by searching for a query in their title. Supports pagination.', + inputSchema: { + query: z + .string() + .describe('The text to search for in the document titles.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + pageSize: z + .number() + .optional() + .describe('The maximum number of results to return.'), + }, + }, + docsService.find, + ); + + server.registerTool( + 'drive.findFolder', + { + description: 'Finds a folder by name in Google Drive.', + inputSchema: { + folderName: z.string().describe('The name of the folder to find.'), + }, + }, + driveService.findFolder, + ); + + server.registerTool( + 'drive.createFolder', + { + description: 'Creates a new folder in Google Drive.', + inputSchema: { + name: z.string().trim().min(1).describe('The name of the new folder.'), + parentId: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'The ID of the parent folder. If not provided, creates in the root directory.', + ), + }, + }, + driveService.createFolder, + ); + + server.registerTool( + 'docs.move', + { + description: 'Moves a document to a specified folder.', + inputSchema: { + documentId: z.string().describe('The ID of the document to move.'), + folderName: z.string().describe('The name of the destination folder.'), + }, + }, + docsService.move, + ); + + server.registerTool( + 'docs.getText', + { + description: 'Retrieves the text content of a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to read.'), + tabId: z + .string() + .optional() + .describe( + 'The ID of the tab to read. If not provided, returns all tabs.', + ), + }, + }, + docsService.getText, + ); + + server.registerTool( + 'docs.appendText', + { + description: 'Appends text to the end of a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + text: z.string().describe('The text to append to the document.'), + tabId: z + .string() + .optional() + .describe( + 'The ID of the tab to modify. If not provided, modifies the first tab.', + ), + }, + }, + docsService.appendText, + ); + + server.registerTool( + 'docs.replaceText', + { + description: + 'Replaces all occurrences of a given text with new text in a Google Doc.', + inputSchema: { + documentId: z.string().describe('The ID of the document to modify.'), + findText: z.string().describe('The text to find in the document.'), + replaceText: z + .string() + .describe('The text to replace the found text with.'), + tabId: z + .string() + .optional() + .describe( + 'The ID of the tab to modify. If not provided, replaces in all tabs (legacy behavior).', + ), + }, + }, + docsService.replaceText, + ); + + server.registerTool( + 'docs.extractIdFromUrl', + { + description: 'Extracts the document ID from a Google Workspace URL.', + inputSchema: { + url: z.string().describe('The URL of the Google Workspace document.'), + }, + }, + async (input: { url: string }) => { + const result = extractDocId(input.url); + return { + content: [ + { + type: 'text' as const, + text: result || '', + }, + ], + }; + }, + ); + + // Slides tools + server.registerTool( + 'slides.getText', + { + description: + 'Retrieves the text content of a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation to read.'), + }, + }, + slidesService.getText, + ); + + server.registerTool( + 'slides.find', + { + description: + 'Finds Google Slides presentations by searching for a query. Supports pagination.', + inputSchema: { + query: z.string().describe('The text to search for in presentations.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + pageSize: z + .number() + .optional() + .describe('The maximum number of results to return.'), + }, + }, + slidesService.find, + ); + + server.registerTool( + 'slides.getMetadata', + { + description: 'Gets metadata about a Google Slides presentation.', + inputSchema: { + presentationId: z + .string() + .describe('The ID or URL of the presentation.'), + }, + }, + slidesService.getMetadata, + ); + + // Sheets tools + server.registerTool( + 'sheets.getText', + { + description: 'Retrieves the content of a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z + .string() + .describe('The ID or URL of the spreadsheet to read.'), + format: z + .enum(['text', 'csv', 'json']) + .optional() + .describe('Output format (default: text).'), + }, + }, + sheetsService.getText, + ); + + server.registerTool( + 'sheets.getRange', + { + description: + 'Gets values from a specific range in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe('The A1 notation range to get (e.g., "Sheet1!A1:B10").'), + }, + }, + sheetsService.getRange, + ); + + server.registerTool( + 'sheets.find', + { + description: + 'Finds Google Sheets spreadsheets by searching for a query. Supports pagination.', + inputSchema: { + query: z.string().describe('The text to search for in spreadsheets.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + pageSize: z + .number() + .optional() + .describe('The maximum number of results to return.'), + }, + }, + sheetsService.find, + ); + + server.registerTool( + 'sheets.getMetadata', + { + description: 'Gets metadata about a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + }, + }, + sheetsService.getMetadata, + ); + + server.registerTool( + 'drive.search', + { + description: + 'Searches for files and folders in Google Drive. The query can be a simple search term, a Google Drive URL, or a full query string. For more information on query strings see: https://developers.google.com/drive/api/guides/search-files', + inputSchema: { + query: z + .string() + .optional() + .describe( + 'A simple search term (e.g., "Budget Q3"), a Google Drive URL, or a full query string (e.g., "name contains \'Budget\' and owners in \'user@example.com\'").', + ), + pageSize: z + .number() + .optional() + .describe('The maximum number of results to return.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + corpus: z + .string() + .optional() + .describe('The corpus of files to search (e.g., "user", "domain").'), + unreadOnly: z + .boolean() + .optional() + .describe('Whether to filter for unread files only.'), + sharedWithMe: z + .boolean() + .optional() + .describe('Whether to search for files shared with the user.'), + }, + }, + driveService.search, + ); + + server.registerTool( + 'drive.downloadFile', + { + description: + 'Downloads the content of a file from Google Drive to a local path. Note: Google Docs, Sheets, and Slides require specialized handling.', + inputSchema: { + fileId: z.string().describe('The ID of the file to download.'), + localPath: z + .string() + .describe( + 'The local file path where the content should be saved (e.g., "downloads/report.pdf").', + ), + }, + }, + driveService.downloadFile, + ); + + server.registerTool( + 'calendar.list', + { + description: "Lists all of the user's calendars.", + inputSchema: {}, + }, + calendarService.listCalendars, + ); + + server.registerTool( + 'calendar.createEvent', + { + description: 'Creates a new event in a calendar.', + inputSchema: { + calendarId: z + .string() + .describe('The ID of the calendar to create the event in.'), + summary: z.string().describe('The summary or title of the event.'), + description: z + .string() + .optional() + .describe('The description of the event.'), + start: z.object({ + dateTime: z + .string() + .describe( + 'The start time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T10:30:00Z or 2024-01-15T10:30:00-05:00).', + ), + }), + end: z.object({ + dateTime: z + .string() + .describe( + 'The end time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T11:30:00Z or 2024-01-15T11:30:00-05:00).', + ), + }), + attendees: z + .array(z.string()) + .optional() + .describe('The email addresses of the attendees.'), + }, + }, + calendarService.createEvent, + ); + + server.registerTool( + 'calendar.listEvents', + { + description: 'Lists events from a calendar. Defaults to upcoming events.', + inputSchema: { + calendarId: z + .string() + .describe('The ID of the calendar to list events from.'), + timeMin: z + .string() + .optional() + .describe( + 'The start time for the event search. Defaults to the current time.', + ), + timeMax: z + .string() + .optional() + .describe('The end time for the event search.'), + attendeeResponseStatus: z + .array(z.string()) + .optional() + .describe('The response status of the attendee.'), + }, + }, + calendarService.listEvents, + ); + + server.registerTool( + 'calendar.getEvent', + { + description: 'Gets the details of a specific calendar event.', + inputSchema: { + eventId: z.string().describe('The ID of the event to retrieve.'), + calendarId: z + .string() + .optional() + .describe( + 'The ID of the calendar the event belongs to. Defaults to the primary calendar.', + ), + }, + }, + calendarService.getEvent, + ); + + server.registerTool( + 'calendar.findFreeTime', + { + description: 'Finds a free time slot for multiple people to meet.', + inputSchema: { + attendees: z + .array(z.string()) + .describe('The email addresses of the attendees.'), + timeMin: z + .string() + .describe( + 'The start time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T09:00:00Z or 2024-01-15T09:00:00-05:00).', + ), + timeMax: z + .string() + .describe( + 'The end time for the search in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T18:00:00Z or 2024-01-15T18:00:00-05:00).', + ), + duration: z + .number() + .describe('The duration of the meeting in minutes.'), + }, + }, + calendarService.findFreeTime, + ); + + server.registerTool( + 'calendar.updateEvent', + { + description: 'Updates an existing event in a calendar.', + inputSchema: { + eventId: z.string().describe('The ID of the event to update.'), + calendarId: z + .string() + .optional() + .describe('The ID of the calendar to update the event in.'), + summary: z + .string() + .optional() + .describe('The new summary or title of the event.'), + description: z + .string() + .optional() + .describe('The new description of the event.'), + start: z + .object({ + dateTime: z + .string() + .describe( + 'The new start time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T10:30:00Z or 2024-01-15T10:30:00-05:00).', + ), + }) + .optional(), + end: z + .object({ + dateTime: z + .string() + .describe( + 'The new end time in strict ISO 8601 format with seconds and timezone (e.g., 2024-01-15T11:30:00Z or 2024-01-15T11:30:00-05:00).', + ), + }) + .optional(), + attendees: z + .array(z.string()) + .optional() + .describe('The new list of attendees for the event.'), + }, + }, + calendarService.updateEvent, + ); + + server.registerTool( + 'calendar.respondToEvent', + { + description: + 'Responds to a meeting invitation (accept, decline, or tentative).', + inputSchema: { + eventId: z.string().describe('The ID of the event to respond to.'), + calendarId: z + .string() + .optional() + .describe('The ID of the calendar containing the event.'), + responseStatus: z + .enum(['accepted', 'declined', 'tentative']) + .describe('Your response to the invitation.'), + sendNotification: z + .boolean() + .optional() + .describe( + 'Whether to send a notification to the organizer (default: true).', + ), + responseMessage: z + .string() + .optional() + .describe('Optional message to include with your response.'), + }, + }, + calendarService.respondToEvent, + ); + + server.registerTool( + 'calendar.deleteEvent', + { + description: 'Deletes an event from a calendar.', + inputSchema: { + eventId: z.string().describe('The ID of the event to delete.'), + calendarId: z + .string() + .optional() + .describe( + 'The ID of the calendar to delete the event from. Defaults to the primary calendar.', + ), + }, + }, + calendarService.deleteEvent, + ); + + server.registerTool( + 'chat.listSpaces', + { + description: 'Lists the spaces the user is a member of.', + inputSchema: {}, + }, + chatService.listSpaces, + ); + + server.registerTool( + 'chat.findSpaceByName', + { + description: 'Finds a Google Chat space by its display name.', + inputSchema: { + displayName: z + .string() + .describe('The display name of the space to find.'), + }, + }, + chatService.findSpaceByName, + ); + + server.registerTool( + 'chat.sendMessage', + { + description: 'Sends a message to a Google Chat space.', + inputSchema: { + spaceName: z + .string() + .describe( + 'The name of the space to send the message to (e.g., spaces/AAAAN2J52O8).', + ), + message: z.string().describe('The message to send.'), + threadName: z + .string() + .optional() + .describe( + 'The resource name of the thread to reply to. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"', + ), + }, + }, + chatService.sendMessage, + ); + + server.registerTool( + 'chat.getMessages', + { + description: 'Gets messages from a Google Chat space.', + inputSchema: { + spaceName: z + .string() + .describe( + 'The name of the space to get messages from (e.g., spaces/AAAAN2J52O8).', + ), + threadName: z + .string() + .optional() + .describe( + 'The resource name of the thread to filter messages by. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"', + ), + unreadOnly: z + .boolean() + .optional() + .describe('Whether to return only unread messages.'), + pageSize: z + .number() + .optional() + .describe('The maximum number of messages to return.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + orderBy: z + .string() + .optional() + .describe('The order to list messages in (e.g., "createTime desc").'), + }, + }, + chatService.getMessages, + ); + + server.registerTool( + 'chat.sendDm', + { + description: 'Sends a direct message to a user.', + inputSchema: { + email: z + .string() + .email() + .describe('The email address of the user to send the message to.'), + message: z.string().describe('The message to send.'), + threadName: z + .string() + .optional() + .describe( + 'The resource name of the thread to reply to. Example: "spaces/AAAAVJcnwPE/threads/IAf4cnLqYfg"', + ), + }, + }, + chatService.sendDm, + ); + + server.registerTool( + 'chat.findDmByEmail', + { + description: "Finds a Google Chat DM space by a user's email address.", + inputSchema: { + email: z + .string() + .email() + .describe('The email address of the user to find the DM space with.'), + }, + }, + chatService.findDmByEmail, + ); + + server.registerTool( + 'chat.listThreads', + { + description: + 'Lists threads from a Google Chat space in reverse chronological order.', + inputSchema: { + spaceName: z + .string() + .describe( + 'The name of the space to get threads from (e.g., spaces/AAAAN2J52O8).', + ), + pageSize: z + .number() + .optional() + .describe('The maximum number of threads to return.'), + pageToken: z + .string() + .optional() + .describe('The token for the next page of results.'), + }, + }, + chatService.listThreads, + ); + + server.registerTool( + 'chat.setUpSpace', + { + description: + 'Sets up a new Google Chat space with a display name and a list of members.', + inputSchema: { + displayName: z.string().describe('The display name of the space.'), + userNames: z + .array(z.string()) + .describe( + 'The user names of the members to add to the space (e.g. users/12345678)', + ), + }, + }, + chatService.setUpSpace, + ); + + // Gmail tools + server.registerTool( + 'gmail.search', + { + description: 'Search for emails in Gmail using query parameters.', + inputSchema: { + query: z + .string() + .optional() + .describe( + 'Search query (same syntax as Gmail search box, e.g., "from:someone@example.com is:unread").', + ), + maxResults: z + .number() + .optional() + .describe( + `Maximum number of results to return (default: ${GMAIL_SEARCH_MAX_RESULTS}).`, + ), + pageToken: z + .string() + .optional() + .describe('Token for the next page of results.'), + labelIds: z + .array(z.string()) + .optional() + .describe('Filter by label IDs (e.g., ["INBOX", "UNREAD"]).'), + includeSpamTrash: z + .boolean() + .optional() + .describe('Include messages from SPAM and TRASH (default: false).'), + }, + }, + gmailService.search, + ); + + server.registerTool( + 'gmail.get', + { + description: 'Get the full content of a specific email message.', + inputSchema: { + messageId: z.string().describe('The ID of the message to retrieve.'), + format: z + .enum(['minimal', 'full', 'raw', 'metadata']) + .optional() + .describe('Format of the message (default: full).'), + }, + }, + gmailService.get, + ); + + server.registerTool( + 'gmail.downloadAttachment', + { + description: + 'Downloads an attachment from a Gmail message to a local file.', + inputSchema: { + messageId: z + .string() + .describe('The ID of the message containing the attachment.'), + attachmentId: z + .string() + .describe('The ID of the attachment to download.'), + localPath: z + .string() + .describe( + 'The absolute local path where the attachment should be saved (e.g., "/Users/name/downloads/report.pdf").', + ), + }, + }, + gmailService.downloadAttachment, + ); + + server.registerTool( + 'gmail.modify', + { + description: `Modify a Gmail message. Supported modifications include: - Add labels to a message. - Remove labels from a message. There are a list of system labels that can be modified on a message: @@ -635,124 +933,167 @@ There are a list of system labels that can be modified on a message: - UNREAD: removing UNREAD label marks a message as read. - STARRED: adding STARRED label marks a message as starred. - IMPORTANT: adding IMPORTANT label marks a message as important.`, - inputSchema: { - messageId: z.string().describe('The ID of the message to add labels to and/or remove labels from.'), - addLabelIds: z.array(z.string()).max(100).optional().describe('A list of label IDs to add to the message. Limit to 100 labels.'), - removeLabelIds: z.array(z.string()).max(100).optional().describe('A list of label IDs to remove from the message. Limit to 100 labels.'), - } - }, - gmailService.modify - ); - - server.registerTool( - "gmail.send", - { - description: 'Send an email message.', - inputSchema: emailComposeSchema - }, - gmailService.send - ); - - server.registerTool( - "gmail.createDraft", - { - description: 'Create a draft email message.', - inputSchema: emailComposeSchema - }, - gmailService.createDraft - ); - - server.registerTool( - "gmail.sendDraft", - { - description: 'Send a previously created draft email.', - inputSchema: { - draftId: z.string().describe('The ID of the draft to send.'), - } - }, - gmailService.sendDraft - ); - - server.registerTool( - "gmail.listLabels", - { - description: 'List all Gmail labels in the user\'s mailbox.', - inputSchema: {} - }, - gmailService.listLabels - ); - - // Time tools - server.registerTool( - "time.getCurrentDate", - { - description: 'Gets the current date. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.', - inputSchema: {} - }, - timeService.getCurrentDate - ); - - server.registerTool( - "time.getCurrentTime", - { - description: 'Gets the current time. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.', - inputSchema: {} - }, - timeService.getCurrentTime - ); - - server.registerTool( - "time.getTimeZone", - { - description: 'Gets the local timezone. Note: timezone is also included in getCurrentDate and getCurrentTime responses.', - inputSchema: {} - }, - timeService.getTimeZone - ); - - // People tools - server.registerTool( - "people.getUserProfile", - { - description: 'Gets a user\'s profile information.', - inputSchema: { - userId: z.string().optional().describe('The ID of the user to get profile information for.'), - email: z.string().optional().describe('The email address of the user to get profile information for.'), - name: z.string().optional().describe('The name of the user to get profile information for.'), - } - }, - peopleService.getUserProfile - ); - - server.registerTool( - "people.getMe", - { - description: 'Gets the profile information of the authenticated user.', - inputSchema: {} - }, - peopleService.getMe - ); - - server.registerTool( - "people.getUserRelations", - { - description: 'Gets a user\'s relations (e.g., manager, spouse, assistant, etc.). Common relation types include: manager, assistant, spouse, partner, relative, mother, father, parent, sibling, child, friend, domesticPartner, referredBy. Defaults to the authenticated user if no userId is provided.', - inputSchema: { - userId: z.string().optional().describe('The ID of the user to get relations for (e.g., "110001608645105799644" or "people/110001608645105799644"). Defaults to the authenticated user if not provided.'), - relationType: z.string().optional().describe('The type of relation to filter by (e.g., "manager", "spouse", "assistant"). If not provided, returns all relations.'), - } - }, - peopleService.getUserRelations - ); - - // 4. Connect the transport layer and start listening - const transport = new StdioServerTransport(); - await server.connect(transport); - - console.error("Google Workspace MCP Server is running (registerTool). Listening for requests..."); + inputSchema: { + messageId: z + .string() + .describe( + 'The ID of the message to add labels to and/or remove labels from.', + ), + addLabelIds: z + .array(z.string()) + .max(100) + .optional() + .describe( + 'A list of label IDs to add to the message. Limit to 100 labels.', + ), + removeLabelIds: z + .array(z.string()) + .max(100) + .optional() + .describe( + 'A list of label IDs to remove from the message. Limit to 100 labels.', + ), + }, + }, + gmailService.modify, + ); + + server.registerTool( + 'gmail.send', + { + description: 'Send an email message.', + inputSchema: emailComposeSchema, + }, + gmailService.send, + ); + + server.registerTool( + 'gmail.createDraft', + { + description: 'Create a draft email message.', + inputSchema: emailComposeSchema, + }, + gmailService.createDraft, + ); + + server.registerTool( + 'gmail.sendDraft', + { + description: 'Send a previously created draft email.', + inputSchema: { + draftId: z.string().describe('The ID of the draft to send.'), + }, + }, + gmailService.sendDraft, + ); + + server.registerTool( + 'gmail.listLabels', + { + description: "List all Gmail labels in the user's mailbox.", + inputSchema: {}, + }, + gmailService.listLabels, + ); + + // Time tools + server.registerTool( + 'time.getCurrentDate', + { + description: + 'Gets the current date. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.', + inputSchema: {}, + }, + timeService.getCurrentDate, + ); + + server.registerTool( + 'time.getCurrentTime', + { + description: + 'Gets the current time. Returns both UTC (for calendar/API use) and local time (for display to the user), along with the timezone.', + inputSchema: {}, + }, + timeService.getCurrentTime, + ); + + server.registerTool( + 'time.getTimeZone', + { + description: + 'Gets the local timezone. Note: timezone is also included in getCurrentDate and getCurrentTime responses.', + inputSchema: {}, + }, + timeService.getTimeZone, + ); + + // People tools + server.registerTool( + 'people.getUserProfile', + { + description: "Gets a user's profile information.", + inputSchema: { + userId: z + .string() + .optional() + .describe('The ID of the user to get profile information for.'), + email: z + .string() + .optional() + .describe( + 'The email address of the user to get profile information for.', + ), + name: z + .string() + .optional() + .describe('The name of the user to get profile information for.'), + }, + }, + peopleService.getUserProfile, + ); + + server.registerTool( + 'people.getMe', + { + description: 'Gets the profile information of the authenticated user.', + inputSchema: {}, + }, + peopleService.getMe, + ); + + server.registerTool( + 'people.getUserRelations', + { + description: + "Gets a user's relations (e.g., manager, spouse, assistant, etc.). Common relation types include: manager, assistant, spouse, partner, relative, mother, father, parent, sibling, child, friend, domesticPartner, referredBy. Defaults to the authenticated user if no userId is provided.", + inputSchema: { + userId: z + .string() + .optional() + .describe( + 'The ID of the user to get relations for (e.g., "110001608645105799644" or "people/110001608645105799644"). Defaults to the authenticated user if not provided.', + ), + relationType: z + .string() + .optional() + .describe( + 'The type of relation to filter by (e.g., "manager", "spouse", "assistant"). If not provided, returns all relations.', + ), + }, + }, + peopleService.getUserRelations, + ); + + // 4. Connect the transport layer and start listening + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.error( + 'Google Workspace MCP Server is running (registerTool). Listening for requests...', + ); } -main().catch(error => { - console.error('A critical error occurred:', error); - process.exit(1); +main().catch((error) => { + console.error('A critical error occurred:', error); + process.exit(1); }); diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 50d87fc..0d55201 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -64,26 +64,35 @@ export interface FindFreeTimeInput { export class CalendarService { private primaryCalendarId: string | null = null; - constructor(private authManager: any) { - } + constructor(private authManager: any) {} private createValidationErrorResponse(error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Validation failed'; - let helpMessage = 'Please use strict ISO 8601 format with seconds and timezone. Examples: 2024-01-15T10:30:00Z (UTC) or 2024-01-15T10:30:00-05:00 (EST)'; - - if (error instanceof z.ZodError && error.issues.some(issue => issue.path.includes('attendees') || issue.message.includes('email'))) { + const errorMessage = + error instanceof Error ? error.message : 'Validation failed'; + let helpMessage = + 'Please use strict ISO 8601 format with seconds and timezone. Examples: 2024-01-15T10:30:00Z (UTC) or 2024-01-15T10:30:00-05:00 (EST)'; + + if ( + error instanceof z.ZodError && + error.issues.some( + (issue) => + issue.path.includes('attendees') || issue.message.includes('email'), + ) + ) { helpMessage = 'Please ensure all attendee emails are in a valid format.'; } return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'Invalid input format', - details: errorMessage, - help: helpMessage - }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'Invalid input format', + details: errorMessage, + help: helpMessage, + }), + }, + ], }; } @@ -102,7 +111,7 @@ export class CalendarService { logToFile('Getting primary calendar ID...'); const calendar = await this.getCalendar(); const res = await calendar.calendarList.list(); - const primaryCalendar = res.data.items?.find(c => c.primary); + const primaryCalendar = res.data.items?.find((c) => c.primary); if (primaryCalendar && primaryCalendar.id) { logToFile(`Found primary calendar: ${primaryCalendar.id}`); this.primaryCalendarId = primaryCalendar.id; @@ -121,28 +130,37 @@ export class CalendarService { const res = await calendar.calendarList.list(); logToFile(`Found ${res.data.items?.length} calendars.`); const calendars = res.data.items || []; - logToFile(`Returning calendar data: ${JSON.stringify(calendars.map(c => ({ id: c?.id, summary: c?.summary })))}`); + logToFile( + `Returning calendar data: ${JSON.stringify(calendars.map((c) => ({ id: c?.id, summary: c?.summary })))}`, + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify(calendars.map(c => ({ id: c?.id, summary: c?.summary }))) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify( + calendars.map((c) => ({ id: c?.id, summary: c?.summary })), + ), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.list: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; createEvent = async (input: CreateEventInput) => { const { calendarId, summary, description, start, end, attendees } = input; - + // Validate datetime formats try { iso8601DateTimeSchema.parse(start.dateTime); @@ -153,8 +171,8 @@ export class CalendarService { } catch (error) { return this.createValidationErrorResponse(error); } - - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); + + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Creating event in calendar: ${finalCalendarId}`); logToFile(`Event summary: ${summary}`); if (description) logToFile(`Event description: ${description}`); @@ -167,7 +185,7 @@ export class CalendarService { description, start, end, - attendees: attendees?.map(email => ({ email })) + attendees: attendees?.map((email) => ({ email })), }; const calendar = await this.getCalendar(); const res = await calendar.events.insert({ @@ -176,26 +194,35 @@ export class CalendarService { }); logToFile(`Successfully created event: ${res.data.id}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify(res.data) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.createEvent: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; listEvents = async (input: ListEventsInput) => { - const { calendarId, timeMin = (new Date()).toISOString(), attendeeResponseStatus = ['accepted', 'tentative', 'needsAction'] } = input; - + const { + calendarId, + timeMin = new Date().toISOString(), + attendeeResponseStatus = ['accepted', 'tentative', 'needsAction'], + } = input; + let timeMax = input.timeMax; if (!timeMax) { const thirtyDaysFromNow = new Date(); @@ -203,7 +230,7 @@ export class CalendarService { timeMax = thirtyDaysFromNow.toISOString(); } - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Listing events for calendar: ${finalCalendarId}`); try { const calendar = await this.getCalendar(); @@ -212,47 +239,55 @@ export class CalendarService { timeMin, timeMax, singleEvents: true, - fields: 'items(id,summary,start,end,description,htmlLink,attendees,status)', + fields: + 'items(id,summary,start,end,description,htmlLink,attendees,status)', }); const events = res.data.items - ?.filter(event => event.status !== 'cancelled' && !!event.summary) - .filter(event => { + ?.filter((event) => event.status !== 'cancelled' && !!event.summary) + .filter((event) => { if (!event.attendees || event.attendees.length === 0) { return true; // No attendees, so we can't filter, include it } if (event.attendees.length === 1 && event.attendees[0].self) { return true; // I'm the only one, always include it } - const self = event.attendees.find(a => a.self); + const self = event.attendees.find((a) => a.self); if (!self) { return true; // We are not an attendee, include it } - return attendeeResponseStatus.includes(self.responseStatus || 'needsAction'); + return attendeeResponseStatus.includes( + self.responseStatus || 'needsAction', + ); }); logToFile(`Found ${events?.length} events after filtering.`); return { - content: [{ - type: "text" as const, - text: JSON.stringify(events) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify(events), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.listEvents: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; getEvent = async (input: GetEventInput) => { const { eventId, calendarId } = input; - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Getting event ${eventId} from calendar: ${finalCalendarId}`); try { const calendar = await this.getCalendar(); @@ -262,26 +297,32 @@ export class CalendarService { }); logToFile(`Successfully retrieved event: ${res.data.id}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify(res.data) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], }; } catch (error) { - const errorMessage = (error as any).response?.data?.error?.message || (error instanceof Error ? error.message : String(error)); + const errorMessage = + (error as any).response?.data?.error?.message || + (error instanceof Error ? error.message : String(error)); logToFile(`Error during calendar.getEvent: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; deleteEvent = async (input: DeleteEventInput) => { const { eventId, calendarId } = input; - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Deleting event ${eventId} from calendar: ${finalCalendarId}`); try { @@ -293,25 +334,34 @@ export class CalendarService { logToFile(`Successfully deleted event: ${eventId}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ message: `Successfully deleted event ${eventId}` }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + message: `Successfully deleted event ${eventId}`, + }), + }, + ], }; } catch (error) { - const errorMessage = (error as any).response?.data?.error?.message || (error instanceof Error ? error.message : String(error)); + const errorMessage = + (error as any).response?.data?.error?.message || + (error instanceof Error ? error.message : String(error)); logToFile(`Error during calendar.deleteEvent: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; updateEvent = async (input: UpdateEventInput) => { - const { eventId, calendarId, summary, description, start, end, attendees } = input; + const { eventId, calendarId, summary, description, start, end, attendees } = + input; // Validate datetime formats if provided try { @@ -328,19 +378,20 @@ export class CalendarService { return this.createValidationErrorResponse(error); } - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`); try { const calendar = await this.getCalendar(); - + // Build request body with only the fields to update (patch semantics) const requestBody: calendar_v3.Schema$Event = {}; if (summary !== undefined) requestBody.summary = summary; if (description !== undefined) requestBody.description = description; if (start) requestBody.start = start; if (end) requestBody.end = end; - if (attendees) requestBody.attendees = attendees.map(email => ({ email })); + if (attendees) + requestBody.attendees = attendees.map((email) => ({ email })); const res = await calendar.events.update({ calendarId: finalCalendarId, @@ -350,28 +401,41 @@ export class CalendarService { logToFile(`Successfully updated event: ${res.data.id}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify(res.data) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.updateEvent: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; respondToEvent = async (input: RespondToEventInput) => { - const { eventId, calendarId, responseStatus, sendNotification = true, responseMessage } = input; - const finalCalendarId = calendarId || await this.getPrimaryCalendarId(); - - logToFile(`Responding to event ${eventId} in calendar: ${finalCalendarId} with status: ${responseStatus}`); + const { + eventId, + calendarId, + responseStatus, + sendNotification = true, + responseMessage, + } = input; + const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); + + logToFile( + `Responding to event ${eventId} in calendar: ${finalCalendarId} with status: ${responseStatus}`, + ); if (responseMessage) { logToFile(`Response message: ${responseMessage}`); } @@ -388,22 +452,28 @@ export class CalendarService { if (!event.data.attendees || event.data.attendees.length === 0) { logToFile('Event has no attendees'); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: 'Event has no attendees' }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: 'Event has no attendees' }), + }, + ], }; } // Find the current user's attendee entry - const selfAttendee = event.data.attendees.find(a => a.self === true); + const selfAttendee = event.data.attendees.find((a) => a.self === true); if (!selfAttendee) { logToFile('User is not an attendee of this event'); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: 'You are not an attendee of this event' }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'You are not an attendee of this event', + }), + }, + ], }; } @@ -419,34 +489,41 @@ export class CalendarService { eventId, sendNotifications: sendNotification, requestBody: { - attendees: event.data.attendees - } + attendees: event.data.attendees, + }, }); - logToFile(`Successfully responded to event: ${res.data.id} with status: ${responseStatus}`); + logToFile( + `Successfully responded to event: ${res.data.id} with status: ${responseStatus}`, + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - eventId: res.data.id, - summary: res.data.summary, - responseStatus, - message: `Successfully ${responseStatus} the meeting invitation${responseMessage ? ' with message' : ''}` - }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + eventId: res.data.id, + summary: res.data.summary, + responseStatus, + message: `Successfully ${responseStatus} the meeting invitation${responseMessage ? ' with message' : ''}`, + }), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.respondToEvent: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; findFreeTime = async (input: FindFreeTimeInput) => { const { attendees, timeMin, timeMax, duration } = input; @@ -466,13 +543,15 @@ export class CalendarService { try { const calendar = await this.getCalendar(); - const items = await Promise.all(attendees.map(async (email) => { - if (email === 'me') { - const primaryId = await this.getPrimaryCalendarId(); - return { id: primaryId }; - } - return { id: email }; - })); + const items = await Promise.all( + attendees.map(async (email) => { + if (email === 'me') { + const primaryId = await this.getPrimaryCalendarId(); + return { id: primaryId }; + } + return { id: email }; + }), + ); const res = await calendar.freebusy.query({ requestBody: { @@ -482,23 +561,34 @@ export class CalendarService { }, }); - const busyTimes = Object.values(res.data.calendars || {}).flatMap(cal => cal.busy || []); + const busyTimes = Object.values(res.data.calendars || {}).flatMap( + (cal) => cal.busy || [], + ); if (busyTimes.length === 0) { - logToFile('No busy times found, returning the start of the time range.'); + logToFile( + 'No busy times found, returning the start of the time range.', + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ start: timeMin, end: new Date(new Date(timeMin).getTime() + duration * 60000).toISOString() }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + start: timeMin, + end: new Date( + new Date(timeMin).getTime() + duration * 60000, + ).toISOString(), + }), + }, + ], }; } // Sort and merge overlapping busy intervals for better performance const sortedBusyTimes = busyTimes - .filter(busy => busy.start && busy.end) - .map(busy => ({ + .filter((busy) => busy.start && busy.end) + .map((busy) => ({ start: new Date(busy.start!).getTime(), - end: new Date(busy.end!).getTime() + end: new Date(busy.end!).getTime(), })) .sort((a, b) => a.start - b.start); @@ -524,12 +614,19 @@ export class CalendarService { // If no busy times, return the start of the range if (mergedBusyTimes.length === 0) { const slotEnd = new Date(startTime + durationMs); - logToFile(`No busy times, found free time: ${timeMin} - ${slotEnd.toISOString()}`); + logToFile( + `No busy times, found free time: ${timeMin} - ${slotEnd.toISOString()}`, + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ start: timeMin, end: slotEnd.toISOString() }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + start: timeMin, + end: slotEnd.toISOString(), + }), + }, + ], }; } @@ -538,10 +635,15 @@ export class CalendarService { const slotEnd = new Date(startTime + durationMs); logToFile(`Found free time: ${timeMin} - ${slotEnd.toISOString()}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ start: timeMin, end: slotEnd.toISOString() }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + start: timeMin, + end: slotEnd.toISOString(), + }), + }, + ], }; } @@ -549,16 +651,23 @@ export class CalendarService { for (let i = 0; i < mergedBusyTimes.length - 1; i++) { const gapStart = mergedBusyTimes[i].end; const gapEnd = mergedBusyTimes[i + 1].start; - + if (gapEnd - gapStart >= durationMs) { const slotStart = new Date(gapStart); const slotEnd = new Date(gapStart + durationMs); - logToFile(`Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`); + logToFile( + `Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`, + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ start: slotStart.toISOString(), end: slotEnd.toISOString() }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + start: slotStart.toISOString(), + end: slotEnd.toISOString(), + }), + }, + ], }; } } @@ -568,31 +677,43 @@ export class CalendarService { if (lastBusyEnd + durationMs <= endTime) { const slotStart = new Date(lastBusyEnd); const slotEnd = new Date(lastBusyEnd + durationMs); - logToFile(`Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`); + logToFile( + `Found free time: ${slotStart.toISOString()} - ${slotEnd.toISOString()}`, + ); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ start: slotStart.toISOString(), end: slotEnd.toISOString() }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + start: slotStart.toISOString(), + end: slotEnd.toISOString(), + }), + }, + ], }; } logToFile('No available free time found'); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: 'No available free time found' }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: 'No available free time found' }), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error during calendar.findFreeTime: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } - } + }; } diff --git a/workspace-server/src/services/ChatService.ts b/workspace-server/src/services/ChatService.ts index 9f4b9b9..c93a00d 100644 --- a/workspace-server/src/services/ChatService.ts +++ b/workspace-server/src/services/ChatService.ts @@ -19,425 +19,525 @@ interface GetMessagesParams { } export class ChatService { - constructor(private authManager: AuthManager) { + constructor(private authManager: AuthManager) {} + + private async getChatClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.chat({ version: 'v1', ...options }); + } + + private async getPeopleClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.people({ version: 'v1', ...options }); + } + + private async _setupDmSpace(email: string): Promise { + const person = { + name: `users/${email}`, + type: 'HUMAN', + }; + + const chat = await this.getChatClient(); + const setupResponse = await chat.spaces.setup({ + requestBody: { + space: { + spaceType: 'DIRECT_MESSAGE', + }, + memberships: [ + { + member: person, + }, + ], + }, + }); + + const space = setupResponse.data; + if (!space) { + throw new Error('Could not find or create a DM space.'); } - - private async getChatClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.chat({ version: 'v1', ...options }); + return space; + } + + public listSpaces = async () => { + logToFile('Listing chat spaces'); + try { + const chat = await this.getChatClient(); + const res = await chat.spaces.list({}); + const spaces = res.data.spaces || []; + logToFile(`Successfully listed ${spaces.length} chat spaces.`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(spaces), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.listSpaces: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while listing chat spaces.', + details: errorMessage, + }), + }, + ], + }; } - - private async getPeopleClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.people({ version: 'v1', ...options }); + }; + + public sendMessage = async ({ + spaceName, + message, + threadName, + }: { + spaceName: string; + message: string; + threadName?: string; + }) => { + logToFile( + `Sending message to space: ${spaceName}${threadName ? ` in thread: ${threadName}` : ''}`, + ); + try { + const chat = await this.getChatClient(); + const response = await chat.spaces.messages.create({ + parent: spaceName, + messageReplyOption: threadName + ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD' + : undefined, + requestBody: { + text: message, + thread: threadName ? { name: threadName } : undefined, + }, + }); + logToFile(`Successfully sent message to space: ${spaceName}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(response.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.sendMessage: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while sending the message.', + details: errorMessage, + }), + }, + ], + }; } - - private async _setupDmSpace(email: string): Promise { - const person = { - name: `users/${email}`, - type: 'HUMAN', + }; + + public findSpaceByName = async ({ displayName }: { displayName: string }) => { + logToFile(`Finding space with display name: ${displayName}`); + try { + const chat = await this.getChatClient(); + // The Chat API's spaces.list method does not support filtering by + // displayName on the server. We must fetch all spaces and filter locally. + let pageToken: string | undefined = undefined; + let allSpaces: chat_v1.Schema$Space[] = []; + + do { + const res: any = await chat.spaces.list({ pageToken }); + const spaces = res.data.spaces || []; + allSpaces = allSpaces.concat(spaces); + pageToken = res.data.nextPageToken || undefined; + } while (pageToken); + + const foundSpaces = allSpaces.filter( + (space) => space.displayName === displayName, + ); + + if (foundSpaces.length > 0) { + logToFile( + `Found ${foundSpaces.length} space(s) with display name: ${displayName}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(foundSpaces), + }, + ], }; - - const chat = await this.getChatClient(); - const setupResponse = await chat.spaces.setup({ - requestBody: { - space: { - spaceType: 'DIRECT_MESSAGE', - }, - memberships: [ - { - member: person, - }, - ], + } else { + logToFile(`No space found with display name: ${displayName}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: `No space found with display name: ${displayName}`, + }), }, - }); - - const space = setupResponse.data; - if (!space) { - throw new Error('Could not find or create a DM space.'); - } - return space; + ], + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.findSpaceByName: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while finding the space.', + details: errorMessage, + }), + }, + ], + }; } + }; + + public getMessages = async ({ + spaceName, + unreadOnly, + pageSize, + pageToken, + orderBy, + threadName, + }: GetMessagesParams) => { + logToFile(`Listing messages for space: ${spaceName}`); + try { + const chat = await this.getChatClient(); + const filters: string[] = []; + + if (threadName) { + filters.push(`thread.name = "${threadName}"`); + } + + if (unreadOnly) { + const people = await this.getPeopleClient(); + const person = await people.people.get({ + resourceName: 'people/me', + personFields: 'metadata', + }); - public listSpaces = async () => { - logToFile('Listing chat spaces'); - try { - const chat = await this.getChatClient(); - const res = await chat.spaces.list({}); - const spaces = res.data.spaces || []; - logToFile(`Successfully listed ${spaces.length} chat spaces.`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(spaces) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.listSpaces: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while listing chat spaces.', - details: errorMessage - }) - }] - }; - } - } + const userId = person.data.metadata?.sources?.find( + (s) => s.type === 'PROFILE', + )?.id; - public sendMessage = async ({ spaceName, message, threadName }: { spaceName: string, message: string, threadName?: string }) => { - logToFile(`Sending message to space: ${spaceName}${threadName ? ` in thread: ${threadName}` : ''}`); - try { - const chat = await this.getChatClient(); - const response = await chat.spaces.messages.create({ - parent: spaceName, - messageReplyOption: threadName ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD' : undefined, - requestBody: { - text: message, - thread: threadName ? { name: threadName } : undefined, - }, - }); - logToFile(`Successfully sent message to space: ${spaceName}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(response.data) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.sendMessage: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while sending the message.', - details: errorMessage - }) - }] - }; + if (!userId) { + throw new Error('Could not determine user ID.'); } - } + const userMemberName = `users/${userId}`; - public findSpaceByName = async ({ displayName }: { displayName: string }) => { - logToFile(`Finding space with display name: ${displayName}`); - try { - const chat = await this.getChatClient(); - // The Chat API's spaces.list method does not support filtering by - // displayName on the server. We must fetch all spaces and filter locally. - let pageToken: string | undefined = undefined; - let allSpaces: chat_v1.Schema$Space[] = []; - - do { - const res: any = await chat.spaces.list({ pageToken }); - const spaces = res.data.spaces || []; - allSpaces = allSpaces.concat(spaces); - pageToken = res.data.nextPageToken || undefined; - } while (pageToken); - - const foundSpaces = allSpaces.filter(space => space.displayName === displayName); - - if (foundSpaces.length > 0) { - logToFile(`Found ${foundSpaces.length} space(s) with display name: ${displayName}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(foundSpaces) - }] - }; - } else { - logToFile(`No space found with display name: ${displayName}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: `No space found with display name: ${displayName}` - }) - }] - }; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.findSpaceByName: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while finding the space.', - details: errorMessage - }) - }] - }; - } - } - - -public getMessages = async ({ spaceName, unreadOnly, pageSize, pageToken, orderBy, threadName }: GetMessagesParams) => { - logToFile(`Listing messages for space: ${spaceName}`); - try { - const chat = await this.getChatClient(); - const filters: string[] = []; - - if (threadName) { - filters.push(`thread.name = "${threadName}"`); - } - - if (unreadOnly) { - const people = await this.getPeopleClient(); - const person = await people.people.get({ - resourceName: 'people/me', - personFields: 'metadata', - }); - - const userId = person.data.metadata?.sources?.find(s => s.type === 'PROFILE')?.id; - - if (!userId) { - throw new Error('Could not determine user ID.'); - } - const userMemberName = `users/${userId}`; - - const membersRes = await chat.spaces.members.list({ - parent: spaceName, - }); - // Type assertion needed due to incomplete type definitions - const memberships = (membersRes.data as any).memberships || []; - const currentUserMember = memberships.find((m: any) => m.member?.name === userMemberName); - - const lastReadTime = currentUserMember?.lastReadTime; - - if (lastReadTime) { - filters.push(`createTime > "${lastReadTime}"`); - } else { - logToFile(`No last read time found for user in space: ${spaceName}`); - } - } - - const filter = filters.join(' AND '); - - const res = await chat.spaces.messages.list({ - parent: spaceName, - filter: filter ? filter : undefined, - pageSize, - pageToken, - orderBy, - }); - - const messages = res.data.messages || []; - const logMessage = unreadOnly - ? `Successfully listed ${messages.length} unread messages for space: ${spaceName}` - : `Successfully listed ${messages.length} messages for space: ${spaceName}`; - logToFile(logMessage); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ messages, nextPageToken: res.data.nextPageToken }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.getMessages: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while listing messages.', - details: errorMessage - }) - }] - }; + const membersRes = await chat.spaces.members.list({ + parent: spaceName, + }); + // Type assertion needed due to incomplete type definitions + const memberships = (membersRes.data as any).memberships || []; + const currentUserMember = memberships.find( + (m: any) => m.member?.name === userMemberName, + ); + + const lastReadTime = currentUserMember?.lastReadTime; + + if (lastReadTime) { + filters.push(`createTime > "${lastReadTime}"`); + } else { + logToFile(`No last read time found for user in space: ${spaceName}`); } + } + + const filter = filters.join(' AND '); + + const res = await chat.spaces.messages.list({ + parent: spaceName, + filter: filter ? filter : undefined, + pageSize, + pageToken, + orderBy, + }); + + const messages = res.data.messages || []; + const logMessage = unreadOnly + ? `Successfully listed ${messages.length} unread messages for space: ${spaceName}` + : `Successfully listed ${messages.length} messages for space: ${spaceName}`; + logToFile(logMessage); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + messages, + nextPageToken: res.data.nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.getMessages: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while listing messages.', + details: errorMessage, + }), + }, + ], + }; } - - - public sendDm = async ({ email, message, threadName }: { email: string, message: string, threadName?: string }) => { - logToFile(`chat.sendDm called with: email=${email}, message=${message}${threadName ? `, threadName=${threadName}` : ''}`); - try { - const space = await this._setupDmSpace(email); - const spaceName = space.name; - - if (!spaceName) { - throw new Error('Could not determine the space name for the DM.'); - } - - const chat = await this.getChatClient(); - // Send the message to the DM space. - const messageResponse = await chat.spaces.messages.create({ - parent: spaceName, - messageReplyOption: threadName ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD' : undefined, - requestBody: { - text: message, - thread: threadName ? { name: threadName } : undefined, - }, - }); - - logToFile(`Successfully sent DM to: ${email}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(messageResponse.data) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.sendDm: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while sending the DM.', - details: errorMessage - }) - }] - }; - } + }; + + public sendDm = async ({ + email, + message, + threadName, + }: { + email: string; + message: string; + threadName?: string; + }) => { + logToFile( + `chat.sendDm called with: email=${email}, message=${message}${threadName ? `, threadName=${threadName}` : ''}`, + ); + try { + const space = await this._setupDmSpace(email); + const spaceName = space.name; + + if (!spaceName) { + throw new Error('Could not determine the space name for the DM.'); + } + + const chat = await this.getChatClient(); + // Send the message to the DM space. + const messageResponse = await chat.spaces.messages.create({ + parent: spaceName, + messageReplyOption: threadName + ? 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD' + : undefined, + requestBody: { + text: message, + thread: threadName ? { name: threadName } : undefined, + }, + }); + + logToFile(`Successfully sent DM to: ${email}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(messageResponse.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.sendDm: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while sending the DM.', + details: errorMessage, + }), + }, + ], + }; } - - public findDmByEmail = async ({ email }: { email: string }) => { - logToFile(`Finding DM space with user: ${email}`); - try { - const space = await this._setupDmSpace(email); - logToFile(`Found or created DM space: ${space.name}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(space) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.findDmByEmail: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while finding the DM space.', - details: errorMessage - }) - }] - }; - } + }; + + public findDmByEmail = async ({ email }: { email: string }) => { + logToFile(`Finding DM space with user: ${email}`); + try { + const space = await this._setupDmSpace(email); + logToFile(`Found or created DM space: ${space.name}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(space), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.findDmByEmail: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while finding the DM space.', + details: errorMessage, + }), + }, + ], + }; } - - public listThreads = async ({ spaceName, pageSize, pageToken }: { spaceName: string, pageSize?: number, pageToken?: string }) => { - logToFile(`Listing threads for space: ${spaceName}`); - try { - const chat = await this.getChatClient(); - const res = await chat.spaces.messages.list({ - parent: spaceName, - pageSize, - pageToken, - orderBy: 'createTime desc', - }); - - const messages = res.data.messages || []; - const threads: chat_v1.Schema$Message[] = []; - const threadIds = new Set(); - - for (const message of messages) { - if (message.thread?.name && !threadIds.has(message.thread.name)) { - threads.push(message); - threadIds.add(message.thread.name); - } - } - - logToFile(`Successfully listed ${threads.length} threads for space: ${spaceName}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ threads, nextPageToken: res.data.nextPageToken }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.listThreads: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while listing threads.', - details: errorMessage - }) - }] - }; + }; + + public listThreads = async ({ + spaceName, + pageSize, + pageToken, + }: { + spaceName: string; + pageSize?: number; + pageToken?: string; + }) => { + logToFile(`Listing threads for space: ${spaceName}`); + try { + const chat = await this.getChatClient(); + const res = await chat.spaces.messages.list({ + parent: spaceName, + pageSize, + pageToken, + orderBy: 'createTime desc', + }); + + const messages = res.data.messages || []; + const threads: chat_v1.Schema$Message[] = []; + const threadIds = new Set(); + + for (const message of messages) { + if (message.thread?.name && !threadIds.has(message.thread.name)) { + threads.push(message); + threadIds.add(message.thread.name); } + } + + logToFile( + `Successfully listed ${threads.length} threads for space: ${spaceName}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + threads, + nextPageToken: res.data.nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.listThreads: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while listing threads.', + details: errorMessage, + }), + }, + ], + }; } - - public setUpSpace = async ({ displayName, userNames }: { displayName: string, userNames: string[] }) => { - logToFile(`Creating space with display name: ${displayName}`); - try { - const memberships = userNames.map(userName => ({ - member: { - name: userName, - type: 'HUMAN', - }, - })); - - const chat = await this.getChatClient(); - const response = await chat.spaces.setup({ - requestBody: { - space: { - spaceType: 'SPACE', - displayName, - }, - memberships: memberships, - }, - }); - logToFile(`Successfully created space: ${response.data.name}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(response.data) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during chat.createSpace: ${errorMessage}`); - if (error instanceof Error && error.stack) { - logToFile(`Stack trace: ${error.stack}`); - } - logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'An error occurred while creating the space.', - details: errorMessage - }) - }] - }; - } + }; + + public setUpSpace = async ({ + displayName, + userNames, + }: { + displayName: string; + userNames: string[]; + }) => { + logToFile(`Creating space with display name: ${displayName}`); + try { + const memberships = userNames.map((userName) => ({ + member: { + name: userName, + type: 'HUMAN', + }, + })); + + const chat = await this.getChatClient(); + const response = await chat.spaces.setup({ + requestBody: { + space: { + spaceType: 'SPACE', + displayName, + }, + memberships: memberships, + }, + }); + logToFile(`Successfully created space: ${response.data.name}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(response.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during chat.createSpace: ${errorMessage}`); + if (error instanceof Error && error.stack) { + logToFile(`Stack trace: ${error.stack}`); + } + logToFile(`Full error object: ${JSON.stringify(error, null, 2)}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'An error occurred while creating the space.', + details: errorMessage, + }), + }, + ], + }; } + }; } diff --git a/workspace-server/src/services/DocsService.ts b/workspace-server/src/services/DocsService.ts index 2a166d8..b62b7df 100644 --- a/workspace-server/src/services/DocsService.ts +++ b/workspace-server/src/services/DocsService.ts @@ -16,659 +16,807 @@ import { JSDOM } from 'jsdom'; import { gaxiosOptions, mediaUploadOptions } from '../utils/GaxiosConfig'; import { buildDriveSearchQuery, MIME_TYPES } from '../utils/DriveQueryBuilder'; import { extractDocumentId as validateAndExtractDocId } from '../utils/validation'; -import { parseMarkdownToDocsRequests, processMarkdownLineBreaks } from '../utils/markdownToDocsRequests'; +import { + parseMarkdownToDocsRequests, + processMarkdownLineBreaks, +} from '../utils/markdownToDocsRequests'; export class DocsService { - private purify: ReturnType; - - constructor(private authManager: AuthManager, private driveService: DriveService) { - const window = new JSDOM('').window; - this.purify = createDOMPurify(window as any); + private purify: ReturnType; + + constructor( + private authManager: AuthManager, + private driveService: DriveService, + ) { + const window = new JSDOM('').window; + this.purify = createDOMPurify(window as any); + } + + private async getDocsClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.docs({ version: 'v1', ...options }); + } + + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + + public create = async ({ + title, + folderName, + markdown, + }: { + title: string; + folderName?: string; + markdown?: string; + }) => { + logToFile( + `[DocsService] Starting create with title: ${title}, folderName: ${folderName}, markdown: ${markdown ? 'true' : 'false'}`, + ); + try { + const docInfo = await (async (): Promise<{ + documentId: string; + title: string; + }> => { + if (markdown) { + logToFile('[DocsService] Creating doc with markdown'); + const unsafeHtml = await marked.parse(markdown); + const html = this.purify.sanitize(unsafeHtml); + + const fileMetadata = { + name: title, + mimeType: 'application/vnd.google-apps.document', + }; + + const media = { + mimeType: 'text/html', + body: Readable.from(html), + }; + + logToFile('[DocsService] Calling drive.files.create'); + const drive = await this.getDriveClient(); + const file = await drive.files.create( + { + requestBody: fileMetadata, + media: media, + fields: 'id, name', + }, + mediaUploadOptions, + ); + logToFile('[DocsService] drive.files.create finished'); + return { documentId: file.data.id!, title: file.data.name! }; + } else { + logToFile('[DocsService] Creating blank doc'); + logToFile('[DocsService] Calling docs.documents.create'); + const docs = await this.getDocsClient(); + const doc = await docs.documents.create({ + requestBody: { title }, + }); + logToFile('[DocsService] docs.documents.create finished'); + return { documentId: doc.data.documentId!, title: doc.data.title! }; + } + })(); + + if (folderName) { + logToFile(`[DocsService] Moving doc to folder: ${folderName}`); + await this._moveFileToFolder(docInfo.documentId, folderName); + logToFile(`[DocsService] Finished moving doc to folder: ${folderName}`); + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: docInfo.documentId, + title: docInfo.title, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during docs.create: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - private async getDocsClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.docs({ version: 'v1', ...options }); + }; + + public insertText = async ({ + documentId, + text, + tabId, + }: { + documentId: string; + text: string; + tabId?: string; + }) => { + logToFile( + `[DocsService] Starting insertText for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + + // Parse markdown and generate formatting requests + const { plainText, formattingRequests } = parseMarkdownToDocsRequests( + text, + 1, + ); + const processedText = processMarkdownLineBreaks(plainText); + + // Build batch update requests + const requests: docs_v1.Schema$Request[] = [ + { + insertText: { + location: { + index: 1, + tabId: tabId, + }, + text: processedText, + }, + }, + ]; + + // Add formatting requests if any + if (formattingRequests.length > 0) { + requests.push( + ...this._addTabIdToFormattingRequests(formattingRequests, tabId), + ); + } + + const docs = await this.getDocsClient(); + const res = await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests, + }, + }); + + logToFile(`[DocsService] Finished insertText for document: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + documentId: res.data.documentId!, + writeControl: res.data.writeControl!, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.insertText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - private async getDriveClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.drive({ version: 'v3', ...options }); + }; + + public find = async ({ + query, + pageToken, + pageSize = 10, + }: { + query: string; + pageToken?: string; + pageSize?: number; + }) => { + logToFile(`Searching for documents with query: ${query}`); + if (pageToken) { + logToFile(`Using pageToken: ${pageToken}`); } - - public create = async ({ title, folderName, markdown }: { title: string, folderName?: string, markdown?: string }) => { - logToFile(`[DocsService] Starting create with title: ${title}, folderName: ${folderName}, markdown: ${markdown ? 'true' : 'false'}`); - try { - const docInfo = await (async (): Promise<{ documentId: string; title: string; }> => { - if (markdown) { - logToFile('[DocsService] Creating doc with markdown'); - const unsafeHtml = await marked.parse(markdown); - const html = this.purify.sanitize(unsafeHtml); - - const fileMetadata = { - name: title, - mimeType: 'application/vnd.google-apps.document', - }; - - const media = { - mimeType: 'text/html', - body: Readable.from(html), - }; - - logToFile('[DocsService] Calling drive.files.create'); - const drive = await this.getDriveClient(); - const file = await drive.files.create({ - requestBody: fileMetadata, - media: media, - fields: 'id, name', - }, mediaUploadOptions); - logToFile('[DocsService] drive.files.create finished'); - return { documentId: file.data.id!, title: file.data.name! }; - } else { - logToFile('[DocsService] Creating blank doc'); - logToFile('[DocsService] Calling docs.documents.create'); - const docs = await this.getDocsClient(); - const doc = await docs.documents.create({ - requestBody: { title }, - }); - logToFile('[DocsService] docs.documents.create finished'); - return { documentId: doc.data.documentId!, title: doc.data.title! }; - } - })(); - - if (folderName) { - logToFile(`[DocsService] Moving doc to folder: ${folderName}`); - await this._moveFileToFolder(docInfo.documentId, folderName); - logToFile(`[DocsService] Finished moving doc to folder: ${folderName}`); - } - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - documentId: docInfo.documentId, - title: docInfo.title, - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during docs.create: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + if (pageSize) { + logToFile(`Using pageSize: ${pageSize}`); } - - public insertText = async ({ documentId, text, tabId }: { documentId: string, text: string, tabId?: string }) => { - logToFile(`[DocsService] Starting insertText for document: ${documentId}, tabId: ${tabId}`); - try { - const id = extractDocId(documentId) || documentId; - - // Parse markdown and generate formatting requests - const { plainText, formattingRequests } = parseMarkdownToDocsRequests(text, 1); - const processedText = processMarkdownLineBreaks(plainText); - - // Build batch update requests - const requests: docs_v1.Schema$Request[] = [ - { - insertText: { - location: { - index: 1, - tabId: tabId - }, - text: processedText, - }, - } - ]; - - // Add formatting requests if any - if (formattingRequests.length > 0) { - requests.push(...this._addTabIdToFormattingRequests(formattingRequests, tabId)); - } - - const docs = await this.getDocsClient(); - const res = await docs.documents.batchUpdate({ - documentId: id, - requestBody: { - requests, - }, - }); - - logToFile(`[DocsService] Finished insertText for document: ${id}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - documentId: res.data.documentId!, - writeControl: res.data.writeControl!, - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[DocsService] Error during docs.insertText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + try { + const q = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, query); + logToFile(`Executing Drive API query: ${q}`); + + const drive = await this.getDriveClient(); + const res = await drive.files.list({ + pageSize: pageSize, + fields: 'nextPageToken, files(id, name)', + q: q, + pageToken: pageToken, + }); + + const files = res.data.files || []; + const nextPageToken = res.data.nextPageToken; + + logToFile(`Found ${files.length} files.`); + if (nextPageToken) { + logToFile(`Next page token: ${nextPageToken}`); + } + logToFile(`API Response: ${JSON.stringify(res.data, null, 2)}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + files: files, + nextPageToken: nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during docs.find: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - public find = async ({ query, pageToken, pageSize = 10 }: { query: string, pageToken?: string, pageSize?: number }) => { - logToFile(`Searching for documents with query: ${query}`); - if (pageToken) { - logToFile(`Using pageToken: ${pageToken}`); - } - if (pageSize) { - logToFile(`Using pageSize: ${pageSize}`); - } - try { - const q = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, query); - logToFile(`Executing Drive API query: ${q}`); - - const drive = await this.getDriveClient(); - const res = await drive.files.list({ - pageSize: pageSize, - fields: 'nextPageToken, files(id, name)', - q: q, - pageToken: pageToken, - }); - - const files = res.data.files || []; - const nextPageToken = res.data.nextPageToken; - - logToFile(`Found ${files.length} files.`); - if (nextPageToken) { - logToFile(`Next page token: ${nextPageToken}`); - } - logToFile(`API Response: ${JSON.stringify(res.data, null, 2)}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - files: files, - nextPageToken: nextPageToken - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during docs.find: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + }; + + public move = async ({ + documentId, + folderName, + }: { + documentId: string; + folderName: string; + }) => { + logToFile(`[DocsService] Starting move for document: ${documentId}`); + try { + const id = extractDocId(documentId) || documentId; + await this._moveFileToFolder(id, folderName); + logToFile(`[DocsService] Finished move for document: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: `Moved document ${id} to folder ${folderName}`, + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.move: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - public move = async ({ documentId, folderName }: { documentId: string, folderName: string }) => { - logToFile(`[DocsService] Starting move for document: ${documentId}`); - try { - const id = extractDocId(documentId) || documentId; - await this._moveFileToFolder(id, folderName); - logToFile(`[DocsService] Finished move for document: ${id}`); - return { - content: [{ - type: "text" as const, - text: `Moved document ${id} to folder ${folderName}` - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[DocsService] Error during docs.move: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + }; + + public getText = async ({ + documentId, + tabId, + }: { + documentId: string; + tabId?: string; + }) => { + logToFile( + `[DocsService] Starting getText for document: ${documentId}, tabId: ${tabId}`, + ); + try { + // Validate and extract document ID + const id = validateAndExtractDocId(documentId); + const docs = await this.getDocsClient(); + const res = await docs.documents.get({ + documentId: id, + fields: 'tabs', // Request tabs only (body is legacy and mutually exclusive with tabs in mask) + includeTabsContent: true, + }); + + const tabs = res.data.tabs || []; + + // If tabId is provided, try to find it + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); } - } - public getText = async ({ documentId, tabId }: { documentId: string, tabId?: string }) => { - logToFile(`[DocsService] Starting getText for document: ${documentId}, tabId: ${tabId}`); - try { - // Validate and extract document ID - const id = validateAndExtractDocId(documentId); - const docs = await this.getDocsClient(); - const res = await docs.documents.get({ - documentId: id, - fields: 'tabs', // Request tabs only (body is legacy and mutually exclusive with tabs in mask) - includeTabsContent: true, - }); - - const tabs = res.data.tabs || []; - - // If tabId is provided, try to find it - if (tabId) { - const tab = tabs.find(t => t.tabProperties?.tabId === tabId); - if (!tab) { - throw new Error(`Tab with ID ${tabId} not found.`); - } - - const content = tab.documentTab?.body?.content; - if (!content) { - return { - content: [{ - type: "text" as const, - text: "" - }] - }; - } - - let text = ''; - content.forEach(element => { - text += this._readStructuralElement(element); - }); - - return { - content: [{ - type: "text" as const, - text: text - }] - }; - } - - // If no tabId provided - if (tabs.length === 0) { - return { - content: [{ - type: "text" as const, - text: "" - }] - }; - } - - // If only 1 tab, return plain text (backward compatibility) - if (tabs.length === 1) { - const tab = tabs[0]; - let text = ''; - if (tab.documentTab?.body?.content) { - tab.documentTab.body.content.forEach(element => { - text += this._readStructuralElement(element); - }); - } - return { - content: [{ - type: "text" as const, - text: text - }] - }; - } - - // If multiple tabs, return JSON - const tabsData = tabs.map((tab, index) => { - let tabText = ''; - if (tab.documentTab?.body?.content) { - tab.documentTab.body.content.forEach(element => { - tabText += this._readStructuralElement(element); - }); - } - return { - tabId: tab.tabProperties?.tabId, - title: tab.tabProperties?.title, - content: tabText, - index: index - }; - }); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify(tabsData, null, 2) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[DocsService] Error during docs.getText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + const content = tab.documentTab?.body?.content; + if (!content) { + return { + content: [ + { + type: 'text' as const, + text: '', + }, + ], + }; } - } - private _readStructuralElement(element: docs_v1.Schema$StructuralElement): string { let text = ''; - if (element.paragraph) { - element.paragraph.elements?.forEach(pElement => { - if (pElement.textRun && pElement.textRun.content) { - text += pElement.textRun.content; - } - }); - } else if (element.table) { - element.table.tableRows?.forEach(row => { - row.tableCells?.forEach(cell => { - cell.content?.forEach(cellContent => { - text += this._readStructuralElement(cellContent); - }); - }); - }); - } - return text; - } + content.forEach((element) => { + text += this._readStructuralElement(element); + }); - public appendText = async ({ documentId, text, tabId }: { documentId: string, text: string, tabId?: string }) => { - logToFile(`[DocsService] Starting appendText for document: ${documentId}, tabId: ${tabId}`); - try { - const id = extractDocId(documentId) || documentId; - const docs = await this.getDocsClient(); - const res = await docs.documents.get({ - documentId: id, - fields: 'tabs', - includeTabsContent: true, - }); - - const tabs = res.data.tabs || []; - let content: docs_v1.Schema$StructuralElement[] | undefined; - - if (tabId) { - const tab = tabs.find(t => t.tabProperties?.tabId === tabId); - if (!tab) { - throw new Error(`Tab with ID ${tabId} not found.`); - } - content = tab.documentTab?.body?.content; - } else { - // Default to first tab if no tabId - if (tabs.length > 0) { - content = tabs[0].documentTab?.body?.content; - } - } - - const lastElement = content?.[content.length - 1]; - const endIndex = lastElement?.endIndex || 1; - - const locationIndex = Math.max(1, endIndex - 1); - - // Parse markdown and generate formatting requests - const { plainText, formattingRequests } = parseMarkdownToDocsRequests(text, locationIndex); - const processedText = processMarkdownLineBreaks(plainText); - - // Build batch update requests - const requests: docs_v1.Schema$Request[] = [ - { - insertText: { - location: { - index: locationIndex, - tabId: tabId // Use tabId for tab-specific insertion - }, - text: processedText, - }, - } - ]; - - // Add formatting requests if any - if (formattingRequests.length > 0) { - requests.push(...this._addTabIdToFormattingRequests(formattingRequests, tabId)); - } - - await docs.documents.batchUpdate({ - documentId: id, - requestBody: { - requests, - }, - }); - - logToFile(`[DocsService] Finished appendText for document: ${id}`); - return { - content: [{ - type: "text" as const, - text: `Successfully appended text to document ${id}` - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[DocsService] Error during docs.appendText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + return { + content: [ + { + type: 'text' as const, + text: text, + }, + ], + }; + } + + // If no tabId provided + if (tabs.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: '', + }, + ], + }; + } + + // If only 1 tab, return plain text (backward compatibility) + if (tabs.length === 1) { + const tab = tabs[0]; + let text = ''; + if (tab.documentTab?.body?.content) { + tab.documentTab.body.content.forEach((element) => { + text += this._readStructuralElement(element); + }); + } + return { + content: [ + { + type: 'text' as const, + text: text, + }, + ], + }; + } + + // If multiple tabs, return JSON + const tabsData = tabs.map((tab, index) => { + let tabText = ''; + if (tab.documentTab?.body?.content) { + tab.documentTab.body.content.forEach((element) => { + tabText += this._readStructuralElement(element); + }); } + return { + tabId: tab.tabProperties?.tabId, + title: tab.tabProperties?.title, + content: tabText, + index: index, + }; + }); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(tabsData, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.getText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - public replaceText = async ({ documentId, findText, replaceText, tabId }: { documentId: string, findText: string, replaceText: string, tabId?: string }) => { - logToFile(`[DocsService] Starting replaceText for document: ${documentId}, tabId: ${tabId}`); - try { - const id = extractDocId(documentId) || documentId; - const docs = await this.getDocsClient(); - - // Parse markdown to get plain text and formatting info - const { plainText, formattingRequests: originalFormattingRequests } = parseMarkdownToDocsRequests(replaceText, 0); - const processedText = processMarkdownLineBreaks(plainText); - - // First, get the document to find where the text will be replaced - const docBefore = await docs.documents.get({ - documentId: id, - fields: 'tabs', - includeTabsContent: true, - }); - - const tabs = docBefore.data.tabs || []; - - const requests: docs_v1.Schema$Request[] = []; - - if (tabId) { - const tab = tabs.find(t => t.tabProperties?.tabId === tabId); - if (!tab) { - throw new Error(`Tab with ID ${tabId} not found.`); - } - const content = tab.documentTab?.body?.content; - - const tabRequests = this._generateReplacementRequests( - content, - tabId, - findText, - processedText, - originalFormattingRequests - ); - requests.push(...tabRequests); - } else { - for (const tab of tabs) { - const currentTabId = tab.tabProperties?.tabId; - const content = tab.documentTab?.body?.content; - - const tabRequests = this._generateReplacementRequests( - content, - currentTabId, - findText, - processedText, - originalFormattingRequests - ); - requests.push(...tabRequests); - } - } - - if (requests.length > 0) { - await docs.documents.batchUpdate({ - documentId: id, - requestBody: { - requests, - }, - }); - } - - logToFile(`[DocsService] Finished replaceText for document: ${id}`); - return { - content: [{ - type: "text" as const, - text: `Successfully replaced text in document ${id}` - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[DocsService] Error during docs.replaceText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + }; + + private _readStructuralElement( + element: docs_v1.Schema$StructuralElement, + ): string { + let text = ''; + if (element.paragraph) { + element.paragraph.elements?.forEach((pElement) => { + if (pElement.textRun && pElement.textRun.content) { + text += pElement.textRun.content; } + }); + } else if (element.table) { + element.table.tableRows?.forEach((row) => { + row.tableCells?.forEach((cell) => { + cell.content?.forEach((cellContent) => { + text += this._readStructuralElement(cellContent); + }); + }); + }); } - - private _generateReplacementRequests( - content: docs_v1.Schema$StructuralElement[] | undefined, - tabId: string | undefined | null, - findText: string, - processedText: string, - originalFormattingRequests: docs_v1.Schema$Request[] - ): docs_v1.Schema$Request[] { - const requests: docs_v1.Schema$Request[] = []; - const documentText = this._getFullDocumentText(content); - const occurrences: number[] = []; - let searchIndex = 0; - while ((searchIndex = documentText.indexOf(findText, searchIndex)) !== -1) { - occurrences.push(searchIndex + 1); - searchIndex += findText.length; + return text; + } + + public appendText = async ({ + documentId, + text, + tabId, + }: { + documentId: string; + text: string; + tabId?: string; + }) => { + logToFile( + `[DocsService] Starting appendText for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + const res = await docs.documents.get({ + documentId: id, + fields: 'tabs', + includeTabsContent: true, + }); + + const tabs = res.data.tabs || []; + let content: docs_v1.Schema$StructuralElement[] | undefined; + + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); } - - const lengthDiff = processedText.length - findText.length; - let cumulativeOffset = 0; - - for (let i = 0; i < occurrences.length; i++) { - const occurrence = occurrences[i]; - const adjustedPosition = occurrence + cumulativeOffset; - - // Delete old text - requests.push({ - deleteContentRange: { - range: { - tabId: tabId, - startIndex: adjustedPosition, - endIndex: adjustedPosition + findText.length - } - } - }); - - // Insert new text - requests.push({ - insertText: { - location: { - tabId: tabId, - index: adjustedPosition - }, - text: processedText - } - }); - - // Formatting - for (const formatRequest of originalFormattingRequests) { - if (formatRequest.updateTextStyle) { - const adjustedRequest: docs_v1.Schema$Request = { - updateTextStyle: { - ...formatRequest.updateTextStyle, - range: { - tabId: tabId, - startIndex: (formatRequest.updateTextStyle.range?.startIndex || 0) + adjustedPosition, - endIndex: (formatRequest.updateTextStyle.range?.endIndex || 0) + adjustedPosition - } - } - }; - requests.push(adjustedRequest); - } - } - - cumulativeOffset += lengthDiff; + content = tab.documentTab?.body?.content; + } else { + // Default to first tab if no tabId + if (tabs.length > 0) { + content = tabs[0].documentTab?.body?.content; } - return requests; + } + + const lastElement = content?.[content.length - 1]; + const endIndex = lastElement?.endIndex || 1; + + const locationIndex = Math.max(1, endIndex - 1); + + // Parse markdown and generate formatting requests + const { plainText, formattingRequests } = parseMarkdownToDocsRequests( + text, + locationIndex, + ); + const processedText = processMarkdownLineBreaks(plainText); + + // Build batch update requests + const requests: docs_v1.Schema$Request[] = [ + { + insertText: { + location: { + index: locationIndex, + tabId: tabId, // Use tabId for tab-specific insertion + }, + text: processedText, + }, + }, + ]; + + // Add formatting requests if any + if (formattingRequests.length > 0) { + requests.push( + ...this._addTabIdToFormattingRequests(formattingRequests, tabId), + ); + } + + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests, + }, + }); + + logToFile(`[DocsService] Finished appendText for document: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: `Successfully appended text to document ${id}`, + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.appendText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - private _getFullDocumentText(content: docs_v1.Schema$StructuralElement[] | undefined): string { - let text = ''; - if (content) { - content.forEach(element => { - text += this._readStructuralElement(element); - }); + }; + + public replaceText = async ({ + documentId, + findText, + replaceText, + tabId, + }: { + documentId: string; + findText: string; + replaceText: string; + tabId?: string; + }) => { + logToFile( + `[DocsService] Starting replaceText for document: ${documentId}, tabId: ${tabId}`, + ); + try { + const id = extractDocId(documentId) || documentId; + const docs = await this.getDocsClient(); + + // Parse markdown to get plain text and formatting info + const { plainText, formattingRequests: originalFormattingRequests } = + parseMarkdownToDocsRequests(replaceText, 0); + const processedText = processMarkdownLineBreaks(plainText); + + // First, get the document to find where the text will be replaced + const docBefore = await docs.documents.get({ + documentId: id, + fields: 'tabs', + includeTabsContent: true, + }); + + const tabs = docBefore.data.tabs || []; + + const requests: docs_v1.Schema$Request[] = []; + + if (tabId) { + const tab = tabs.find((t) => t.tabProperties?.tabId === tabId); + if (!tab) { + throw new Error(`Tab with ID ${tabId} not found.`); } - return text; - } - - - - private _addTabIdToFormattingRequests(requests: docs_v1.Schema$Request[], tabId?: string): docs_v1.Schema$Request[] { - if (!tabId || requests.length === 0) { - return requests; + const content = tab.documentTab?.body?.content; + + const tabRequests = this._generateReplacementRequests( + content, + tabId, + findText, + processedText, + originalFormattingRequests, + ); + requests.push(...tabRequests); + } else { + for (const tab of tabs) { + const currentTabId = tab.tabProperties?.tabId; + const content = tab.documentTab?.body?.content; + + const tabRequests = this._generateReplacementRequests( + content, + currentTabId, + findText, + processedText, + originalFormattingRequests, + ); + requests.push(...tabRequests); } - return requests.map(req => { - const newReq = { ...req }; - if (newReq.updateTextStyle?.range) { - newReq.updateTextStyle = { - ...newReq.updateTextStyle, - range: { ...newReq.updateTextStyle.range, tabId: tabId } - }; - } - if (newReq.updateParagraphStyle?.range) { - newReq.updateParagraphStyle = { - ...newReq.updateParagraphStyle, - range: { ...newReq.updateParagraphStyle.range, tabId: tabId } - }; - } - if (newReq.insertText?.location) { - newReq.insertText = { - ...newReq.insertText, - location: { ...newReq.insertText.location, tabId: tabId } - }; - } - return newReq; + } + + if (requests.length > 0) { + await docs.documents.batchUpdate({ + documentId: id, + requestBody: { + requests, + }, }); + } + + logToFile(`[DocsService] Finished replaceText for document: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: `Successfully replaced text in document ${id}`, + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[DocsService] Error during docs.replaceText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + private _generateReplacementRequests( + content: docs_v1.Schema$StructuralElement[] | undefined, + tabId: string | undefined | null, + findText: string, + processedText: string, + originalFormattingRequests: docs_v1.Schema$Request[], + ): docs_v1.Schema$Request[] { + const requests: docs_v1.Schema$Request[] = []; + const documentText = this._getFullDocumentText(content); + const occurrences: number[] = []; + let searchIndex = 0; + while ((searchIndex = documentText.indexOf(findText, searchIndex)) !== -1) { + occurrences.push(searchIndex + 1); + searchIndex += findText.length; } - private async _moveFileToFolder(documentId: string, folderName: string): Promise { - try { - const findFolderResponse = await this.driveService.findFolder({ folderName }); - const parsedResponse = JSON.parse(findFolderResponse.content[0].text); - - if (parsedResponse.error) { - throw new Error(parsedResponse.error); - } - - const folders = parsedResponse as { id: string, name: string }[]; - - if (folders.length === 0) { - throw new Error(`Folder not found: ${folderName}`); - } - - if (folders.length > 1) { - logToFile(`Warning: Found multiple folders with name "${folderName}". Using the first one found.`); - } - - const folderId = folders[0].id; - const drive = await this.getDriveClient(); - const file = await drive.files.get({ - fileId: documentId, - fields: 'parents', - }); - - const previousParents = file.data.parents?.join(','); - - await drive.files.update({ - fileId: documentId, - addParents: folderId, - removeParents: previousParents, - fields: 'id, parents', - }); - } catch (error) { - if (error instanceof Error) { - logToFile(`Error during _moveFileToFolder: ${error.message}`); - } else { - logToFile(`An unknown error occurred during _moveFileToFolder: ${JSON.stringify(error)}`); - } - throw error; + const lengthDiff = processedText.length - findText.length; + let cumulativeOffset = 0; + + for (let i = 0; i < occurrences.length; i++) { + const occurrence = occurrences[i]; + const adjustedPosition = occurrence + cumulativeOffset; + + // Delete old text + requests.push({ + deleteContentRange: { + range: { + tabId: tabId, + startIndex: adjustedPosition, + endIndex: adjustedPosition + findText.length, + }, + }, + }); + + // Insert new text + requests.push({ + insertText: { + location: { + tabId: tabId, + index: adjustedPosition, + }, + text: processedText, + }, + }); + + // Formatting + for (const formatRequest of originalFormattingRequests) { + if (formatRequest.updateTextStyle) { + const adjustedRequest: docs_v1.Schema$Request = { + updateTextStyle: { + ...formatRequest.updateTextStyle, + range: { + tabId: tabId, + startIndex: + (formatRequest.updateTextStyle.range?.startIndex || 0) + + adjustedPosition, + endIndex: + (formatRequest.updateTextStyle.range?.endIndex || 0) + + adjustedPosition, + }, + }, + }; + requests.push(adjustedRequest); } + } + + cumulativeOffset += lengthDiff; + } + return requests; + } + + private _getFullDocumentText( + content: docs_v1.Schema$StructuralElement[] | undefined, + ): string { + let text = ''; + if (content) { + content.forEach((element) => { + text += this._readStructuralElement(element); + }); + } + return text; + } + + private _addTabIdToFormattingRequests( + requests: docs_v1.Schema$Request[], + tabId?: string, + ): docs_v1.Schema$Request[] { + if (!tabId || requests.length === 0) { + return requests; + } + return requests.map((req) => { + const newReq = { ...req }; + if (newReq.updateTextStyle?.range) { + newReq.updateTextStyle = { + ...newReq.updateTextStyle, + range: { ...newReq.updateTextStyle.range, tabId: tabId }, + }; + } + if (newReq.updateParagraphStyle?.range) { + newReq.updateParagraphStyle = { + ...newReq.updateParagraphStyle, + range: { ...newReq.updateParagraphStyle.range, tabId: tabId }, + }; + } + if (newReq.insertText?.location) { + newReq.insertText = { + ...newReq.insertText, + location: { ...newReq.insertText.location, tabId: tabId }, + }; + } + return newReq; + }); + } + + private async _moveFileToFolder( + documentId: string, + folderName: string, + ): Promise { + try { + const findFolderResponse = await this.driveService.findFolder({ + folderName, + }); + const parsedResponse = JSON.parse(findFolderResponse.content[0].text); + + if (parsedResponse.error) { + throw new Error(parsedResponse.error); + } + + const folders = parsedResponse as { id: string; name: string }[]; + + if (folders.length === 0) { + throw new Error(`Folder not found: ${folderName}`); + } + + if (folders.length > 1) { + logToFile( + `Warning: Found multiple folders with name "${folderName}". Using the first one found.`, + ); + } + + const folderId = folders[0].id; + const drive = await this.getDriveClient(); + const file = await drive.files.get({ + fileId: documentId, + fields: 'parents', + }); + + const previousParents = file.data.parents?.join(','); + + await drive.files.update({ + fileId: documentId, + addParents: folderId, + removeParents: previousParents, + fields: 'id, parents', + }); + } catch (error) { + if (error instanceof Error) { + logToFile(`Error during _moveFileToFolder: ${error.message}`); + } else { + logToFile( + `An unknown error occurred during _moveFileToFolder: ${JSON.stringify(error)}`, + ); + } + throw error; } + } } diff --git a/workspace-server/src/services/DriveService.ts b/workspace-server/src/services/DriveService.ts index c937051..41cf62f 100644 --- a/workspace-server/src/services/DriveService.ts +++ b/workspace-server/src/services/DriveService.ts @@ -16,334 +16,428 @@ import { PROJECT_ROOT } from '../utils/paths'; const MIN_DRIVE_ID_LENGTH = 25; const URL_PATTERNS = [ - { pattern: /\/folders\/([a-zA-Z0-9-_]+)/, type: 'folder' as const }, - { pattern: /\/file\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, - { pattern: /\/document\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, - { pattern: /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, - { pattern: /\/presentation\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, - { pattern: /\/forms\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, - { pattern: /[?&]id=([a-zA-Z0-9-_]+)/, type: 'unknown' as const } + { pattern: /\/folders\/([a-zA-Z0-9-_]+)/, type: 'folder' as const }, + { pattern: /\/file\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, + { pattern: /\/document\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, + { pattern: /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, + { pattern: /\/presentation\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, + { pattern: /\/forms\/d\/([a-zA-Z0-9-_]+)/, type: 'file' as const }, + { pattern: /[?&]id=([a-zA-Z0-9-_]+)/, type: 'unknown' as const }, ]; export class DriveService { - constructor(private authManager: AuthManager) { + constructor(private authManager: AuthManager) {} + + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + + public findFolder = async ({ folderName }: { folderName: string }) => { + logToFile(`Searching for folder with name: ${folderName}`); + try { + const drive = await this.getDriveClient(); + const query = `mimeType='application/vnd.google-apps.folder' and name = '${folderName}'`; + logToFile(`Executing Drive API query: ${query}`); + const res = await drive.files.list({ + q: query, + fields: 'files(id, name)', + spaces: 'drive', + }); + + const folders = res.data.files || []; + logToFile(`Found ${folders.length} folders.`); + logToFile(`API Response: ${JSON.stringify(folders, null, 2)}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(folders), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.findFolder: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - private async getDriveClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.drive({ version: 'v3', ...options }); + }; + + public createFolder = async ({ + name, + parentId, + }: { + name: string; + parentId?: string; + }) => { + logToFile( + `Creating folder with name: ${name} ${parentId ? `in parent: ${parentId}` : ''}`, + ); + try { + const drive = await this.getDriveClient(); + const fileMetadata: drive_v3.Schema$File = { + name: name, + mimeType: 'application/vnd.google-apps.folder', + }; + + if (parentId) { + fileMetadata.parents = [parentId]; + } + + const file = await drive.files.create({ + requestBody: fileMetadata, + fields: 'id, name', + }); + + logToFile(`Created folder: ${file.data.name} (${file.data.id})`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + id: file.data.id, + name: file.data.name, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.createFolder: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - public findFolder = async ({ folderName }: { folderName: string }) => { - logToFile(`Searching for folder with name: ${folderName}`); - try { - const drive = await this.getDriveClient(); - const query = `mimeType='application/vnd.google-apps.folder' and name = '${folderName}'`; - logToFile(`Executing Drive API query: ${query}`); - const res = await drive.files.list({ - q: query, - fields: 'files(id, name)', - spaces: 'drive', - }); - - const folders = res.data.files || []; - logToFile(`Found ${folders.length} folders.`); - logToFile(`API Response: ${JSON.stringify(folders, null, 2)}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify(folders) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during drive.findFolder: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + }; + + public search = async ({ + query, + pageSize = 10, + pageToken, + corpus, + unreadOnly, + sharedWithMe, + }: { + query?: string; + pageSize?: number; + pageToken?: string; + corpus?: string; + unreadOnly?: boolean; + sharedWithMe?: boolean; + }) => { + const drive = await this.getDriveClient(); + let q = query; + let isProcessed = false; + + // Check if query is a Google Drive URL + if ( + query && + (query.includes('drive.google.com') || query.includes('docs.google.com')) + ) { + isProcessed = true; + logToFile(`Detected Google Drive URL in query: ${query}`); + + let fileId: string | null = null; + let urlType: 'file' | 'folder' | 'unknown' = 'unknown'; + + for (const urlPattern of URL_PATTERNS) { + const match = query.match(urlPattern.pattern); + if (match) { + fileId = match[1]; + urlType = urlPattern.type; + break; } - } + } - public createFolder = async ({ name, parentId }: { name: string, parentId?: string }) => { - logToFile(`Creating folder with name: ${name} ${parentId ? `in parent: ${parentId}` : ''}`); - try { - const drive = await this.getDriveClient(); - const fileMetadata: drive_v3.Schema$File = { - name: name, - mimeType: 'application/vnd.google-apps.folder', - }; + if (fileId) { + let isFolder = urlType === 'folder'; - if (parentId) { - fileMetadata.parents = [parentId]; + if (urlType === 'unknown') { + try { + const file = await drive.files.get({ fileId, fields: 'mimeType' }); + if (file.data.mimeType === 'application/vnd.google-apps.folder') { + isFolder = true; } + } catch { + logToFile( + `Could not determine type of ID from URL, treating as file: ${fileId}`, + ); + } + } - const file = await drive.files.create({ - requestBody: fileMetadata, - fields: 'id, name', + if (isFolder) { + q = `'${fileId}' in parents`; + logToFile( + `Extracted Folder ID from URL: ${fileId}, using query: ${q}`, + ); + } else { + logToFile(`Extracted File ID from URL: ${fileId}, using files.get`); + try { + const res = await drive.files.get({ + fileId: fileId, + fields: + 'id, name, modifiedTime, viewedByMeTime, mimeType, parents', }); - - logToFile(`Created folder: ${file.data.name} (${file.data.id})`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - id: file.data.id, - name: file.data.name - }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + files: [res.data], + nextPageToken: null, + }), + }, + ], }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during drive.createFolder: ${errorMessage}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.files.get: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; + } } + } else { + logToFile(`Could not extract file/folder ID from URL: ${query}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: + 'Invalid Drive URL. Please provide a valid Google Drive URL or a search query.', + details: + 'Could not extract file or folder ID from the provided URL.', + }), + }, + ], + }; + } } - public search = async ({ query, pageSize = 10, pageToken, corpus, unreadOnly, sharedWithMe }: { query?: string, pageSize?: number, pageToken?: string, corpus?: string, unreadOnly?: boolean, sharedWithMe?: boolean }) => { - const drive = await this.getDriveClient(); - let q = query; - let isProcessed = false; - - // Check if query is a Google Drive URL - if (query && (query.includes('drive.google.com') || query.includes('docs.google.com'))) { - isProcessed = true; - logToFile(`Detected Google Drive URL in query: ${query}`); - - let fileId: string | null = null; - let urlType: 'file' | 'folder' | 'unknown' = 'unknown'; - - for (const urlPattern of URL_PATTERNS) { - const match = query.match(urlPattern.pattern); - if (match) { - fileId = match[1]; - urlType = urlPattern.type; - break; - } - } - - if (fileId) { - let isFolder = urlType === 'folder'; - - if (urlType === 'unknown') { - try { - const file = await drive.files.get({ fileId, fields: 'mimeType' }); - if (file.data.mimeType === 'application/vnd.google-apps.folder') { - isFolder = true; - } - } catch { - logToFile(`Could not determine type of ID from URL, treating as file: ${fileId}`); - } - } - - if (isFolder) { - q = `'${fileId}' in parents`; - logToFile(`Extracted Folder ID from URL: ${fileId}, using query: ${q}`); - } else { - logToFile(`Extracted File ID from URL: ${fileId}, using files.get`); - try { - const res = await drive.files.get({ - fileId: fileId, - fields: 'id, name, modifiedTime, viewedByMeTime, mimeType, parents', - }); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - files: [res.data], - nextPageToken: null - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during drive.files.get: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } - } - } else { - logToFile(`Could not extract file/folder ID from URL: ${query}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "Invalid Drive URL. Please provide a valid Google Drive URL or a search query.", - details: "Could not extract file or folder ID from the provided URL." - }) - }] - }; - } - } - - if (query && !isProcessed) { - const titlePrefix = 'title:'; - const trimmedQuery = query.trim(); - - if (trimmedQuery.startsWith(titlePrefix)) { - let searchTerm = trimmedQuery.substring(titlePrefix.length).trim(); - if ((searchTerm.startsWith("'") && searchTerm.endsWith("'")) || - (searchTerm.startsWith('"') && searchTerm.endsWith('"'))) { - searchTerm = searchTerm.substring(1, searchTerm.length - 1); - } - q = `name contains '${escapeQueryString(searchTerm)}'`; - } else { - const driveIdPattern = new RegExp(`^[a-zA-Z0-9-_]{${MIN_DRIVE_ID_LENGTH},}$`); - if (driveIdPattern.test(trimmedQuery) && !trimmedQuery.includes(" ")) { - q = `'${trimmedQuery}' in parents`; - logToFile(`Detected Drive ID: ${trimmedQuery}, listing contents`); - } else { - const looksLikeQuery = /( and | or | not | contains | in |=)/.test(trimmedQuery); - if (!looksLikeQuery) { - const escapedQuery = escapeQueryString(trimmedQuery); - q = `fullText contains '${escapedQuery}'`; - } - } - } - } - - if (sharedWithMe) { - logToFile('Searching for files shared with the user.'); - if (q) { - q += " and sharedWithMe"; - } else { - q = "sharedWithMe"; - } - } - - logToFile(`Executing Drive search with query: ${q}`); - if (corpus) { - logToFile(`Using corpus: ${corpus}`); + if (query && !isProcessed) { + const titlePrefix = 'title:'; + const trimmedQuery = query.trim(); + + if (trimmedQuery.startsWith(titlePrefix)) { + let searchTerm = trimmedQuery.substring(titlePrefix.length).trim(); + if ( + (searchTerm.startsWith("'") && searchTerm.endsWith("'")) || + (searchTerm.startsWith('"') && searchTerm.endsWith('"')) + ) { + searchTerm = searchTerm.substring(1, searchTerm.length - 1); } - if (unreadOnly) { - logToFile('Filtering for unread files only.'); - } - - try { - const res = await drive.files.list({ - q: q, - pageSize: pageSize, - pageToken: pageToken, - corpus: corpus as 'user' | 'domain' | undefined, - fields: 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', - }); - - let files = res.data.files || []; - const nextPageToken = res.data.nextPageToken; - - if (unreadOnly) { - files = files.filter(file => !file.viewedByMeTime); - } - - logToFile(`Found ${files.length} files.`); - if (nextPageToken) { - logToFile(`Next page token: ${nextPageToken}`); - } - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - files: files, - nextPageToken: nextPageToken - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during drive.search: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + q = `name contains '${escapeQueryString(searchTerm)}'`; + } else { + const driveIdPattern = new RegExp( + `^[a-zA-Z0-9-_]{${MIN_DRIVE_ID_LENGTH},}$`, + ); + if (driveIdPattern.test(trimmedQuery) && !trimmedQuery.includes(' ')) { + q = `'${trimmedQuery}' in parents`; + logToFile(`Detected Drive ID: ${trimmedQuery}, listing contents`); + } else { + const looksLikeQuery = /( and | or | not | contains | in |=)/.test( + trimmedQuery, + ); + if (!looksLikeQuery) { + const escapedQuery = escapeQueryString(trimmedQuery); + q = `fullText contains '${escapedQuery}'`; + } } + } } - public downloadFile = async ({ fileId, localPath }: { fileId: string, localPath: string }) => { - logToFile(`Downloading Drive file ${fileId} to ${localPath}`); - try { - const drive = await this.getDriveClient(); - - // 1. Check if it's a Google Doc (special handling required, export instead of download) - const metadata = await drive.files.get({ - fileId: fileId, - fields: 'id, name, mimeType', - }); - const mimeType = metadata.data.mimeType || ''; - - const googleWorkspaceFileMap: Record = { - 'application/vnd.google-apps.document': { tool: 'docs.getText', idName: 'documentId', type: 'Google Doc' }, - 'application/vnd.google-apps.spreadsheet': { tool: 'sheets.getText', idName: 'spreadsheetId', type: 'Google Sheet' }, - 'application/vnd.google-apps.presentation': { tool: 'slides.getText', idName: 'presentationId', type: 'Google Slide' }, - }; - - if (mimeType in googleWorkspaceFileMap) { - const fileInfo = googleWorkspaceFileMap[mimeType]; - return { - content: [{ - type: "text" as const, - text: `This is a ${fileInfo.type}. Direct download is not supported. Please use the '${fileInfo.tool}' tool with ${fileInfo.idName}: ${fileId}` - }] - }; - } - - if (mimeType.includes('vnd.google-apps.')) { - return { - content: [{ - type: "text" as const, - text: `This is a Google Workspace file type (${mimeType}). Direct media download is not supported. Please use specific tools (docs.getText, slides.getText, etc.) or export it if supported.` - }] - }; - } - - // 2. Download media - const response = await drive.files.get({ - fileId: fileId, - alt: 'media', - }, { responseType: 'arraybuffer' }); - - const buffer = Buffer.from(response.data as unknown as ArrayBuffer); - - // 3. Save to localPath - const absolutePath = path.isAbsolute(localPath) ? localPath : path.resolve(PROJECT_ROOT, localPath); - const dir = path.dirname(absolutePath); - - await fs.promises.mkdir(dir, { recursive: true }); - - await fs.promises.writeFile(absolutePath, buffer); + if (sharedWithMe) { + logToFile('Searching for files shared with the user.'); + if (q) { + q += ' and sharedWithMe'; + } else { + q = 'sharedWithMe'; + } + } - return { - content: [{ - type: "text" as const, - text: `Successfully downloaded file ${metadata.data.name} to ${absolutePath}` - }] - }; + logToFile(`Executing Drive search with query: ${q}`); + if (corpus) { + logToFile(`Using corpus: ${corpus}`); + } + if (unreadOnly) { + logToFile('Filtering for unread files only.'); + } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during drive.downloadFile: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + try { + const res = await drive.files.list({ + q: q, + pageSize: pageSize, + pageToken: pageToken, + corpus: corpus as 'user' | 'domain' | undefined, + fields: + 'nextPageToken, files(id, name, modifiedTime, viewedByMeTime, mimeType, parents)', + }); + + let files = res.data.files || []; + const nextPageToken = res.data.nextPageToken; + + if (unreadOnly) { + files = files.filter((file) => !file.viewedByMeTime); + } + + logToFile(`Found ${files.length} files.`); + if (nextPageToken) { + logToFile(`Next page token: ${nextPageToken}`); + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + files: files, + nextPageToken: nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.search: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public downloadFile = async ({ + fileId, + localPath, + }: { + fileId: string; + localPath: string; + }) => { + logToFile(`Downloading Drive file ${fileId} to ${localPath}`); + try { + const drive = await this.getDriveClient(); + + // 1. Check if it's a Google Doc (special handling required, export instead of download) + const metadata = await drive.files.get({ + fileId: fileId, + fields: 'id, name, mimeType', + }); + const mimeType = metadata.data.mimeType || ''; + + const googleWorkspaceFileMap: Record< + string, + { tool: string; idName: string; type: string } + > = { + 'application/vnd.google-apps.document': { + tool: 'docs.getText', + idName: 'documentId', + type: 'Google Doc', + }, + 'application/vnd.google-apps.spreadsheet': { + tool: 'sheets.getText', + idName: 'spreadsheetId', + type: 'Google Sheet', + }, + 'application/vnd.google-apps.presentation': { + tool: 'slides.getText', + idName: 'presentationId', + type: 'Google Slide', + }, + }; + + if (mimeType in googleWorkspaceFileMap) { + const fileInfo = googleWorkspaceFileMap[mimeType]; + return { + content: [ + { + type: 'text' as const, + text: `This is a ${fileInfo.type}. Direct download is not supported. Please use the '${fileInfo.tool}' tool with ${fileInfo.idName}: ${fileId}`, + }, + ], + }; + } + + if (mimeType.includes('vnd.google-apps.')) { + return { + content: [ + { + type: 'text' as const, + text: `This is a Google Workspace file type (${mimeType}). Direct media download is not supported. Please use specific tools (docs.getText, slides.getText, etc.) or export it if supported.`, + }, + ], + }; + } + + // 2. Download media + const response = await drive.files.get( + { + fileId: fileId, + alt: 'media', + }, + { responseType: 'arraybuffer' }, + ); + + const buffer = Buffer.from(response.data as unknown as ArrayBuffer); + + // 3. Save to localPath + const absolutePath = path.isAbsolute(localPath) + ? localPath + : path.resolve(PROJECT_ROOT, localPath); + const dir = path.dirname(absolutePath); + + await fs.promises.mkdir(dir, { recursive: true }); + + await fs.promises.writeFile(absolutePath, buffer); + + return { + content: [ + { + type: 'text' as const, + text: `Successfully downloaded file ${metadata.data.name} to ${absolutePath}`, + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`Error during drive.downloadFile: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } -} \ No newline at end of file + }; +} diff --git a/workspace-server/src/services/GmailService.ts b/workspace-server/src/services/GmailService.ts index a2d59ea..04b3cab 100644 --- a/workspace-server/src/services/GmailService.ts +++ b/workspace-server/src/services/GmailService.ts @@ -16,457 +16,518 @@ import { emailArraySchema } from '../utils/validation'; // Type definitions for email parameters type SendEmailParams = { - to: string | string[]; - subject: string; - body: string; - cc?: string | string[]; - bcc?: string | string[]; - isHtml?: boolean; + to: string | string[]; + subject: string; + body: string; + cc?: string | string[]; + bcc?: string | string[]; + isHtml?: boolean; }; interface GmailAttachment { - filename: string | null | undefined; - mimeType: string | null | undefined; - attachmentId: string | null | undefined; - size: number | null | undefined; + filename: string | null | undefined; + mimeType: string | null | undefined; + attachmentId: string | null | undefined; + size: number | null | undefined; } export class GmailService { - constructor(private authManager: AuthManager) { - } - - private async getGmailClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.gmail({ version: 'v1', ...options }); + constructor(private authManager: AuthManager) {} + + private async getGmailClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.gmail({ version: 'v1', ...options }); + } + + /** + * Helper method to handle errors consistently across all methods + */ + private handleError(error: unknown, context: string) { + const errorMessage = error instanceof Error ? error.message : String(error); + logToFile(`Error during ${context}: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + + public search = async ({ + query, + maxResults = GMAIL_SEARCH_MAX_RESULTS, + pageToken, + labelIds, + includeSpamTrash = false, + }: { + query?: string; + maxResults?: number; + pageToken?: string; + labelIds?: string[]; + includeSpamTrash?: boolean; + }) => { + try { + logToFile(`Gmail search - query: ${query}, maxResults: ${maxResults}`); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.messages.list({ + userId: 'me', + q: query, + maxResults, + pageToken, + labelIds, + includeSpamTrash, + }); + + const messages = response.data.messages || []; + const nextPageToken = response.data.nextPageToken; + const resultSizeEstimate = response.data.resultSizeEstimate; + + logToFile( + `Found ${messages.length} messages, estimated total: ${resultSizeEstimate}`, + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + messages: messages.map((msg) => ({ + id: msg.id, + threadId: msg.threadId, + })), + nextPageToken, + resultSizeEstimate, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.search'); } + }; + + public get = async ({ + messageId, + format = 'full', + }: { + messageId: string; + format?: 'minimal' | 'full' | 'raw' | 'metadata'; + }) => { + try { + logToFile(`Getting message ${messageId} with format: ${format}`); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.messages.get({ + userId: 'me', + id: messageId, + format, + }); + + const message = response.data; + + // Extract useful information based on format + if (format === 'metadata' || format === 'full') { + const headers = message.payload?.headers || []; + const getHeader = (name: string) => + headers.find((h) => h.name === name)?.value; + + const subject = getHeader('Subject'); + const from = getHeader('From'); + const to = getHeader('To'); + const date = getHeader('Date'); + + // Extract body and attachments for full format + let body = ''; + let attachments: GmailAttachment[] = []; + if (format === 'full' && message.payload) { + const result = this.extractAttachmentsAndBody(message.payload); + body = result.body; + attachments = result.attachments; + } - /** - * Helper method to handle errors consistently across all methods - */ - private handleError(error: unknown, context: string) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`Error during ${context}: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: message.id, + threadId: message.threadId, + labelIds: message.labelIds, + snippet: message.snippet, + subject, + from, + to, + date, + body: body || message.snippet, + attachments: attachments, + }, + null, + 2, + ), + }, + ], }; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(message, null, 2), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.get'); } - - public search = async ({ - query, - maxResults = GMAIL_SEARCH_MAX_RESULTS, - pageToken, - labelIds, - includeSpamTrash = false - }: { - query?: string, - maxResults?: number, - pageToken?: string, - labelIds?: string[], - includeSpamTrash?: boolean - }) => { - try { - logToFile(`Gmail search - query: ${query}, maxResults: ${maxResults}`); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.messages.list({ - userId: 'me', - q: query, - maxResults, - pageToken, - labelIds, - includeSpamTrash - }); - - const messages = response.data.messages || []; - const nextPageToken = response.data.nextPageToken; - const resultSizeEstimate = response.data.resultSizeEstimate; - - logToFile(`Found ${messages.length} messages, estimated total: ${resultSizeEstimate}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - messages: messages.map(msg => ({ - id: msg.id, - threadId: msg.threadId - })), - nextPageToken, - resultSizeEstimate - }, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.search'); - } + }; + + public downloadAttachment = async ({ + messageId, + attachmentId, + localPath, + }: { + messageId: string; + attachmentId: string; + localPath: string; + }) => { + try { + logToFile( + `Downloading attachment ${attachmentId} from message ${messageId} to ${localPath}`, + ); + + if (!path.isAbsolute(localPath)) { + throw new Error('localPath must be an absolute path.'); + } + + const gmail = await this.getGmailClient(); + const response = await gmail.users.messages.attachments.get({ + userId: 'me', + messageId: messageId, + id: attachmentId, + }); + + const data = response.data.data; + if (!data) { + throw new Error('Attachment data is empty'); + } + + // Ensure directory exists + await fs.mkdir(path.dirname(localPath), { recursive: true }); + + // Write file + const buffer = Buffer.from(data, 'base64url'); + await fs.writeFile(localPath, buffer); + + logToFile(`Attachment downloaded successfully to ${localPath}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + message: `Attachment downloaded successfully to ${localPath}`, + path: localPath, + }), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.downloadAttachment'); } - - public get = async ({ - messageId, - format = 'full' - }: { - messageId: string, - format?: 'minimal' | 'full' | 'raw' | 'metadata' - }) => { - try { - logToFile(`Getting message ${messageId} with format: ${format}`); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.messages.get({ - userId: 'me', - id: messageId, - format - }); - - const message = response.data; - - // Extract useful information based on format - if (format === 'metadata' || format === 'full') { - const headers = message.payload?.headers || []; - const getHeader = (name: string) => headers.find(h => h.name === name)?.value; - - const subject = getHeader('Subject'); - const from = getHeader('From'); - const to = getHeader('To'); - const date = getHeader('Date'); - - // Extract body and attachments for full format - let body = ''; - let attachments: GmailAttachment[] = []; - if (format === 'full' && message.payload) { - const result = this.extractAttachmentsAndBody(message.payload); - body = result.body; - attachments = result.attachments; - } - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - id: message.id, - threadId: message.threadId, - labelIds: message.labelIds, - snippet: message.snippet, - subject, - from, - to, - date, - body: body || message.snippet, - attachments: attachments - }, null, 2) - }] - }; - } - - return { - content: [{ - type: "text" as const, - text: JSON.stringify(message, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.get'); - } + }; + + public modify = async ({ + messageId, + addLabelIds = [], + removeLabelIds = [], + }: { + messageId: string; + addLabelIds?: string[]; + removeLabelIds?: string[]; + }) => { + try { + logToFile( + `Modifying message ${messageId} with addLabelIds: ${addLabelIds}, removeLabelIds: ${removeLabelIds}`, + ); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.messages.modify({ + userId: 'me', + id: messageId, + requestBody: { + addLabelIds, + removeLabelIds, + }, + }); + + const message = response.data; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(message, null, 2), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.modify'); } - - public downloadAttachment = async ({ - messageId, - attachmentId, - localPath - }: { - messageId: string, - attachmentId: string, - localPath: string - }) => { - try { - logToFile(`Downloading attachment ${attachmentId} from message ${messageId} to ${localPath}`); - - if (!path.isAbsolute(localPath)) { - throw new Error('localPath must be an absolute path.'); - } - - const gmail = await this.getGmailClient(); - const response = await gmail.users.messages.attachments.get({ - userId: 'me', - messageId: messageId, - id: attachmentId - }); - - const data = response.data.data; - if (!data) { - throw new Error('Attachment data is empty'); - } - - // Ensure directory exists - await fs.mkdir(path.dirname(localPath), { recursive: true }); - - // Write file - const buffer = Buffer.from(data, 'base64url'); - await fs.writeFile(localPath, buffer); - - logToFile(`Attachment downloaded successfully to ${localPath}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - message: `Attachment downloaded successfully to ${localPath}`, - path: localPath - }) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.downloadAttachment'); - } + }; + + public send = async ({ + to, + subject, + body, + cc, + bcc, + isHtml = false, + }: SendEmailParams) => { + try { + // Validate email addresses + try { + emailArraySchema.parse(to); + if (cc) emailArraySchema.parse(cc); + if (bcc) emailArraySchema.parse(bcc); + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: 'Invalid email address format', + details: + error instanceof Error ? error.message : 'Validation failed', + }), + }, + ], + }; + } + + logToFile(`Sending email to: ${to}, subject: ${subject}`); + + // Create MIME message + const mimeMessage = MimeHelper.createMimeMessage({ + to: Array.isArray(to) ? to.join(', ') : to, + subject, + body, + cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, + bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined, + isHtml, + }); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.messages.send({ + userId: 'me', + requestBody: { + raw: mimeMessage, + }, + }); + + logToFile(`Email sent successfully: ${response.data.id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: response.data.id, + threadId: response.data.threadId, + labelIds: response.data.labelIds, + status: 'sent', + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.send'); } - - public modify = async ({ - messageId, - addLabelIds = [], - removeLabelIds = [] - }: { - messageId: string, - addLabelIds?: string[], - removeLabelIds?: string[] - }) => { - try { - logToFile(`Modifying message ${messageId} with addLabelIds: ${addLabelIds}, removeLabelIds: ${removeLabelIds}`); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.messages.modify({ - userId: 'me', - id: messageId, - requestBody: { - addLabelIds, - removeLabelIds, + }; + + public createDraft = async ({ + to, + subject, + body, + cc, + bcc, + isHtml = false, + }: SendEmailParams) => { + try { + logToFile(`Creating draft - to: ${to}, subject: ${subject}`); + + // Create MIME message + const mimeMessage = MimeHelper.createMimeMessage({ + to: Array.isArray(to) ? to.join(', ') : to, + subject, + body, + cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, + bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined, + isHtml, + }); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.drafts.create({ + userId: 'me', + requestBody: { + message: { + raw: mimeMessage, + }, + }, + }); + + logToFile(`Draft created successfully: ${response.data.id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: response.data.id, + message: { + id: response.data.message?.id, + threadId: response.data.message?.threadId, + labelIds: response.data.message?.labelIds, }, - }); - - const message = response.data; - return { - content: [{ - type: "text" as const, - text: JSON.stringify(message, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.modify'); - } + status: 'draft_created', + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.createDraft'); } - - public send = async ({ - to, - subject, - body, - cc, - bcc, - isHtml = false - }: SendEmailParams) => { - try { - // Validate email addresses - try { - emailArraySchema.parse(to); - if (cc) emailArraySchema.parse(cc); - if (bcc) emailArraySchema.parse(bcc); - } catch (error) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: 'Invalid email address format', - details: error instanceof Error ? error.message : 'Validation failed' - }) - }] - }; - } - - logToFile(`Sending email to: ${to}, subject: ${subject}`); - - // Create MIME message - const mimeMessage = MimeHelper.createMimeMessage({ - to: Array.isArray(to) ? to.join(', ') : to, - subject, - body, - cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, - bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined, - isHtml - }); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.messages.send({ - userId: 'me', - requestBody: { - raw: mimeMessage - } - }); - - logToFile(`Email sent successfully: ${response.data.id}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - id: response.data.id, - threadId: response.data.threadId, - labelIds: response.data.labelIds, - status: 'sent' - }, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.send'); - } + }; + + public sendDraft = async ({ draftId }: { draftId: string }) => { + try { + logToFile(`Sending draft: ${draftId}`); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.drafts.send({ + userId: 'me', + requestBody: { + id: draftId, + }, + }); + + logToFile(`Draft sent successfully: ${response.data.id}`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + id: response.data.id, + threadId: response.data.threadId, + labelIds: response.data.labelIds, + status: 'sent', + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.sendDraft'); } - - public createDraft = async ({ - to, - subject, - body, - cc, - bcc, - isHtml = false - }: SendEmailParams) => { - try { - logToFile(`Creating draft - to: ${to}, subject: ${subject}`); - - // Create MIME message - const mimeMessage = MimeHelper.createMimeMessage({ - to: Array.isArray(to) ? to.join(', ') : to, - subject, - body, - cc: cc ? (Array.isArray(cc) ? cc.join(', ') : cc) : undefined, - bcc: bcc ? (Array.isArray(bcc) ? bcc.join(', ') : bcc) : undefined, - isHtml - }); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.drafts.create({ - userId: 'me', - requestBody: { - message: { - raw: mimeMessage - } - } - }); - - logToFile(`Draft created successfully: ${response.data.id}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - id: response.data.id, - message: { - id: response.data.message?.id, - threadId: response.data.message?.threadId, - labelIds: response.data.message?.labelIds - }, - status: 'draft_created' - }, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.createDraft'); - } + }; + + public listLabels = async () => { + try { + logToFile(`Listing Gmail labels`); + + const gmail = await this.getGmailClient(); + const response = await gmail.users.labels.list({ + userId: 'me', + }); + + const labels = response.data.labels || []; + + logToFile(`Found ${labels.length} labels`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + labels: labels.map((label) => ({ + id: label.id, + name: label.name, + type: label.type, + messageListVisibility: label.messageListVisibility, + labelListVisibility: label.labelListVisibility, + })), + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + return this.handleError(error, 'gmail.listLabels'); } - - public sendDraft = async ({ draftId }: { draftId: string }) => { - try { - logToFile(`Sending draft: ${draftId}`); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.drafts.send({ - userId: 'me', - requestBody: { - id: draftId - } - }); - - logToFile(`Draft sent successfully: ${response.data.id}`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - id: response.data.id, - threadId: response.data.threadId, - labelIds: response.data.labelIds, - status: 'sent' - }, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.sendDraft'); + }; + + private extractAttachmentsAndBody( + payload: gmail_v1.Schema$MessagePart, + result: { body: string; attachments: GmailAttachment[] } = { + body: '', + attachments: [], + }, + ) { + if (!payload) return result; + + // Handle body parts + if (payload.body?.data) { + // If it's the main body (and not an attachment) + if (!payload.filename || !payload.body.attachmentId) { + if (payload.mimeType?.startsWith('text/')) { + // Prioritize plain text over HTML for direct body extraction + if (!result.body || payload.mimeType === 'text/plain') { + result.body = Buffer.from(payload.body.data, 'base64').toString( + 'utf-8', + ); + } } + } } - public listLabels = async () => { - try { - logToFile(`Listing Gmail labels`); - - const gmail = await this.getGmailClient(); - const response = await gmail.users.labels.list({ - userId: 'me' - }); - - const labels = response.data.labels || []; - - logToFile(`Found ${labels.length} labels`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - labels: labels.map(label => ({ - id: label.id, - name: label.name, - type: label.type, - messageListVisibility: label.messageListVisibility, - labelListVisibility: label.labelListVisibility - })) - }, null, 2) - }] - }; - } catch (error) { - return this.handleError(error, 'gmail.listLabels'); - } + // Handle attachments and recursive parts + if (payload.filename && payload.body?.attachmentId) { + result.attachments.push({ + filename: payload.filename, + mimeType: payload.mimeType, + attachmentId: payload.body.attachmentId, + size: payload.body.size, // Size in bytes + }); } - private extractAttachmentsAndBody(payload: gmail_v1.Schema$MessagePart, result: { body: string, attachments: GmailAttachment[] } = { body: '', attachments: [] }) { - if (!payload) return result; - - // Handle body parts - if (payload.body?.data) { - // If it's the main body (and not an attachment) - if (!payload.filename || !payload.body.attachmentId) { - if (payload.mimeType?.startsWith('text/')) { - // Prioritize plain text over HTML for direct body extraction - if (!result.body || payload.mimeType === 'text/plain') { - result.body = Buffer.from(payload.body.data, 'base64').toString('utf-8'); - } - } - } - } - - // Handle attachments and recursive parts - if (payload.filename && payload.body?.attachmentId) { - result.attachments.push({ - filename: payload.filename, - mimeType: payload.mimeType, - attachmentId: payload.body.attachmentId, - size: payload.body.size, // Size in bytes - }); - } - - if (payload.parts) { - for (const part of payload.parts) { - this.extractAttachmentsAndBody(part, result); - } - } - return result; + if (payload.parts) { + for (const part of payload.parts) { + this.extractAttachmentsAndBody(part, result); + } } + return result; + } } diff --git a/workspace-server/src/services/PeopleService.ts b/workspace-server/src/services/PeopleService.ts index 35fac5c..c0961f4 100644 --- a/workspace-server/src/services/PeopleService.ts +++ b/workspace-server/src/services/PeopleService.ts @@ -10,142 +10,198 @@ import { logToFile } from '../utils/logger'; import { gaxiosOptions } from '../utils/GaxiosConfig'; export class PeopleService { - constructor(private authManager: AuthManager) { - } + constructor(private authManager: AuthManager) {} - private async getPeopleClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.people({ version: 'v1', ...options }); - } + private async getPeopleClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.people({ version: 'v1', ...options }); + } - public getUserProfile = async ({ userId, email, name }: { userId?: string, email?: string, name?: string }) => { - logToFile(`[PeopleService] Starting getUserProfile with: userId=${userId}, email=${email}, name=${name}`); - try { - if (!userId && !email && !name) { - throw new Error('Either userId, email, or name must be provided.'); - } - const people = await this.getPeopleClient(); - if (userId) { - const resourceName = userId.startsWith('people/') ? userId : `people/${userId}`; - const res = await people.people.get({ - resourceName, - personFields: 'names,emailAddresses', - }); - logToFile(`[PeopleService] Finished getUserProfile for user: ${userId}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ results: [{ person: res.data }] }) - }] - }; - } else if (email || name) { - const query = email || name; - const res = await people.people.searchDirectoryPeople({ - query, - readMask: 'names,emailAddresses', - sources: ['DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT', 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE'], - }); - logToFile(`[PeopleService] Finished getUserProfile search for: ${query}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(res.data) - }] - }; - } else { - throw new Error('Either userId, email, or name must be provided.'); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[PeopleService] Error during people.getUserProfile: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + public getUserProfile = async ({ + userId, + email, + name, + }: { + userId?: string; + email?: string; + name?: string; + }) => { + logToFile( + `[PeopleService] Starting getUserProfile with: userId=${userId}, email=${email}, name=${name}`, + ); + try { + if (!userId && !email && !name) { + throw new Error('Either userId, email, or name must be provided.'); + } + const people = await this.getPeopleClient(); + if (userId) { + const resourceName = userId.startsWith('people/') + ? userId + : `people/${userId}`; + const res = await people.people.get({ + resourceName, + personFields: 'names,emailAddresses', + }); + logToFile( + `[PeopleService] Finished getUserProfile for user: ${userId}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ results: [{ person: res.data }] }), + }, + ], + }; + } else if (email || name) { + const query = email || name; + const res = await people.people.searchDirectoryPeople({ + query, + readMask: 'names,emailAddresses', + sources: [ + 'DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT', + 'DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE', + ], + }); + logToFile( + `[PeopleService] Finished getUserProfile search for: ${query}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } else { + throw new Error('Either userId, email, or name must be provided.'); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[PeopleService] Error during people.getUserProfile: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; - public getMe = async () => { - logToFile(`[PeopleService] Starting getMe`); - try { - const people = await this.getPeopleClient(); - const res = await people.people.get({ - resourceName: 'people/me', - personFields: 'names,emailAddresses', - }); - logToFile(`[PeopleService] Finished getMe`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(res.data) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[PeopleService] Error during people.getMe: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + public getMe = async () => { + logToFile(`[PeopleService] Starting getMe`); + try { + const people = await this.getPeopleClient(); + const res = await people.people.get({ + resourceName: 'people/me', + personFields: 'names,emailAddresses', + }); + logToFile(`[PeopleService] Finished getMe`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(res.data), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[PeopleService] Error during people.getMe: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; + + /** + * Gets a user's relations (e.g., manager, spouse, assistant). + * Defaults to the authenticated user if no userId is provided. + * Optionally filters by a specific relation type. + */ + public getUserRelations = async ({ + userId, + relationType, + }: { + userId?: string; + relationType?: string; + }) => { + const targetUser = userId + ? userId.startsWith('people/') + ? userId + : `people/${userId}` + : 'people/me'; + logToFile( + `[PeopleService] Starting getUserRelations for ${targetUser} with relationType=${relationType}`, + ); + try { + const people = await this.getPeopleClient(); + const res = await people.people.get({ + resourceName: targetUser, + personFields: 'relations', + }); + logToFile(`[PeopleService] Finished getUserRelations API call`); - /** - * Gets a user's relations (e.g., manager, spouse, assistant). - * Defaults to the authenticated user if no userId is provided. - * Optionally filters by a specific relation type. - */ - public getUserRelations = async ({ userId, relationType }: { userId?: string, relationType?: string }) => { - const targetUser = userId ? (userId.startsWith('people/') ? userId : `people/${userId}`) : 'people/me'; - logToFile(`[PeopleService] Starting getUserRelations for ${targetUser} with relationType=${relationType}`); - try { - const people = await this.getPeopleClient(); - const res = await people.people.get({ - resourceName: targetUser, - personFields: 'relations', - }); - logToFile(`[PeopleService] Finished getUserRelations API call`); + const relations = res.data?.relations || []; - const relations = res.data?.relations || []; - - const filteredRelations = relationType - ? relations.filter( - (relation) => relation.type?.toLowerCase() === relationType.toLowerCase() - ) - : relations; + const filteredRelations = relationType + ? relations.filter( + (relation) => + relation.type?.toLowerCase() === relationType.toLowerCase(), + ) + : relations; - if (relationType) { - logToFile(`[PeopleService] Filtered to ${filteredRelations.length} relations of type: ${relationType}`); - } else { - logToFile(`[PeopleService] Returning all ${filteredRelations.length} relations`); - } + if (relationType) { + logToFile( + `[PeopleService] Filtered to ${filteredRelations.length} relations of type: ${relationType}`, + ); + } else { + logToFile( + `[PeopleService] Returning all ${filteredRelations.length} relations`, + ); + } - const responseData = { - resourceName: targetUser, - ...(relationType && { relationType }), - relations: filteredRelations, - }; + const responseData = { + resourceName: targetUser, + ...(relationType && { relationType }), + relations: filteredRelations, + }; - return { - content: [{ - type: "text" as const, - text: JSON.stringify(responseData), - }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[PeopleService] Error during people.getUserRelations: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(responseData), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[PeopleService] Error during people.getUserRelations: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } -} \ No newline at end of file + }; +} diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 2c8e66e..e981aeb 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -12,238 +12,300 @@ import { gaxiosOptions } from '../utils/GaxiosConfig'; import { buildDriveSearchQuery, MIME_TYPES } from '../utils/DriveQueryBuilder'; export class SheetsService { - constructor(private authManager: AuthManager) { - } + constructor(private authManager: AuthManager) {} - private async getSheetsClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.sheets({ version: 'v4', ...options }); - } + private async getSheetsClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.sheets({ version: 'v4', ...options }); + } - private async getDriveClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.drive({ version: 'v3', ...options }); - } + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + + public getText = async ({ + spreadsheetId, + format = 'text', + }: { + spreadsheetId: string; + format?: 'text' | 'csv' | 'json'; + }) => { + logToFile( + `[SheetsService] Starting getText for spreadsheet: ${spreadsheetId} with format: ${format}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + // Get spreadsheet metadata + const spreadsheet = await sheets.spreadsheets.get({ + spreadsheetId: id, + includeGridData: false, + }); + + let content = ''; + const jsonData: Record = {}; + + // Add spreadsheet title (except for JSON format) + if (spreadsheet.data.properties?.title && format !== 'json') { + content += `Spreadsheet Title: ${spreadsheet.data.properties.title}\n\n`; + } + + // Get all sheet names + const sheetNames = + spreadsheet.data.sheets?.map((sheet) => sheet.properties?.title) || []; + + // Get data from all sheets + for (const sheetName of sheetNames) { + if (!sheetName) continue; - public getText = async ({ spreadsheetId, format = 'text' }: { spreadsheetId: string, format?: 'text' | 'csv' | 'json' }) => { - logToFile(`[SheetsService] Starting getText for spreadsheet: ${spreadsheetId} with format: ${format}`); try { - const id = extractDocId(spreadsheetId) || spreadsheetId; - - const sheets = await this.getSheetsClient(); - // Get spreadsheet metadata - const spreadsheet = await sheets.spreadsheets.get({ - spreadsheetId: id, - includeGridData: false, - }); - - let content = ''; - const jsonData: Record = {}; - - // Add spreadsheet title (except for JSON format) - if (spreadsheet.data.properties?.title && format !== 'json') { - content += `Spreadsheet Title: ${spreadsheet.data.properties.title}\n\n`; - } + const response = await sheets.spreadsheets.values.get({ + spreadsheetId: id, + range: `'${sheetName}'`, + }); + + const values = response.data.values || []; + + if (format === 'json') { + // Collect data for JSON structure + jsonData[sheetName] = values; + } else { + // Add sheet name as context + content += `Sheet Name: ${sheetName}\n`; - // Get all sheet names - const sheetNames = spreadsheet.data.sheets?.map(sheet => sheet.properties?.title) || []; - - // Get data from all sheets - for (const sheetName of sheetNames) { - if (!sheetName) continue; - - try { - const response = await sheets.spreadsheets.values.get({ - spreadsheetId: id, - range: `'${sheetName}'`, - }); - - const values = response.data.values || []; - - if (format === 'json') { - // Collect data for JSON structure - jsonData[sheetName] = values; - } else { - // Add sheet name as context - content += `Sheet Name: ${sheetName}\n`; - - if (values.length === 0) { - content += '(Empty sheet)\n'; - } else { - // Process each row - values.forEach((row) => { - if (format === 'csv') { - // Convert to CSV format - const csvRow = row.map(cell => { - // Escape quotes and wrap in quotes if contains comma or quotes - const cellStr = String(cell || ''); - if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) { - return `"${cellStr.replace(/"/g, '""')}"`; - } - return cellStr; - }).join(','); - content += csvRow + '\n'; - } else { - // Plain text format with pipe separators for readability - content += row.map(cell => cell || '').join(' | ') + '\n'; - } - }); - } - content += '\n'; - } - } catch (sheetError) { - logToFile(`[SheetsService] Error reading sheet ${sheetName}: ${sheetError}`); - if (format === 'json') { - // For JSON format, we'll skip sheets with errors - logToFile(`[SheetsService] Skipping sheet ${sheetName} in JSON output due to error`); - } else { - content += `Sheet Name: ${sheetName}\n(Error reading sheet)\n\n`; - } + if (values.length === 0) { + content += '(Empty sheet)\n'; + } else { + // Process each row + values.forEach((row) => { + if (format === 'csv') { + // Convert to CSV format + const csvRow = row + .map((cell) => { + // Escape quotes and wrap in quotes if contains comma or quotes + const cellStr = String(cell || ''); + if ( + cellStr.includes(',') || + cellStr.includes('"') || + cellStr.includes('\n') + ) { + return `"${cellStr.replace(/"/g, '""')}"`; + } + return cellStr; + }) + .join(','); + content += csvRow + '\n'; + } else { + // Plain text format with pipe separators for readability + content += row.map((cell) => cell || '').join(' | ') + '\n'; } + }); } - - if (format === 'json') { - // Generate clean JSON output from collected data - content = JSON.stringify(jsonData, null, 2); - } - - logToFile(`[SheetsService] Finished getText for spreadsheet: ${id}`); - return { - content: [{ - type: "text" as const, - text: content.trim() - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SheetsService] Error during sheets.getText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + content += '\n'; + } + } catch (sheetError) { + logToFile( + `[SheetsService] Error reading sheet ${sheetName}: ${sheetError}`, + ); + if (format === 'json') { + // For JSON format, we'll skip sheets with errors + logToFile( + `[SheetsService] Skipping sheet ${sheetName} in JSON output due to error`, + ); + } else { + content += `Sheet Name: ${sheetName}\n(Error reading sheet)\n\n`; + } } + } + + if (format === 'json') { + // Generate clean JSON output from collected data + content = JSON.stringify(jsonData, null, 2); + } + + logToFile(`[SheetsService] Finished getText for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: content.trim(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SheetsService] Error during sheets.getText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; - public getRange = async ({ spreadsheetId, range }: { spreadsheetId: string, range: string }) => { - logToFile(`[SheetsService] Starting getRange for spreadsheet: ${spreadsheetId}, range: ${range}`); - try { - const id = extractDocId(spreadsheetId) || spreadsheetId; - - const sheets = await this.getSheetsClient(); - const response = await sheets.spreadsheets.values.get({ - spreadsheetId: id, - range: range, - }); - - const values = response.data.values || []; - - logToFile(`[SheetsService] Finished getRange for spreadsheet: ${id}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - range: response.data.range, - values: values - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SheetsService] Error during sheets.getRange: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + public getRange = async ({ + spreadsheetId, + range, + }: { + spreadsheetId: string; + range: string; + }) => { + logToFile( + `[SheetsService] Starting getRange for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.get({ + spreadsheetId: id, + range: range, + }); + + const values = response.data.values || []; + + logToFile(`[SheetsService] Finished getRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + range: response.data.range, + values: values, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.getRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; - public find = async ({ query, pageToken, pageSize = 10 }: { query: string, pageToken?: string, pageSize?: number }) => { - logToFile(`[SheetsService] Searching for spreadsheets with query: ${query}`); - try { - const q = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, query); - logToFile(`[SheetsService] Executing Drive API query: ${q}`); - - const drive = await this.getDriveClient(); - const res = await drive.files.list({ - pageSize: pageSize, - fields: 'nextPageToken, files(id, name)', - q: q, - pageToken: pageToken, - }); - - const files = res.data.files || []; - const nextPageToken = res.data.nextPageToken; - - logToFile(`[SheetsService] Found ${files.length} spreadsheets.`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - files: files, - nextPageToken: nextPageToken - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SheetsService] Error during sheets.find: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + public find = async ({ + query, + pageToken, + pageSize = 10, + }: { + query: string; + pageToken?: string; + pageSize?: number; + }) => { + logToFile( + `[SheetsService] Searching for spreadsheets with query: ${query}`, + ); + try { + const q = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, query); + logToFile(`[SheetsService] Executing Drive API query: ${q}`); + + const drive = await this.getDriveClient(); + const res = await drive.files.list({ + pageSize: pageSize, + fields: 'nextPageToken, files(id, name)', + q: q, + pageToken: pageToken, + }); + + const files = res.data.files || []; + const nextPageToken = res.data.nextPageToken; + + logToFile(`[SheetsService] Found ${files.length} spreadsheets.`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + files: files, + nextPageToken: nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SheetsService] Error during sheets.find: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; - public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => { - logToFile(`[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`); - try { - const id = extractDocId(spreadsheetId) || spreadsheetId; - - const sheets = await this.getSheetsClient(); - const spreadsheet = await sheets.spreadsheets.get({ - spreadsheetId: id, - includeGridData: false, - }); - - const metadata = { - spreadsheetId: spreadsheet.data.spreadsheetId, - title: spreadsheet.data.properties?.title, - sheets: spreadsheet.data.sheets?.map(sheet => ({ - sheetId: sheet.properties?.sheetId, - title: sheet.properties?.title, - index: sheet.properties?.index, - rowCount: sheet.properties?.gridProperties?.rowCount, - columnCount: sheet.properties?.gridProperties?.columnCount, - })), - locale: spreadsheet.data.properties?.locale, - timeZone: spreadsheet.data.properties?.timeZone, - }; - - logToFile(`[SheetsService] Finished getMetadata for spreadsheet: ${id}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(metadata) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SheetsService] Error during sheets.getMetadata: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => { + logToFile( + `[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const spreadsheet = await sheets.spreadsheets.get({ + spreadsheetId: id, + includeGridData: false, + }); + + const metadata = { + spreadsheetId: spreadsheet.data.spreadsheetId, + title: spreadsheet.data.properties?.title, + sheets: spreadsheet.data.sheets?.map((sheet) => ({ + sheetId: sheet.properties?.sheetId, + title: sheet.properties?.title, + index: sheet.properties?.index, + rowCount: sheet.properties?.gridProperties?.rowCount, + columnCount: sheet.properties?.gridProperties?.columnCount, + })), + locale: spreadsheet.data.properties?.locale, + timeZone: spreadsheet.data.properties?.timeZone, + }; + + logToFile(`[SheetsService] Finished getMetadata for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(metadata), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.getMetadata: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; } diff --git a/workspace-server/src/services/SlidesService.ts b/workspace-server/src/services/SlidesService.ts index c8d46a6..d88c953 100644 --- a/workspace-server/src/services/SlidesService.ts +++ b/workspace-server/src/services/SlidesService.ts @@ -12,187 +12,229 @@ import { gaxiosOptions } from '../utils/GaxiosConfig'; import { buildDriveSearchQuery, MIME_TYPES } from '../utils/DriveQueryBuilder'; export class SlidesService { - constructor(private authManager: AuthManager) { - } - - private async getSlidesClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.slides({ version: 'v1', ...options }); - } - - private async getDriveClient(): Promise { - const auth = await this.authManager.getAuthenticatedClient(); - const options = { ...gaxiosOptions, auth }; - return google.drive({ version: 'v3', ...options }); - } - - public getText = async ({ presentationId }: { presentationId: string }) => { - logToFile(`[SlidesService] Starting getText for presentation: ${presentationId}`); - try { - const id = extractDocId(presentationId) || presentationId; - - const slides = await this.getSlidesClient(); - // Get the presentation with all necessary fields - const presentation = await slides.presentations.get({ - presentationId: id, - fields: 'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))', - }); - - let content = ''; - - // Add presentation title - if (presentation.data.title) { - content += `Presentation Title: ${presentation.data.title}\n\n`; - } - - // Process each slide - if (presentation.data.slides) { - presentation.data.slides.forEach((slide, slideIndex) => { - content += `\n--- Slide ${slideIndex + 1} ---\n`; - - if (slide.pageElements) { - slide.pageElements.forEach(element => { - // Extract text from shapes - if (element.shape && element.shape.text) { - const shapeText = this.extractTextFromTextContent(element.shape.text); - if (shapeText) { - content += shapeText + '\n'; - } - } - - // Extract text from tables - if (element.table && element.table.tableRows) { - content += '\n--- Table Data ---\n'; - element.table.tableRows.forEach(row => { - const rowText: string[] = []; - if (row.tableCells) { - row.tableCells.forEach(cell => { - const cellText = cell.text ? this.extractTextFromTextContent(cell.text) : ''; - rowText.push(cellText.trim()); - }); - } - content += rowText.join(' | ') + '\n'; - }); - content += '--- End Table Data ---\n'; - } - }); - } - content += '\n'; - }); - } - - logToFile(`[SlidesService] Finished getText for presentation: ${id}`); - return { - content: [{ - type: "text" as const, - text: content.trim() - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.getText: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } - } - - private extractTextFromTextContent(textContent: slides_v1.Schema$TextContent): string { - let text = ''; - if (textContent.textElements) { - textContent.textElements.forEach(element => { - if (element.textRun && element.textRun.content) { - text += element.textRun.content; - } else if (element.paragraphMarker) { - // Add newline for paragraph markers - text += '\n'; + constructor(private authManager: AuthManager) {} + + private async getSlidesClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.slides({ version: 'v1', ...options }); + } + + private async getDriveClient(): Promise { + const auth = await this.authManager.getAuthenticatedClient(); + const options = { ...gaxiosOptions, auth }; + return google.drive({ version: 'v3', ...options }); + } + + public getText = async ({ presentationId }: { presentationId: string }) => { + logToFile( + `[SlidesService] Starting getText for presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + + const slides = await this.getSlidesClient(); + // Get the presentation with all necessary fields + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))', + }); + + let content = ''; + + // Add presentation title + if (presentation.data.title) { + content += `Presentation Title: ${presentation.data.title}\n\n`; + } + + // Process each slide + if (presentation.data.slides) { + presentation.data.slides.forEach((slide, slideIndex) => { + content += `\n--- Slide ${slideIndex + 1} ---\n`; + + if (slide.pageElements) { + slide.pageElements.forEach((element) => { + // Extract text from shapes + if (element.shape && element.shape.text) { + const shapeText = this.extractTextFromTextContent( + element.shape.text, + ); + if (shapeText) { + content += shapeText + '\n'; } + } + + // Extract text from tables + if (element.table && element.table.tableRows) { + content += '\n--- Table Data ---\n'; + element.table.tableRows.forEach((row) => { + const rowText: string[] = []; + if (row.tableCells) { + row.tableCells.forEach((cell) => { + const cellText = cell.text + ? this.extractTextFromTextContent(cell.text) + : ''; + rowText.push(cellText.trim()); + }); + } + content += rowText.join(' | ') + '\n'; + }); + content += '--- End Table Data ---\n'; + } }); - } - return text; + } + content += '\n'; + }); + } + + logToFile(`[SlidesService] Finished getText for presentation: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: content.trim(), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during slides.getText: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } - - public find = async ({ query, pageToken, pageSize = 10 }: { query: string, pageToken?: string, pageSize?: number }) => { - logToFile(`[SlidesService] Searching for presentations with query: ${query}`); - try { - const q = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, query); - logToFile(`[SlidesService] Executing Drive API query: ${q}`); - - const drive = await this.getDriveClient(); - const res = await drive.files.list({ - pageSize: pageSize, - fields: 'nextPageToken, files(id, name)', - q: q, - pageToken: pageToken, - }); - - const files = res.data.files || []; - const nextPageToken = res.data.nextPageToken; - - logToFile(`[SlidesService] Found ${files.length} presentations.`); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - files: files, - nextPageToken: nextPageToken - }) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.find: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; + }; + + private extractTextFromTextContent( + textContent: slides_v1.Schema$TextContent, + ): string { + let text = ''; + if (textContent.textElements) { + textContent.textElements.forEach((element) => { + if (element.textRun && element.textRun.content) { + text += element.textRun.content; + } else if (element.paragraphMarker) { + // Add newline for paragraph markers + text += '\n'; } + }); } - - public getMetadata = async ({ presentationId }: { presentationId: string }) => { - logToFile(`[SlidesService] Starting getMetadata for presentation: ${presentationId}`); - try { - const id = extractDocId(presentationId) || presentationId; - - const slides = await this.getSlidesClient(); - const presentation = await slides.presentations.get({ - presentationId: id, - fields: 'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts', - }); - - const metadata = { - presentationId: presentation.data.presentationId, - title: presentation.data.title, - slideCount: presentation.data.slides?.length || 0, - pageSize: presentation.data.pageSize, - hasMasters: !!presentation.data.masters?.length, - hasLayouts: !!presentation.data.layouts?.length, - hasNotesMaster: !!presentation.data.notesMaster, - }; - - logToFile(`[SlidesService] Finished getMetadata for presentation: ${id}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify(metadata) - }] - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logToFile(`[SlidesService] Error during slides.getMetadata: ${errorMessage}`); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] - }; - } + return text; + } + + public find = async ({ + query, + pageToken, + pageSize = 10, + }: { + query: string; + pageToken?: string; + pageSize?: number; + }) => { + logToFile( + `[SlidesService] Searching for presentations with query: ${query}`, + ); + try { + const q = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, query); + logToFile(`[SlidesService] Executing Drive API query: ${q}`); + + const drive = await this.getDriveClient(); + const res = await drive.files.list({ + pageSize: pageSize, + fields: 'nextPageToken, files(id, name)', + q: q, + pageToken: pageToken, + }); + + const files = res.data.files || []; + const nextPageToken = res.data.nextPageToken; + + logToFile(`[SlidesService] Found ${files.length} presentations.`); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + files: files, + nextPageToken: nextPageToken, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile(`[SlidesService] Error during slides.find: ${errorMessage}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public getMetadata = async ({ + presentationId, + }: { + presentationId: string; + }) => { + logToFile( + `[SlidesService] Starting getMetadata for presentation: ${presentationId}`, + ); + try { + const id = extractDocId(presentationId) || presentationId; + + const slides = await this.getSlidesClient(); + const presentation = await slides.presentations.get({ + presentationId: id, + fields: + 'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts', + }); + + const metadata = { + presentationId: presentation.data.presentationId, + title: presentation.data.title, + slideCount: presentation.data.slides?.length || 0, + pageSize: presentation.data.pageSize, + hasMasters: !!presentation.data.masters?.length, + hasLayouts: !!presentation.data.layouts?.length, + hasNotesMaster: !!presentation.data.notesMaster, + }; + + logToFile(`[SlidesService] Finished getMetadata for presentation: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(metadata), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SlidesService] Error during slides.getMetadata: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; } + }; } diff --git a/workspace-server/src/services/TimeService.ts b/workspace-server/src/services/TimeService.ts index d0bcbb3..9d0c306 100644 --- a/workspace-server/src/services/TimeService.ts +++ b/workspace-server/src/services/TimeService.ts @@ -11,23 +11,30 @@ export class TimeService { logToFile('TimeService initialized.'); } - private async handleErrors(fn: () => Promise): Promise<{ content: [{ type: "text"; text: string; }] }> { + private async handleErrors( + fn: () => Promise, + ): Promise<{ content: [{ type: 'text'; text: string }] }> { try { const result = await fn(); return { - content: [{ - type: "text" as const, - text: JSON.stringify(result) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify(result), + }, + ], }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); logToFile(`Error in TimeService: ${errorMessage}`); return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: errorMessage }) - }] + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], }; } } @@ -35,7 +42,7 @@ export class TimeService { private getTimeContext() { return { now: new Date(), - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; } @@ -46,10 +53,10 @@ export class TimeService { return { utc: now.toISOString().slice(0, 10), local: now.toLocaleDateString('en-CA', { timeZone }), // YYYY-MM-DD format - timeZone + timeZone, }; }); - } + }; getCurrentTime = async () => { logToFile('getCurrentTime called'); @@ -58,15 +65,15 @@ export class TimeService { return { utc: now.toISOString().slice(11, 19), local: now.toLocaleTimeString('en-GB', { hour12: false, timeZone }), // HH:MM:SS format - timeZone + timeZone, }; }); - } + }; getTimeZone = async () => { logToFile('getTimeZone called'); return this.handleErrors(async () => { return { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; }); - } + }; } diff --git a/workspace-server/src/utils/DriveQueryBuilder.ts b/workspace-server/src/utils/DriveQueryBuilder.ts index 7fda7dc..02cf6fd 100644 --- a/workspace-server/src/utils/DriveQueryBuilder.ts +++ b/workspace-server/src/utils/DriveQueryBuilder.ts @@ -15,28 +15,30 @@ * @returns The formatted Drive API query string */ export function buildDriveSearchQuery(mimeType: string, query: string): string { - let searchTerm = query; - const titlePrefix = 'title:'; - let q: string; - - if (searchTerm.trim().startsWith(titlePrefix)) { - // Extract search term after 'title:' prefix - searchTerm = searchTerm.trim().substring(titlePrefix.length).trim(); - - // Remove surrounding quotes if present - if ((searchTerm.startsWith("'") && searchTerm.endsWith("'")) || - (searchTerm.startsWith('"') && searchTerm.endsWith('"'))) { - searchTerm = searchTerm.substring(1, searchTerm.length - 1); - } - - // Search by name (title) only - q = `mimeType='${mimeType}' and name contains '${escapeQueryString(searchTerm)}'`; - } else { - // Search full text content - q = `mimeType='${mimeType}' and fullText contains '${escapeQueryString(searchTerm)}'`; + let searchTerm = query; + const titlePrefix = 'title:'; + let q: string; + + if (searchTerm.trim().startsWith(titlePrefix)) { + // Extract search term after 'title:' prefix + searchTerm = searchTerm.trim().substring(titlePrefix.length).trim(); + + // Remove surrounding quotes if present + if ( + (searchTerm.startsWith("'") && searchTerm.endsWith("'")) || + (searchTerm.startsWith('"') && searchTerm.endsWith('"')) + ) { + searchTerm = searchTerm.substring(1, searchTerm.length - 1); } - - return q; + + // Search by name (title) only + q = `mimeType='${mimeType}' and name contains '${escapeQueryString(searchTerm)}'`; + } else { + // Search full text content + q = `mimeType='${mimeType}' and fullText contains '${escapeQueryString(searchTerm)}'`; + } + + return q; } /** @@ -45,13 +47,13 @@ export function buildDriveSearchQuery(mimeType: string, query: string): string { * @returns The escaped string */ export function escapeQueryString(str: string): string { - return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } // Export MIME type constants for convenience export const MIME_TYPES = { - DOCUMENT: 'application/vnd.google-apps.document', - PRESENTATION: 'application/vnd.google-apps.presentation', - SPREADSHEET: 'application/vnd.google-apps.spreadsheet', - FOLDER: 'application/vnd.google-apps.folder', + DOCUMENT: 'application/vnd.google-apps.document', + PRESENTATION: 'application/vnd.google-apps.presentation', + SPREADSHEET: 'application/vnd.google-apps.spreadsheet', + FOLDER: 'application/vnd.google-apps.folder', } as const; diff --git a/workspace-server/src/utils/GaxiosConfig.ts b/workspace-server/src/utils/GaxiosConfig.ts index 1689b00..dfe7c16 100644 --- a/workspace-server/src/utils/GaxiosConfig.ts +++ b/workspace-server/src/utils/GaxiosConfig.ts @@ -8,26 +8,28 @@ import { GaxiosOptions } from 'gaxios'; import { logToFile } from './logger'; export const gaxiosOptions: GaxiosOptions = { - retryConfig: { - retry: 3, - noResponseRetries: 3, - retryDelay: 1000, - httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT'], - statusCodesToRetry: [ - [429, 429], - [500, 599], - ], - onRetryAttempt: (err) => { - const config = err.config as GaxiosOptions; - logToFile(`Retrying request to ${config.url}, attempt #${config.retryConfig?.currentRetryAttempt}`); - logToFile(`Error: ${err.message}`); - } + retryConfig: { + retry: 3, + noResponseRetries: 3, + retryDelay: 1000, + httpMethodsToRetry: ['GET', 'HEAD', 'OPTIONS', 'DELETE', 'PUT'], + statusCodesToRetry: [ + [429, 429], + [500, 599], + ], + onRetryAttempt: (err) => { + const config = err.config as GaxiosOptions; + logToFile( + `Retrying request to ${config.url}, attempt #${config.retryConfig?.currentRetryAttempt}`, + ); + logToFile(`Error: ${err.message}`); }, - timeout: 30000, + }, + timeout: 30000, }; // Extended timeout for media upload operations export const mediaUploadOptions: GaxiosOptions = { - ...gaxiosOptions, - timeout: 60000, // 60 seconds for media uploads + ...gaxiosOptions, + timeout: 60000, // 60 seconds for media uploads }; diff --git a/workspace-server/src/utils/MimeHelper.ts b/workspace-server/src/utils/MimeHelper.ts index f67fff7..14e4f4f 100644 --- a/workspace-server/src/utils/MimeHelper.ts +++ b/workspace-server/src/utils/MimeHelper.ts @@ -8,181 +8,194 @@ * Helper class for creating RFC 2822 compliant MIME messages for Gmail API */ export class MimeHelper { - /** - * Creates a base64url-encoded MIME message for Gmail API - */ - public static createMimeMessage({ - to, - subject, - body, - from, - cc, - bcc, - replyTo, - isHtml = false - }: { - to: string; - subject: string; - body: string; - from?: string; - cc?: string; - bcc?: string; - replyTo?: string; - isHtml?: boolean; - }): string { - // Encode subject for UTF-8 support - const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`; - - // Build message headers - const messageParts: string[] = []; - - // Add From header if provided, otherwise Gmail will use the authenticated user - if (from) { - messageParts.push(`From: ${from}`); - } - - messageParts.push(`To: ${to}`); - - if (cc) { - messageParts.push(`Cc: ${cc}`); - } - - if (bcc) { - messageParts.push(`Bcc: ${bcc}`); - } - - if (replyTo) { - messageParts.push(`Reply-To: ${replyTo}`); - } - - messageParts.push(`Subject: ${utf8Subject}`); - - // Add content type based on whether it's HTML or plain text - if (isHtml) { - messageParts.push('Content-Type: text/html; charset=utf-8'); - } else { - messageParts.push('Content-Type: text/plain; charset=utf-8'); - } - - messageParts.push(''); // Empty line between headers and body - messageParts.push(body); - - // Join all parts with CRLF as per RFC 2822 - const message = messageParts.join('\r\n'); - - // Encode to base64url format required by Gmail API - const encodedMessage = Buffer.from(message) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - return encodedMessage; + /** + * Creates a base64url-encoded MIME message for Gmail API + */ + public static createMimeMessage({ + to, + subject, + body, + from, + cc, + bcc, + replyTo, + isHtml = false, + }: { + to: string; + subject: string; + body: string; + from?: string; + cc?: string; + bcc?: string; + replyTo?: string; + isHtml?: boolean; + }): string { + // Encode subject for UTF-8 support + const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`; + + // Build message headers + const messageParts: string[] = []; + + // Add From header if provided, otherwise Gmail will use the authenticated user + if (from) { + messageParts.push(`From: ${from}`); + } + + messageParts.push(`To: ${to}`); + + if (cc) { + messageParts.push(`Cc: ${cc}`); + } + + if (bcc) { + messageParts.push(`Bcc: ${bcc}`); + } + + if (replyTo) { + messageParts.push(`Reply-To: ${replyTo}`); + } + + messageParts.push(`Subject: ${utf8Subject}`); + + // Add content type based on whether it's HTML or plain text + if (isHtml) { + messageParts.push('Content-Type: text/html; charset=utf-8'); + } else { + messageParts.push('Content-Type: text/plain; charset=utf-8'); + } + + messageParts.push(''); // Empty line between headers and body + messageParts.push(body); + + // Join all parts with CRLF as per RFC 2822 + const message = messageParts.join('\r\n'); + + // Encode to base64url format required by Gmail API + const encodedMessage = Buffer.from(message) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + return encodedMessage; + } + + /** + * Creates a MIME message with attachments + */ + public static createMimeMessageWithAttachments({ + to, + subject, + body, + from, + cc, + bcc, + attachments, + isHtml = false, + }: { + to: string; + subject: string; + body: string; + from?: string; + cc?: string; + bcc?: string; + attachments?: Array<{ + filename: string; + content: Buffer | string; + contentType?: string; + }>; + isHtml?: boolean; + }): string { + const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(7)}`; + const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`; + + const messageParts: string[] = []; + + // Headers + if (from) { + messageParts.push(`From: ${from}`); + } + messageParts.push(`To: ${to}`); + if (cc) { + messageParts.push(`Cc: ${cc}`); + } + if (bcc) { + messageParts.push(`Bcc: ${bcc}`); } - - /** - * Creates a MIME message with attachments - */ - public static createMimeMessageWithAttachments({ + messageParts.push(`Subject: ${utf8Subject}`); + messageParts.push('MIME-Version: 1.0'); + + if (!attachments || attachments.length === 0) { + // Simple message without attachments + return this.createMimeMessage({ to, subject, body, from, cc, bcc, - attachments, - isHtml = false - }: { - to: string; - subject: string; - body: string; - from?: string; - cc?: string; - bcc?: string; - attachments?: Array<{ - filename: string; - content: Buffer | string; - contentType?: string; - }>; - isHtml?: boolean; - }): string { - const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substring(7)}`; - const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`; - - const messageParts: string[] = []; - - // Headers - if (from) { - messageParts.push(`From: ${from}`); - } - messageParts.push(`To: ${to}`); - if (cc) { - messageParts.push(`Cc: ${cc}`); - } - if (bcc) { - messageParts.push(`Bcc: ${bcc}`); - } - messageParts.push(`Subject: ${utf8Subject}`); - messageParts.push('MIME-Version: 1.0'); - - if (!attachments || attachments.length === 0) { - // Simple message without attachments - return this.createMimeMessage({ to, subject, body, from, cc, bcc, isHtml }); - } - - // Multipart message with attachments - messageParts.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); - messageParts.push(''); - - // Body part - messageParts.push(`--${boundary}`); - if (isHtml) { - messageParts.push('Content-Type: text/html; charset=utf-8'); - } else { - messageParts.push('Content-Type: text/plain; charset=utf-8'); - } - messageParts.push(''); - messageParts.push(body); - - // Attachments - for (const attachment of attachments) { - messageParts.push(`--${boundary}`); - messageParts.push(`Content-Type: ${attachment.contentType || 'application/octet-stream'}`); - messageParts.push('Content-Transfer-Encoding: base64'); - messageParts.push(`Content-Disposition: attachment; filename="${attachment.filename}"`); - messageParts.push(''); - - const content = typeof attachment.content === 'string' - ? attachment.content - : attachment.content.toString('base64'); - - // Add content in chunks of 76 characters as per MIME spec - const chunks = content.match(/.{1,76}/g) || []; - messageParts.push(...chunks); - } - - // End boundary - messageParts.push(`--${boundary}--`); - - const message = messageParts.join('\r\n'); - - // Encode to base64url - return Buffer.from(message) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + isHtml, + }); + } + + // Multipart message with attachments + messageParts.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); + messageParts.push(''); + + // Body part + messageParts.push(`--${boundary}`); + if (isHtml) { + messageParts.push('Content-Type: text/html; charset=utf-8'); + } else { + messageParts.push('Content-Type: text/plain; charset=utf-8'); + } + messageParts.push(''); + messageParts.push(body); + + // Attachments + for (const attachment of attachments) { + messageParts.push(`--${boundary}`); + messageParts.push( + `Content-Type: ${attachment.contentType || 'application/octet-stream'}`, + ); + messageParts.push('Content-Transfer-Encoding: base64'); + messageParts.push( + `Content-Disposition: attachment; filename="${attachment.filename}"`, + ); + messageParts.push(''); + + const content = + typeof attachment.content === 'string' + ? attachment.content + : attachment.content.toString('base64'); + + // Add content in chunks of 76 characters as per MIME spec + const chunks = content.match(/.{1,76}/g) || []; + messageParts.push(...chunks); } - - /** - * Decodes a base64url-encoded string (inverse of encoding) - */ - public static decodeBase64Url(encoded: string): string { - // Add back padding if needed - let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - while (base64.length % 4) { - base64 += '='; - } - return Buffer.from(base64, 'base64').toString('utf-8'); + + // End boundary + messageParts.push(`--${boundary}--`); + + const message = messageParts.join('\r\n'); + + // Encode to base64url + return Buffer.from(message) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + /** + * Decodes a base64url-encoded string (inverse of encoding) + */ + public static decodeBase64Url(encoded: string): string { + // Add back padding if needed + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + while (base64.length % 4) { + base64 += '='; } -} \ No newline at end of file + return Buffer.from(base64, 'base64').toString('utf-8'); + } +} diff --git a/workspace-server/src/utils/logger.ts b/workspace-server/src/utils/logger.ts index d52c2d3..b39bb7a 100644 --- a/workspace-server/src/utils/logger.ts +++ b/workspace-server/src/utils/logger.ts @@ -11,12 +11,12 @@ import { PROJECT_ROOT } from './paths'; const logFilePath = path.join(PROJECT_ROOT, 'logs', 'server.log'); async function ensureLogDirectoryExists() { - try { - await fs.mkdir(path.dirname(logFilePath), { recursive: true }); - } catch (error) { - // If we can't create the log directory, log to console as a fallback. - console.error('Could not create log directory:', error); - } + try { + await fs.mkdir(path.dirname(logFilePath), { recursive: true }); + } catch (error) { + // If we can't create the log directory, log to console as a fallback. + console.error('Could not create log directory:', error); + } } // Ensure the directory exists when the module is loaded. @@ -25,18 +25,18 @@ ensureLogDirectoryExists(); let isLoggingEnabled = false; export function setLoggingEnabled(enabled: boolean) { - isLoggingEnabled = enabled; + isLoggingEnabled = enabled; } export function logToFile(message: string) { - if (!isLoggingEnabled) { - return; - } - const timestamp = new Date().toISOString(); - const logMessage = `${timestamp} - ${message}\n`; - - fs.appendFile(logFilePath, logMessage).catch(err => { - // Fallback to console if file logging fails - console.error('Failed to write to log file:', err); - }); + if (!isLoggingEnabled) { + return; + } + const timestamp = new Date().toISOString(); + const logMessage = `${timestamp} - ${message}\n`; + + fs.appendFile(logFilePath, logMessage).catch((err) => { + // Fallback to console if file logging fails + console.error('Failed to write to log file:', err); + }); } diff --git a/workspace-server/src/utils/markdownToDocsRequests.ts b/workspace-server/src/utils/markdownToDocsRequests.ts index 6a5b21f..1b4a45f 100644 --- a/workspace-server/src/utils/markdownToDocsRequests.ts +++ b/workspace-server/src/utils/markdownToDocsRequests.ts @@ -9,231 +9,252 @@ import { marked } from 'marked'; import { JSDOM } from 'jsdom'; interface FormatRange { - start: number; - end: number; - type: 'bold' | 'italic' | 'code' | 'link' | 'heading'; - url?: string; - headingLevel?: number; - isParagraph?: boolean; + start: number; + end: number; + type: 'bold' | 'italic' | 'code' | 'link' | 'heading'; + url?: string; + headingLevel?: number; + isParagraph?: boolean; } interface ParsedMarkdown { - plainText: string; - formattingRequests: docs_v1.Schema$Request[]; + plainText: string; + formattingRequests: docs_v1.Schema$Request[]; } /** * Parses markdown text and generates Google Docs API requests for formatting. * Uses the marked library to convert to HTML, then parses the HTML to extract formatting. */ -export function parseMarkdownToDocsRequests(markdown: string, startIndex: number): ParsedMarkdown { - // Split markdown into lines to handle block elements like headings - const lines = markdown.split('\n'); - const htmlParts: string[] = []; - - for (const line of lines) { - // Check if this is a heading line - const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headingMatch) { - const level = headingMatch[1].length; - const content = headingMatch[2]; - // Parse inline content within the heading - try { - const inlineHtml = marked.parseInline(content) as string; - htmlParts.push(`${inlineHtml}`); - } catch (error) { - console.error('Markdown parsing failed for heading, falling back to raw content:', error); - htmlParts.push(`${content}`); - } - } else if (line.trim()) { - // For non-heading, non-empty lines, use parseInline - try { - const inlineHtml = marked.parseInline(line) as string; - htmlParts.push(`

${inlineHtml}

`); - } catch (error) { - console.error('Markdown parsing failed for line, falling back to raw content:', error); - htmlParts.push(`

${line}

`); - } - } else { - // Empty lines become paragraph breaks - htmlParts.push(''); - } +export function parseMarkdownToDocsRequests( + markdown: string, + startIndex: number, +): ParsedMarkdown { + // Split markdown into lines to handle block elements like headings + const lines = markdown.split('\n'); + const htmlParts: string[] = []; + + for (const line of lines) { + // Check if this is a heading line + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + const level = headingMatch[1].length; + const content = headingMatch[2]; + // Parse inline content within the heading + try { + const inlineHtml = marked.parseInline(content) as string; + htmlParts.push(`${inlineHtml}`); + } catch (error) { + console.error( + 'Markdown parsing failed for heading, falling back to raw content:', + error, + ); + htmlParts.push(`${content}`); + } + } else if (line.trim()) { + // For non-heading, non-empty lines, use parseInline + try { + const inlineHtml = marked.parseInline(line) as string; + htmlParts.push(`

${inlineHtml}

`); + } catch (error) { + console.error( + 'Markdown parsing failed for line, falling back to raw content:', + error, + ); + htmlParts.push(`

${line}

`); + } + } else { + // Empty lines become paragraph breaks + htmlParts.push(''); } + } - // Convert markdown to HTML - handle both block and inline elements - const html = htmlParts.join('\n'); + // Convert markdown to HTML - handle both block and inline elements + const html = htmlParts.join('\n'); - // If no conversion happened, return plain text - if (!html || html === markdown) { - return { - plainText: markdown, - formattingRequests: [] - }; - } + // If no conversion happened, return plain text + if (!html || html === markdown) { + return { + plainText: markdown, + formattingRequests: [], + }; + } + + // Parse HTML to extract text and formatting + // Create a wrapper div to handle inline HTML that might not have a parent element + const dom = new JSDOM(`
${html}
`); + const document = dom.window.document; + const wrapper = document.querySelector('div'); - // Parse HTML to extract text and formatting - // Create a wrapper div to handle inline HTML that might not have a parent element - const dom = new JSDOM(`
${html}
`); - const document = dom.window.document; - const wrapper = document.querySelector('div'); - - const formattingRanges: FormatRange[] = []; - let plainText = ''; - let currentPos = 0; - - // Recursive function to process nodes - function processNode(node: Node) { - if (node.nodeType === 3) { // Text node - const text = node.textContent || ''; - plainText += text; - currentPos += text.length; - } else if (node.nodeType === 1) { // Element node - const element = node as HTMLElement; - const tagName = element.tagName.toLowerCase(); - - const start = currentPos; - - // Process children first to get the text content - for (const child of Array.from(node.childNodes)) { - processNode(child); - } - - const end = currentPos; - - // Record formatting based on tag - if (tagName === 'strong' || tagName === 'b') { - formattingRanges.push({ start, end, type: 'bold' }); - } else if (tagName === 'em' || tagName === 'i') { - formattingRanges.push({ start, end, type: 'italic' }); - } else if (tagName === 'code') { - formattingRanges.push({ start, end, type: 'code' }); - } else if (tagName === 'a') { - const href = element.getAttribute('href') || ''; - formattingRanges.push({ start, end, type: 'link', url: href }); - } else if (tagName.match(/^h[1-6]$/)) { - const level = parseInt(tagName.charAt(1)); - // Mark the entire paragraph range for heading style - formattingRanges.push({ start, end, type: 'heading', headingLevel: level, isParagraph: true }); - } else if (tagName === 'p') { - // Add newline after paragraph content if not the last element - const nextSibling = element.nextSibling; - if (nextSibling && nextSibling.nodeType === 1) { - plainText += '\n'; - currentPos += 1; - } - } + const formattingRanges: FormatRange[] = []; + let plainText = ''; + let currentPos = 0; + + // Recursive function to process nodes + function processNode(node: Node) { + if (node.nodeType === 3) { + // Text node + const text = node.textContent || ''; + plainText += text; + currentPos += text.length; + } else if (node.nodeType === 1) { + // Element node + const element = node as HTMLElement; + const tagName = element.tagName.toLowerCase(); + + const start = currentPos; + + // Process children first to get the text content + for (const child of Array.from(node.childNodes)) { + processNode(child); + } + + const end = currentPos; + + // Record formatting based on tag + if (tagName === 'strong' || tagName === 'b') { + formattingRanges.push({ start, end, type: 'bold' }); + } else if (tagName === 'em' || tagName === 'i') { + formattingRanges.push({ start, end, type: 'italic' }); + } else if (tagName === 'code') { + formattingRanges.push({ start, end, type: 'code' }); + } else if (tagName === 'a') { + const href = element.getAttribute('href') || ''; + formattingRanges.push({ start, end, type: 'link', url: href }); + } else if (tagName.match(/^h[1-6]$/)) { + const level = parseInt(tagName.charAt(1)); + // Mark the entire paragraph range for heading style + formattingRanges.push({ + start, + end, + type: 'heading', + headingLevel: level, + isParagraph: true, + }); + } else if (tagName === 'p') { + // Add newline after paragraph content if not the last element + const nextSibling = element.nextSibling; + if (nextSibling && nextSibling.nodeType === 1) { + plainText += '\n'; + currentPos += 1; } + } } + } - // Process all nodes - if (wrapper) { - for (const child of Array.from(wrapper.childNodes)) { - processNode(child); - } - } else { - // If parsing failed, just use the plain markdown (no formatting) - plainText = markdown; + // Process all nodes + if (wrapper) { + for (const child of Array.from(wrapper.childNodes)) { + processNode(child); } + } else { + // If parsing failed, just use the plain markdown (no formatting) + plainText = markdown; + } - // Generate formatting requests - const formattingRequests: docs_v1.Schema$Request[] = []; - - for (const range of formattingRanges) { - const textStyle: docs_v1.Schema$TextStyle = {}; - const fields: string[] = []; - - if (range.type === 'bold') { - textStyle.bold = true; - fields.push('bold'); - } else if (range.type === 'italic') { - textStyle.italic = true; - fields.push('italic'); - } else if (range.type === 'code') { - textStyle.weightedFontFamily = { - fontFamily: 'Courier New', - weight: 400 - }; - textStyle.backgroundColor = { - color: { - rgbColor: { - red: 0.95, - green: 0.95, - blue: 0.95 - } - } - }; - fields.push('weightedFontFamily', 'backgroundColor'); - } else if (range.type === 'link' && range.url) { - textStyle.link = { - url: range.url - }; - textStyle.foregroundColor = { - color: { - rgbColor: { - red: 0.06, - green: 0.33, - blue: 0.80 - } - } - }; - textStyle.underline = true; - fields.push('link', 'foregroundColor', 'underline'); - } else if (range.type === 'heading' && range.headingLevel && range.isParagraph) { - // Use updateParagraphStyle for headings as per Google Docs API best practices - const headingStyles: { [key: number]: string } = { - 1: 'HEADING_1', - 2: 'HEADING_2', - 3: 'HEADING_3', - 4: 'HEADING_4', - 5: 'HEADING_5', - 6: 'HEADING_6' - }; - - const namedStyleType = headingStyles[range.headingLevel] || 'HEADING_1'; - - // Create a separate updateParagraphStyle request for headings - formattingRequests.push({ - updateParagraphStyle: { - paragraphStyle: { - namedStyleType: namedStyleType - }, - range: { - startIndex: startIndex + range.start, - endIndex: startIndex + range.end - }, - fields: 'namedStyleType' - } - }); - - // Skip the normal text style formatting for headings - continue; - } + // Generate formatting requests + const formattingRequests: docs_v1.Schema$Request[] = []; - if (fields.length > 0) { - formattingRequests.push({ - updateTextStyle: { - range: { - startIndex: startIndex + range.start, - endIndex: startIndex + range.end - }, - textStyle: textStyle, - fields: fields.join(',') - } - }); - } + for (const range of formattingRanges) { + const textStyle: docs_v1.Schema$TextStyle = {}; + const fields: string[] = []; + + if (range.type === 'bold') { + textStyle.bold = true; + fields.push('bold'); + } else if (range.type === 'italic') { + textStyle.italic = true; + fields.push('italic'); + } else if (range.type === 'code') { + textStyle.weightedFontFamily = { + fontFamily: 'Courier New', + weight: 400, + }; + textStyle.backgroundColor = { + color: { + rgbColor: { + red: 0.95, + green: 0.95, + blue: 0.95, + }, + }, + }; + fields.push('weightedFontFamily', 'backgroundColor'); + } else if (range.type === 'link' && range.url) { + textStyle.link = { + url: range.url, + }; + textStyle.foregroundColor = { + color: { + rgbColor: { + red: 0.06, + green: 0.33, + blue: 0.8, + }, + }, + }; + textStyle.underline = true; + fields.push('link', 'foregroundColor', 'underline'); + } else if ( + range.type === 'heading' && + range.headingLevel && + range.isParagraph + ) { + // Use updateParagraphStyle for headings as per Google Docs API best practices + const headingStyles: { [key: number]: string } = { + 1: 'HEADING_1', + 2: 'HEADING_2', + 3: 'HEADING_3', + 4: 'HEADING_4', + 5: 'HEADING_5', + 6: 'HEADING_6', + }; + + const namedStyleType = headingStyles[range.headingLevel] || 'HEADING_1'; + + // Create a separate updateParagraphStyle request for headings + formattingRequests.push({ + updateParagraphStyle: { + paragraphStyle: { + namedStyleType: namedStyleType, + }, + range: { + startIndex: startIndex + range.start, + endIndex: startIndex + range.end, + }, + fields: 'namedStyleType', + }, + }); + + // Skip the normal text style formatting for headings + continue; } - return { - plainText, - formattingRequests - }; + if (fields.length > 0) { + formattingRequests.push({ + updateTextStyle: { + range: { + startIndex: startIndex + range.start, + endIndex: startIndex + range.end, + }, + textStyle: textStyle, + fields: fields.join(','), + }, + }); + } + } + + return { + plainText, + formattingRequests, + }; } /** * Handles line breaks and paragraphs in markdown text */ export function processMarkdownLineBreaks(text: string): string { - // Convert double line breaks to paragraph breaks - // Single line breaks remain as-is - return text.replace(/\n\n+/g, '\n\n'); -} \ No newline at end of file + // Convert double line breaks to paragraph breaks + // Single line breaks remain as-is + return text.replace(/\n\n+/g, '\n\n'); +} diff --git a/workspace-server/src/utils/open-wrapper.ts b/workspace-server/src/utils/open-wrapper.ts index 3575e86..e23bec7 100644 --- a/workspace-server/src/utils/open-wrapper.ts +++ b/workspace-server/src/utils/open-wrapper.ts @@ -11,7 +11,10 @@ * 2. Prints the URL to console if browser launch should be skipped or fails */ -import { openBrowserSecurely, shouldLaunchBrowser } from './secure-browser-launcher'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from './secure-browser-launcher'; // Create a mock child process object that matches what open returns const createMockChildProcess = () => ({ @@ -33,7 +36,9 @@ const createMockChildProcess = () => ({ const openWrapper = async (url: string): Promise => { // Check if we should launch the browser if (!shouldLaunchBrowser()) { - console.log(`Browser launch not supported. Please open this URL in your browser: ${url}`); + console.log( + `Browser launch not supported. Please open this URL in your browser: ${url}`, + ); return createMockChildProcess(); } @@ -42,10 +47,12 @@ const openWrapper = async (url: string): Promise => { await openBrowserSecurely(url); return createMockChildProcess(); } catch { - console.log(`Failed to open browser. Please open this URL in your browser: ${url}`); + console.log( + `Failed to open browser. Please open this URL in your browser: ${url}`, + ); return createMockChildProcess(); } }; // Use standard ES Module export and let the compiler generate the CommonJS correct output. -export default openWrapper; \ No newline at end of file +export default openWrapper; diff --git a/workspace-server/src/utils/secure-browser-launcher.ts b/workspace-server/src/utils/secure-browser-launcher.ts index 6126d44..3050ee1 100644 --- a/workspace-server/src/utils/secure-browser-launcher.ts +++ b/workspace-server/src/utils/secure-browser-launcher.ts @@ -1,232 +1,233 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { execFile, ExecFileOptions } from 'node:child_process'; -import { platform } from 'node:os'; -import { URL } from 'node:url'; - - -function withTimeout(promise: Promise, ms: number): Promise { - let timeoutId: NodeJS.Timeout; - const timeout = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Timeout')), ms); - }); - return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId)); -} - -/** - * Validates that a URL is safe to open in a browser. - * Only allows HTTP and HTTPS URLs to prevent command injection. - * - * @param url The URL to validate - * @throws Error if the URL is invalid or uses an unsafe protocol - */ -function validateUrl(url: string): void { - let parsedUrl: URL; - - try { - parsedUrl = new URL(url); - } catch { - throw new Error('Invalid URL'); - } - - // Only allow HTTP and HTTPS protocols - if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { - throw new Error( - `Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.` - ); - } - - // Additional validation: ensure no newlines or control characters - if (/[\r\n\x00-\x1F]/.test(url)) { - throw new Error('URL contains invalid characters'); - } -} - -/** - * Opens a URL in the default browser using platform-specific commands. - * This implementation avoids shell injection vulnerabilities by: - * 1. Validating the URL to ensure it's HTTP/HTTPS only - * 2. Using execFile instead of exec to avoid shell interpretation - * 3. Passing the URL as an argument rather than constructing a command string - * - * @param url The URL to open - * @param execFileFn The function to execute a command. Defaults to node's execFile. - * @throws Error if the URL is invalid or if opening the browser fails - */ -export async function openBrowserSecurely( - url: string, - execFileFn: typeof execFile = execFile -): Promise { - // Validate the URL first - validateUrl(url); - - const platformName = platform(); - let command: string; - let args: string[]; - - switch (platformName) { - case 'darwin': - // macOS - command = 'open'; - args = [url]; - break; - - case 'win32': - // Windows - use PowerShell with Start-Process - // This avoids the cmd.exe shell which is vulnerable to injection - command = 'powershell.exe'; - args = [ - '-NoProfile', - '-NonInteractive', - '-WindowStyle', - 'Hidden', - '-Command', - `Start-Process '${url.replace(/'/g, "''")}'`, - ]; - break; - - case 'linux': - case 'freebsd': - case 'openbsd': - // Linux and BSD variants - // Try xdg-open first, fall back to other options - command = 'xdg-open'; - args = [url]; - break; - - default: - throw new Error(`Unsupported platform: ${platformName}`); - } - - const options: Record = { - // Don't inherit parent's environment to avoid potential issues - env: { - ...process.env, - // Ensure we're not in a shell that might interpret special characters - SHELL: undefined, - }, - // Detach the browser process so it doesn't block - detached: true, - stdio: 'ignore', - }; - - const tryCommand = (cmd: string, cmdArgs: string[]): Promise => { - return new Promise((resolve, reject) => { - const child = execFileFn( - cmd, - cmdArgs, - options as ExecFileOptions, - (error) => { - if (error) { - // This callback handles errors after the process has run, - // but for our case, the 'error' event is more important for spawn failures. - reject(error); - } - } - ); - - // The 'error' event is critical. It fires if the command cannot be found or spawned. - child.on('error', (error) => { - reject(error); - }); - - // If the process spawns successfully, 'xdg-open' and similar commands - // exit almost immediately. We don't need to wait for the browser to close. - // We can consider the job done if the process exits with code 0. - child.on('exit', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Process exited with code ${code}`)); - } - }); - }); - }; - - try { - await withTimeout(tryCommand(command, args), 5000); - } catch (error) { - // For Linux, try fallback commands if xdg-open fails - if ( - (platformName === 'linux' || - platformName === 'freebsd' || - platformName === 'openbsd') && - command === 'xdg-open' - ) { - const fallbackCommands = [ - 'gnome-open', - 'kde-open', - 'firefox', - 'chromium', - 'google-chrome', - ]; - - for (const fallbackCommand of fallbackCommands) { - try { - await withTimeout(tryCommand(fallbackCommand, [url]), 5000); - return; // Success! - } catch { - // Try next command - continue; - } - } - } - - // Re-throw the error if all attempts failed - throw new Error( - `Failed to open browser: ${ - error instanceof Error ? error.message : 'Unknown error' - }` - ); - } -} - -/** - * Checks if the current environment should attempt to launch a browser. - * This is the same logic as in browser.ts for consistency. - * - * @returns True if the tool should attempt to launch a browser - */ -export function shouldLaunchBrowser(): boolean { - // A list of browser names that indicate we should not attempt to open a - // web browser for the user. - const browserBlocklist = ['www-browser']; - const browserEnv = process.env.BROWSER; - if (browserEnv && browserBlocklist.includes(browserEnv)) { - return false; - } - - // Common environment variables used in CI/CD or other non-interactive shells. - if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') { - return false; - } - - // The presence of SSH_CONNECTION indicates a remote session. - // We should not attempt to launch a browser unless a display is explicitly available - // (checked below for Linux). - const isSSH = !!process.env.SSH_CONNECTION; - - // On Linux, the presence of a display server is a strong indicator of a GUI. - if (platform() === 'linux') { - // These are environment variables that can indicate a running compositor on Linux. - const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET']; - const hasDisplay = displayVariables.some((v) => !!process.env[v]); - if (!hasDisplay) { - return false; - } - } - - // If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser. - // The Linux case is handled above (it's allowed if DISPLAY is set). - if (isSSH && platform() !== 'linux') { - return false; - } - - // For non-Linux OSes, we generally assume a GUI is available - // unless other signals (like SSH) suggest otherwise. - return true; -} +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile, ExecFileOptions } from 'node:child_process'; +import { platform } from 'node:os'; +import { URL } from 'node:url'; + +function withTimeout(promise: Promise, ms: number): Promise { + let timeoutId: NodeJS.Timeout; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Timeout')), ms); + }); + return Promise.race([promise, timeout]).finally(() => + clearTimeout(timeoutId), + ); +} + +/** + * Validates that a URL is safe to open in a browser. + * Only allows HTTP and HTTPS URLs to prevent command injection. + * + * @param url The URL to validate + * @throws Error if the URL is invalid or uses an unsafe protocol + */ +function validateUrl(url: string): void { + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + throw new Error('Invalid URL'); + } + + // Only allow HTTP and HTTPS protocols + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + throw new Error( + `Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`, + ); + } + + // Additional validation: ensure no newlines or control characters + if (/[\r\n\x00-\x1F]/.test(url)) { + throw new Error('URL contains invalid characters'); + } +} + +/** + * Opens a URL in the default browser using platform-specific commands. + * This implementation avoids shell injection vulnerabilities by: + * 1. Validating the URL to ensure it's HTTP/HTTPS only + * 2. Using execFile instead of exec to avoid shell interpretation + * 3. Passing the URL as an argument rather than constructing a command string + * + * @param url The URL to open + * @param execFileFn The function to execute a command. Defaults to node's execFile. + * @throws Error if the URL is invalid or if opening the browser fails + */ +export async function openBrowserSecurely( + url: string, + execFileFn: typeof execFile = execFile, +): Promise { + // Validate the URL first + validateUrl(url); + + const platformName = platform(); + let command: string; + let args: string[]; + + switch (platformName) { + case 'darwin': + // macOS + command = 'open'; + args = [url]; + break; + + case 'win32': + // Windows - use PowerShell with Start-Process + // This avoids the cmd.exe shell which is vulnerable to injection + command = 'powershell.exe'; + args = [ + '-NoProfile', + '-NonInteractive', + '-WindowStyle', + 'Hidden', + '-Command', + `Start-Process '${url.replace(/'/g, "''")}'`, + ]; + break; + + case 'linux': + case 'freebsd': + case 'openbsd': + // Linux and BSD variants + // Try xdg-open first, fall back to other options + command = 'xdg-open'; + args = [url]; + break; + + default: + throw new Error(`Unsupported platform: ${platformName}`); + } + + const options: Record = { + // Don't inherit parent's environment to avoid potential issues + env: { + ...process.env, + // Ensure we're not in a shell that might interpret special characters + SHELL: undefined, + }, + // Detach the browser process so it doesn't block + detached: true, + stdio: 'ignore', + }; + + const tryCommand = (cmd: string, cmdArgs: string[]): Promise => { + return new Promise((resolve, reject) => { + const child = execFileFn( + cmd, + cmdArgs, + options as ExecFileOptions, + (error) => { + if (error) { + // This callback handles errors after the process has run, + // but for our case, the 'error' event is more important for spawn failures. + reject(error); + } + }, + ); + + // The 'error' event is critical. It fires if the command cannot be found or spawned. + child.on('error', (error) => { + reject(error); + }); + + // If the process spawns successfully, 'xdg-open' and similar commands + // exit almost immediately. We don't need to wait for the browser to close. + // We can consider the job done if the process exits with code 0. + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Process exited with code ${code}`)); + } + }); + }); + }; + + try { + await withTimeout(tryCommand(command, args), 5000); + } catch (error) { + // For Linux, try fallback commands if xdg-open fails + if ( + (platformName === 'linux' || + platformName === 'freebsd' || + platformName === 'openbsd') && + command === 'xdg-open' + ) { + const fallbackCommands = [ + 'gnome-open', + 'kde-open', + 'firefox', + 'chromium', + 'google-chrome', + ]; + + for (const fallbackCommand of fallbackCommands) { + try { + await withTimeout(tryCommand(fallbackCommand, [url]), 5000); + return; // Success! + } catch { + // Try next command + continue; + } + } + } + + // Re-throw the error if all attempts failed + throw new Error( + `Failed to open browser: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } +} + +/** + * Checks if the current environment should attempt to launch a browser. + * This is the same logic as in browser.ts for consistency. + * + * @returns True if the tool should attempt to launch a browser + */ +export function shouldLaunchBrowser(): boolean { + // A list of browser names that indicate we should not attempt to open a + // web browser for the user. + const browserBlocklist = ['www-browser']; + const browserEnv = process.env.BROWSER; + if (browserEnv && browserBlocklist.includes(browserEnv)) { + return false; + } + + // Common environment variables used in CI/CD or other non-interactive shells. + if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') { + return false; + } + + // The presence of SSH_CONNECTION indicates a remote session. + // We should not attempt to launch a browser unless a display is explicitly available + // (checked below for Linux). + const isSSH = !!process.env.SSH_CONNECTION; + + // On Linux, the presence of a display server is a strong indicator of a GUI. + if (platform() === 'linux') { + // These are environment variables that can indicate a running compositor on Linux. + const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET']; + const hasDisplay = displayVariables.some((v) => !!process.env[v]); + if (!hasDisplay) { + return false; + } + } + + // If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser. + // The Linux case is handled above (it's allowed if DISPLAY is set). + if (isSSH && platform() !== 'linux') { + return false; + } + + // For non-Linux OSes, we generally assume a GUI is available + // unless other signals (like SSH) suggest otherwise. + return true; +} diff --git a/workspace-server/src/utils/validation.ts b/workspace-server/src/utils/validation.ts index 8442a25..a122d79 100644 --- a/workspace-server/src/utils/validation.ts +++ b/workspace-server/src/utils/validation.ts @@ -15,10 +15,7 @@ export const emailSchema = z.string().email('Invalid email format'); /** * Validates multiple email addresses (for CC/BCC fields) */ -export const emailArraySchema = z.union([ - emailSchema, - z.array(emailSchema) -]); +export const emailArraySchema = z.union([emailSchema, z.array(emailSchema)]); /** * ISO 8601 datetime validation schema @@ -28,145 +25,157 @@ export const emailArraySchema = z.union([ * - 2024-01-15T10:30:00.000Z */ export const iso8601DateTimeSchema = z.string().refine( - (val) => { - const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|[+-]\d{2}:\d{2})$/; - if (!iso8601Regex.test(val)) return false; - - // Additional check: ensure it's a valid date - const date = new Date(val); - return !isNaN(date.getTime()); - }, - { - message: 'Invalid ISO 8601 datetime format. Expected format: YYYY-MM-DDTHH:mm:ss[.sss][Z|±HH:mm]' - } + (val) => { + const iso8601Regex = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?(Z|[+-]\d{2}:\d{2})$/; + if (!iso8601Regex.test(val)) return false; + + // Additional check: ensure it's a valid date + const date = new Date(val); + return !isNaN(date.getTime()); + }, + { + message: + 'Invalid ISO 8601 datetime format. Expected format: YYYY-MM-DDTHH:mm:ss[.sss][Z|±HH:mm]', + }, ); /** * Google Drive document/file ID validation * Google IDs are typically alphanumeric strings with hyphens and underscores */ -export const googleDocumentIdSchema = z.string().regex( +export const googleDocumentIdSchema = z + .string() + .regex( /^[a-zA-Z0-9_-]+$/, - 'Invalid document ID format. Document IDs should only contain letters, numbers, hyphens, and underscores' -); + 'Invalid document ID format. Document IDs should only contain letters, numbers, hyphens, and underscores', + ); /** * Google Drive URL validation * Accepts various Google Workspace URLs and extracts the document ID */ -export const googleWorkspaceUrlSchema = z.string().regex( +export const googleWorkspaceUrlSchema = z + .string() + .regex( /^https:\/\/(docs|drive|sheets|slides)\.google\.com\/.+\/d\/([a-zA-Z0-9_-]+)/, - 'Invalid Google Workspace URL format' -); + 'Invalid Google Workspace URL format', + ); /** * Folder name validation * Prevents problematic characters in folder names */ -export const folderNameSchema = z.string() - .min(1, 'Folder name cannot be empty') - .max(255, 'Folder name too long (max 255 characters)') - .refine( - (val) => !(/[<>:"/\\|?*\x00-\x1F]/.test(val)), - 'Folder name contains invalid characters' - ); +export const folderNameSchema = z + .string() + .min(1, 'Folder name cannot be empty') + .max(255, 'Folder name too long (max 255 characters)') + .refine( + (val) => !/[<>:"/\\|?*\x00-\x1F]/.test(val), + 'Folder name contains invalid characters', + ); /** * Calendar ID validation * Can be 'primary' or an email address */ -export const calendarIdSchema = z.union([ - z.literal('primary'), - emailSchema -]); +export const calendarIdSchema = z.union([z.literal('primary'), emailSchema]); /** * Search query sanitization * Escapes potentially dangerous characters from search queries * Preserves quotes for exact phrase searching */ -export const searchQuerySchema = z.string() - .transform((val) => { - // Escape backslashes first, then escape quotes - // This preserves the ability to search for exact phrases - return val - .replace(/\\/g, '\\\\') // Escape backslashes - .replace(/'/g, "\\'") // Escape single quotes - .replace(/"/g, '\\"'); // Escape double quotes - }); +export const searchQuerySchema = z.string().transform((val) => { + // Escape backslashes first, then escape quotes + // This preserves the ability to search for exact phrases + return val + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/'/g, "\\'") // Escape single quotes + .replace(/"/g, '\\"'); // Escape double quotes +}); /** * Page size validation for pagination */ -export const pageSizeSchema = z.number() - .int('Page size must be an integer') - .min(1, 'Page size must be at least 1') - .max(100, 'Page size cannot exceed 100'); - +export const pageSizeSchema = z + .number() + .int('Page size must be an integer') + .min(1, 'Page size must be at least 1') + .max(100, 'Page size cannot exceed 100'); /** * Helper function to create a validator from a Zod schema */ function createValidator( - schema: z.ZodSchema, - fallbackErrorMessage: string + schema: z.ZodSchema, + fallbackErrorMessage: string, ) { - return (value: unknown): { success: boolean; error?: string } => { - try { - schema.parse(value); - return { success: true }; - } catch (error) { - if (error instanceof z.ZodError) { - return { success: false, error: error.issues[0].message }; - } - return { success: false, error: fallbackErrorMessage }; - } - }; + return (value: unknown): { success: boolean; error?: string } => { + try { + schema.parse(value); + return { success: true }; + } catch (error) { + if (error instanceof z.ZodError) { + return { success: false, error: error.issues[0].message }; + } + return { success: false, error: fallbackErrorMessage }; + } + }; } /** * Helper function to validate email */ -export const validateEmail = createValidator(emailSchema, 'Invalid email format'); +export const validateEmail = createValidator( + emailSchema, + 'Invalid email format', +); /** * Helper function to validate ISO 8601 datetime */ -export const validateDateTime = createValidator(iso8601DateTimeSchema, 'Invalid datetime format'); +export const validateDateTime = createValidator( + iso8601DateTimeSchema, + 'Invalid datetime format', +); /** * Helper function to validate Google document ID */ -export const validateDocumentId = createValidator(googleDocumentIdSchema, 'Invalid document ID'); +export const validateDocumentId = createValidator( + googleDocumentIdSchema, + 'Invalid document ID', +); /** * Helper function to extract document ID from URL or return the ID if already valid */ export function extractDocumentId(urlOrId: string): string { - // First check if it's already a valid ID - if (googleDocumentIdSchema.safeParse(urlOrId).success) { - return urlOrId; - } - - // Try to extract from URL - const urlMatch = urlOrId.match(/\/d\/([a-zA-Z0-9_-]+)/); - if (urlMatch && urlMatch[1]) { - return urlMatch[1]; - } - - throw new Error('Invalid document ID or URL'); + // First check if it's already a valid ID + if (googleDocumentIdSchema.safeParse(urlOrId).success) { + return urlOrId; + } + + // Try to extract from URL + const urlMatch = urlOrId.match(/\/d\/([a-zA-Z0-9_-]+)/); + if (urlMatch && urlMatch[1]) { + return urlMatch[1]; + } + + throw new Error('Invalid document ID or URL'); } /** * Validation error class for consistent error handling */ export class ValidationError extends Error { - constructor( - message: string, - public field: string, - public value: unknown - ) { - super(message); - this.name = 'ValidationError'; - } -} \ No newline at end of file + constructor( + message: string, + public field: string, + public value: unknown, + ) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/workspace-server/tsconfig.json b/workspace-server/tsconfig.json index 83e6012..21e1259 100644 --- a/workspace-server/tsconfig.json +++ b/workspace-server/tsconfig.json @@ -4,13 +4,6 @@ "rootDir": "./src", "outDir": "./dist" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "dist", - "node_modules", - "src/**/*.test.ts", - "src/**/*.spec.ts" - ] -} \ No newline at end of file + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/workspace-server/tsconfig.test.json b/workspace-server/tsconfig.test.json index 4885ae8..c45f72a 100644 --- a/workspace-server/tsconfig.test.json +++ b/workspace-server/tsconfig.test.json @@ -4,8 +4,5 @@ "strict": false, "noImplicitAny": false }, - "include": [ - "src/**/*.test.ts", - "src/**/*.spec.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.test.ts", "src/**/*.spec.ts"] +} From 07d4e515725755a278c7990cee5fe05e3373d6ed Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Jan 2026 15:12:52 -0800 Subject: [PATCH 4/7] Run npm run format:fix --- .github/dependabot.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cdcfede..d273031 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,7 +8,7 @@ updates: groups: npm-root: patterns: - - "*" + - '*' labels: - 'dependencies' - 'npm' @@ -24,7 +24,7 @@ updates: groups: npm-workspace-server: patterns: - - "*" + - '*' labels: - 'dependencies' - 'npm' @@ -40,7 +40,7 @@ updates: groups: github-actions: patterns: - - "*" + - '*' labels: - 'dependencies' - 'github-actions' From 1904ebb6c78b1ade68ba4a47f8874f7fa72ab698 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Jan 2026 15:21:40 -0800 Subject: [PATCH 5/7] Running npm run format:fix --- docs/development.md | 8 +++++-- .../src/__tests__/tool-normalization.test.ts | 24 +++++++++++++++---- .../src/utils/tool-normalization.ts | 14 +++++++---- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/development.md b/docs/development.md index 71a30da..fb1e21b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -141,9 +141,13 @@ gemini --debug ### Tool Naming -Tool names in source use dot notation (e.g., `docs.create`) for logical grouping. By default, these are normalized to underscores at runtime (e.g., `docs_create`) for compatibility with a broader set of applications that use MCP including Google Antigravity. +Tool names in source use dot notation (e.g., `docs.create`) for logical +grouping. By default, these are normalized to underscores at runtime (e.g., +`docs_create`) for compatibility with a broader set of applications that use MCP +including Google Antigravity. -When the server is run as a Gemini CLI extension the `--use-dot-names` flag is used to maintain dot notation and avoid breaking existing configurations. +When the server is run as a Gemini CLI extension the `--use-dot-names` flag is +used to maintain dot notation and avoid breaking existing configurations. ### Project Structure diff --git a/workspace-server/src/__tests__/tool-normalization.test.ts b/workspace-server/src/__tests__/tool-normalization.test.ts index af088f1..2f969b1 100644 --- a/workspace-server/src/__tests__/tool-normalization.test.ts +++ b/workspace-server/src/__tests__/tool-normalization.test.ts @@ -15,9 +15,17 @@ describe('Tool Name Normalization', () => { const server = { registerTool: mockRegisterTool } as unknown as McpServer; applyToolNameNormalization(server, false); - server.registerTool('test.tool', { inputSchema: z.object({}) }, async () => ({ content: [] })); + server.registerTool( + 'test.tool', + { inputSchema: z.object({}) }, + async () => ({ content: [] }), + ); - expect(mockRegisterTool).toHaveBeenCalledWith('test_tool', expect.any(Object), expect.any(Function)); + expect(mockRegisterTool).toHaveBeenCalledWith( + 'test_tool', + expect.any(Object), + expect.any(Function), + ); }); it('should preserve dots when useDotNames is true', () => { @@ -25,8 +33,16 @@ describe('Tool Name Normalization', () => { const server = { registerTool: mockRegisterTool } as unknown as McpServer; applyToolNameNormalization(server, true); - server.registerTool('test.tool', { inputSchema: z.object({}) }, async () => ({ content: [] })); + server.registerTool( + 'test.tool', + { inputSchema: z.object({}) }, + async () => ({ content: [] }), + ); - expect(mockRegisterTool).toHaveBeenCalledWith('test.tool', expect.any(Object), expect.any(Function)); + expect(mockRegisterTool).toHaveBeenCalledWith( + 'test.tool', + expect.any(Object), + expect.any(Function), + ); }); }); diff --git a/workspace-server/src/utils/tool-normalization.ts b/workspace-server/src/utils/tool-normalization.ts index 1e4fe07..6e42db5 100644 --- a/workspace-server/src/utils/tool-normalization.ts +++ b/workspace-server/src/utils/tool-normalization.ts @@ -4,17 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; /** * Wraps the McpServer.registerTool method to normalize tool names. * If useDotNames is true, dots in tool names are preserved. * If useDotNames is false (default), dots are replaced with underscores. - * + * * @param server The McpServer instance to modify. * @param useDotNames Whether to preserve dot notation in tool names. */ -export function applyToolNameNormalization(server: McpServer, useDotNames: boolean): void { +export function applyToolNameNormalization( + server: McpServer, + useDotNames: boolean, +): void { const separator = useDotNames ? '.' : '_'; const originalRegisterTool = server.registerTool.bind(server); @@ -25,6 +28,9 @@ export function applyToolNameNormalization(server: McpServer, useDotNames: boole (server as any).registerTool = (name: string, ...rest: unknown[]) => { const normalizedName = name.replace(/\./g, separator); // Cast originalRegisterTool to accept spread arguments - return (originalRegisterTool as (...args: unknown[]) => unknown)(normalizedName, ...rest); + return (originalRegisterTool as (...args: unknown[]) => unknown)( + normalizedName, + ...rest, + ); }; } From 595dafbd545f50f195610250f3be7075b1a00188 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Jan 2026 15:36:50 -0800 Subject: [PATCH 6/7] chore: trigger ci check Force re-run of CI checks by updating tool-normalization.ts comment. Verified local formatting passes. --- workspace-server/src/utils/tool-normalization.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/workspace-server/src/utils/tool-normalization.ts b/workspace-server/src/utils/tool-normalization.ts index 6e42db5..199813f 100644 --- a/workspace-server/src/utils/tool-normalization.ts +++ b/workspace-server/src/utils/tool-normalization.ts @@ -6,6 +6,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +// Utility for normalizing tool names /** * Wraps the McpServer.registerTool method to normalize tool names. * If useDotNames is true, dots in tool names are preserved. From 2485ec653a8cccfc4cbd71f990ecccef00a0646c Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 29 Jan 2026 15:39:58 -0800 Subject: [PATCH 7/7] ci: split verify and test jobs for efficiency - Moved lint, format, and type-check to a separate 'verify' job running once on ubuntu-latest. - 'test' job now depends on 'verify' and only runs tests across the OS/Node matrix. - Reduces redundant checks and speeds up the feedback loop. --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a650216..c08545d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,32 @@ on: branches: [main] jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js 20.x + uses: actions/setup-node@v6 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run Prettier check + run: npm run format:check + + - name: Run type checking + run: npx tsc --noEmit --project workspace-server + test: + needs: verify runs-on: ${{ matrix.os }} strategy: @@ -32,15 +57,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Run linter - run: npm run lint - - - name: Run Prettier check - run: npm run format:check - - - name: Run type checking - run: npx tsc --noEmit --project workspace-server - - name: Run tests with coverage run: npm run test:ci