From d648589ac7899fc0f90cdd58438d969c1a0b3ac5 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:36:52 +0200 Subject: [PATCH] upd --- README.md | 69 ++ documentation/google-api-communication.md | 97 +++ src/index.ts | 749 +++++++++++++++++++++- 3 files changed, 896 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 26f4bcd..5273288 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,14 @@ Node.js TypeScript sluzba pro komunikaci s Google API za AppFactory reverse prox - `GET /google/discovery/apis` - `GET /google/discovery/apis/{api}/{version}/rest` - `POST /google/oauth/token` +- `GET /google/oauth/scopes` +- `GET /google/oauth/authorize-url` - `POST /google/oauth/service-account-token` - `POST /google/oauth/revoke` - `GET /google/oauth/tokeninfo` - `GET|POST|PATCH|DELETE /google/calendar/...` - `GET|POST|PUT /google/sheets/...` +- `POST /google/sheets/write-by-url` - `GET|POST|PATCH|DELETE /google/drive/...` - `GET|POST /google/gmail/...` - `GET|POST /google/docs/...` @@ -37,11 +40,29 @@ Volitelne environment variables: Secrets se nevraci v zadnem endpointu a neloguji se. +## Credentials ve Swaggeru + +`GET /google/configuration` vraci prehled podporovanych env promennych a jejich request alternativ. Nevraci hodnoty secrets. + +Env promenne lze nahradit primo v requestu: + +- `GOOGLE_CLIENT_ID`: `clientId` nebo `X-Google-Client-Id` +- `GOOGLE_CLIENT_SECRET`: `clientSecret` nebo `X-Google-Client-Secret` +- `GOOGLE_REDIRECT_URI`: `redirectUri` nebo `X-Google-Redirect-Uri` +- `GOOGLE_SERVICE_ACCOUNT_EMAIL`: `serviceAccountEmail` nebo `X-Google-Service-Account-Email` +- `GOOGLE_PRIVATE_KEY`: `privateKey` nebo `X-Google-Private-Key` +- `GOOGLE_SCOPES`: `scope`, `scopes` nebo `X-Google-Scopes` +- `GOOGLE_ACCESS_TOKEN`: `Authorization: Bearer `, `accessToken`, `accessTokenEnv`, `X-Google-Access-Token`, `X-Google-Access-Token-Env` +- `GOOGLE_API_KEY`: `apiKey`, `apiKeyEnv`, `X-Google-Api-Key`, `X-Google-Api-Key-Env` + ## Obecne volani Google API `POST /google/request` umi volat libovolny HTTPS endpoint na Google domene. Autorizace muze jit pres: - `Authorization: Bearer ` header na requestu do sluzby +- `X-Google-Access-Token` header +- `X-Google-Access-Token-Env` header, napr. `GOOGLE_ACCESS_TOKEN` +- `X-Google-Api-Key` nebo `X-Google-Api-Key-Env` header - `accessToken` v body - `accessTokenEnv` v body, napr. `GOOGLE_ACCESS_TOKEN` - `GOOGLE_API_KEY`, `apiKey` nebo `apiKeyEnv` pro endpointy podporujici API key @@ -102,3 +123,51 @@ POST /google/sheets/spreadsheets/{spreadsheetId}/values/append?range=Sheet1!A1 ] } ``` + +Priklad zapisu do Google Sheets podle URL a nazvu listu: + +```json +POST /google/sheets/write-by-url +{ + "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", + "sheetName": "Objednavky", + "startCell": "A1", + "mode": "append", + "valueInputOption": "USER_ENTERED", + "values": [ + ["Datum", "Zakaznik", "Castka"], + ["2026-06-15", "ACME", 1234] + ] +} +``` + +`mode=append` prida radky pod existujici data. `mode=update` prepise bunky od `startCell`. + +## OAuth postup + +1. Zavolej `GET /google/oauth/scopes` a vyber scopes podle sluzeb. +2. Zavolej `GET /google/oauth/authorize-url?redirectUri=&scopes=`. +3. Otevri `authorizationUrl` z odpovedi v prohlizeci. +4. Google presmeruje na `redirectUri?code=...`. +5. Vymen code pres `POST /google/oauth/token`. +6. Access token posilej jako `Authorization: Bearer `. + +Priklad vymeny code: + +```json +{ + "grantType": "authorization_code", + "code": "code-from-google-redirect", + "redirectUri": "https://example.test/oauth/callback" +} +``` + +## Kde vzit ID + +- `calendarId`: `GET /google/calendar/calendars`, hlavni kalendar lze volat jako `primary`. +- `spreadsheetId`: `GET /google/sheets/spreadsheets`, hodnota je `files[].id`. +- `sheetId`: `GET /google/sheets/spreadsheets/{spreadsheetId}/sheets`, hodnota je `sheets[].properties.sheetId`. +- A1 range pro Sheets hodnoty: pouzij `sheets[].properties.title`, napr. `Sheet1!A1:B10`. +- `fileId`: `GET /google/drive/files`, hodnota je `files[].id`. +- `messageId`: `GET /google/gmail/messages`, hodnota je `messages[].id`. +- `documentId`, `presentationId`: u Docs/Slides jde o ID Drive souboru. diff --git a/documentation/google-api-communication.md b/documentation/google-api-communication.md index 941acef..10b678e 100644 --- a/documentation/google-api-communication.md +++ b/documentation/google-api-communication.md @@ -12,6 +12,8 @@ Discovery endpointy slouzi k dohledani dostupnych resource, metod, schema a OAut ## OAuth - `POST /google/oauth/token` podporuje `authorization_code` a `refresh_token` grant. +- `GET /google/oauth/scopes` vraci doporucene scopes pro podporovane sluzby. +- `GET /google/oauth/authorize-url` vygeneruje Google consent URL. - `POST /google/oauth/service-account-token` podporuje service account JWT bearer flow. - `POST /google/oauth/revoke` revokuje token. - `GET /google/oauth/tokeninfo` vraci informace o access tokenu nebo ID tokenu. @@ -24,6 +26,61 @@ OAuth client a service account hodnoty je vhodne predavat pres environment varia - `GOOGLE_PRIVATE_KEY` - `GOOGLE_SCOPES` +### Env promenne a request alternativy + +Swagger obsahuje `GET /google/configuration`, ktery vypise podporovane env promenne, jejich pouziti a alternativy v requestu. Endpoint nevraci hodnoty secrets. + +- `GOOGLE_CLIENT_ID`: `clientId`, `X-Google-Client-Id` +- `GOOGLE_CLIENT_SECRET`: `clientSecret`, `X-Google-Client-Secret` +- `GOOGLE_REDIRECT_URI`: `redirectUri`, `X-Google-Redirect-Uri` +- `GOOGLE_SERVICE_ACCOUNT_EMAIL`: `serviceAccountEmail`, `X-Google-Service-Account-Email` +- `GOOGLE_PRIVATE_KEY`: `privateKey`, `X-Google-Private-Key` +- `GOOGLE_SCOPES`: `scope`, `scopes`, `X-Google-Scopes` +- `GOOGLE_ACCESS_TOKEN`: `Authorization: Bearer `, `accessToken`, `accessTokenEnv`, `X-Google-Access-Token`, `X-Google-Access-Token-Env` +- `GOOGLE_API_KEY`: `apiKey`, `apiKeyEnv`, `X-Google-Api-Key`, `X-Google-Api-Key-Env` + +### OAuth krok za krokem + +1. Zavolej `GET /google/oauth/scopes`. +2. Vyber scopes, napr. Sheets + Drive metadata: + +```text +https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly +``` + +3. Zavolej: + +```text +GET /google/oauth/authorize-url?redirectUri=https://example.test/oauth/callback&scopes=https://www.googleapis.com/auth/spreadsheets https://www.googleapis.com/auth/drive.metadata.readonly +``` + +4. Otevri `authorizationUrl` z odpovedi. +5. Po souhlasu Google presmeruje na `redirectUri` s query parametrem `code`. +6. Code vymen: + +```json +{ + "grantType": "authorization_code", + "code": "code-from-google-redirect", + "redirectUri": "https://example.test/oauth/callback" +} +``` + +7. `access_token` posilej do produktovych endpointu jako: + +```text +Authorization: Bearer +``` + +Pro dlouhodobe pouziti si uloz `refresh_token` mimo zdrojovy kod a obnovuj token pres: + +```json +{ + "grantType": "refresh_token", + "refreshToken": "stored-refresh-token" +} +``` + ## Obecne REST volani `POST /google/request` je genericka proxy pro Google REST API. Request obsahuje HTTP metodu, cilovou Google URL nebo kombinaci `baseUrl` a `path`, volitelne query parametry, body a autorizaci. @@ -64,8 +121,11 @@ Krome obecne proxy jsou dostupne konkretni wrappery nad nejbeznejsimi Google API ### Sheets +- `GET /google/sheets/spreadsheets` - seznam Google Sheets souboru; `spreadsheetId` je `files[].id` - `POST /google/sheets/spreadsheets` - `GET /google/sheets/spreadsheets/{spreadsheetId}` +- `GET /google/sheets/spreadsheets/{spreadsheetId}/sheets` - seznam tabu/listu; numericke `sheetId` je `sheets[].properties.sheetId`, A1 range pouziva `sheets[].properties.title` +- `POST /google/sheets/write-by-url` - zapis hodnot podle plne URL spreadsheetu a nazvu listu - `POST /google/sheets/spreadsheets/{spreadsheetId}/batch-update` - `GET /google/sheets/spreadsheets/{spreadsheetId}/values?range=Sheet1!A1:B10` - `PUT /google/sheets/spreadsheets/{spreadsheetId}/values?range=Sheet1!A1` @@ -73,6 +133,31 @@ Krome obecne proxy jsou dostupne konkretni wrappery nad nejbeznejsimi Google API - `POST /google/sheets/spreadsheets/{spreadsheetId}/values/batch-update` - `POST /google/sheets/spreadsheets/{spreadsheetId}/values/batch-clear` +#### Zapis podle URL a nazvu listu + +Endpoint `POST /google/sheets/write-by-url` je urceny pro bezny zapis bez rucni prace se `spreadsheetId` a A1 range. + +```json +{ + "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", + "sheetName": "Objednavky", + "startCell": "A1", + "mode": "append", + "valueInputOption": "USER_ENTERED", + "values": [ + ["Datum", "Zakaznik", "Castka"], + ["2026-06-15", "ACME", 1234] + ] +} +``` + +Chovani: + +- `mode=append` vola Google Sheets append a prida radky pod existujici tabulku. +- `mode=update` vola Google Sheets update a prepise bunky od `startCell`. +- `sheetName` muze obsahovat mezery i apostrofy; sluzba ho sama escapuje do A1 range. +- `spreadsheetUrl` muze byt bezna URL ve tvaru `https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit#gid=0`. + ### Drive - `GET /google/drive/files` @@ -121,6 +206,18 @@ Krome obecne proxy jsou dostupne konkretni wrappery nad nejbeznejsimi Google API - `PATCH /google/tasks/lists/{tasklistId}/tasks/{taskId}` - `DELETE /google/tasks/lists/{tasklistId}/tasks/{taskId}` +## Kde vzit ID + +- `calendarId`: `GET /google/calendar/calendars`, hlavni kalendar lze typicky volat jako `primary`. +- `eventId`: `GET /google/calendar/calendars/{calendarId}/events`. +- `spreadsheetId`: `GET /google/sheets/spreadsheets`, pole `files[].id`. +- `sheetId`: `GET /google/sheets/spreadsheets/{spreadsheetId}/sheets`, pole `sheets[].properties.sheetId`. +- `fileId`: `GET /google/drive/files`, pole `files[].id`. +- `messageId`: `GET /google/gmail/messages`, pole `messages[].id`. +- `documentId`: ID Google Docs souboru z Drive. +- `presentationId`: ID Google Slides souboru z Drive. +- `formId`: ID Google Forms souboru z Drive nebo odpoved z `POST /google/forms/forms`. + ## AppFactory overeni Po deploy over: diff --git a/src/index.ts b/src/index.ts index c207fc1..4d5c7e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,29 @@ type ServiceAccountTokenBody = { privateKey?: string; }; +type SheetWriteByUrlBody = { + spreadsheetUrl?: string; + sheetName?: string; + values?: JsonValue[][]; + startCell?: string; + mode?: "append" | "update"; + majorDimension?: "ROWS" | "COLUMNS"; + valueInputOption?: "RAW" | "USER_ENTERED"; + insertDataOption?: "OVERWRITE" | "INSERT_ROWS"; +}; + +type AuthorizationUrlQuery = { + redirectUri?: string; + scope?: string; + scopes?: string; + state?: string; + accessType?: string; + prompt?: string; + loginHint?: string; + includeGrantedScopes?: string; + clientId?: string; +}; + type GoogleRouteOptions = { baseUrl: string; path: string; @@ -47,6 +70,18 @@ const app = express(); const port = Number(process.env.PORT || 3000); const rootPath = normalizeRootPath(process.env.ROOT_PATH || ""); +const recommendedScopes = { + calendar: ["https://www.googleapis.com/auth/calendar"], + sheets: ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive.metadata.readonly"], + drive: ["https://www.googleapis.com/auth/drive"], + gmail: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.send"], + docs: ["https://www.googleapis.com/auth/documents"], + slides: ["https://www.googleapis.com/auth/presentations"], + forms: ["https://www.googleapis.com/auth/forms.body", "https://www.googleapis.com/auth/forms.responses.readonly"], + people: ["https://www.googleapis.com/auth/contacts"], + tasks: ["https://www.googleapis.com/auth/tasks"] +}; + const googleHosts = new Set([ "accounts.google.com", "androidpublisher.googleapis.com", @@ -161,8 +196,8 @@ app.get("/google/discovery/apis/:api/:version/rest", async (req, res) => { app.post("/google/oauth/token", async (req, res) => { try { const body = req.body as TokenExchangeBody; - const clientId = body.clientId || process.env.GOOGLE_CLIENT_ID; - const clientSecret = body.clientSecret || process.env.GOOGLE_CLIENT_SECRET; + const clientId = body.clientId || req.header("x-google-client-id") || process.env.GOOGLE_CLIENT_ID; + const clientSecret = body.clientSecret || req.header("x-google-client-secret") || process.env.GOOGLE_CLIENT_SECRET; const grantType = body.grantType || (body.refreshToken ? "refresh_token" : "authorization_code"); if (!clientId || !clientSecret) { @@ -203,10 +238,10 @@ app.post("/google/oauth/token", async (req, res) => { app.post("/google/oauth/service-account-token", async (req, res) => { try { const body = req.body as ServiceAccountTokenBody; - const email = body.serviceAccountEmail || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL; - const rawPrivateKey = body.privateKey || process.env.GOOGLE_PRIVATE_KEY; + const email = body.serviceAccountEmail || req.header("x-google-service-account-email") || process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL; + const rawPrivateKey = body.privateKey || req.header("x-google-private-key") || process.env.GOOGLE_PRIVATE_KEY; const privateKey = rawPrivateKey?.replace(/\\n/g, "\n"); - const scope = body.scope || body.scopes?.join(" ") || process.env.GOOGLE_SCOPES; + const scope = body.scope || body.scopes?.join(" ") || req.header("x-google-scopes") || process.env.GOOGLE_SCOPES; if (!email || !privateKey || !scope) { return badRequest( @@ -269,6 +304,64 @@ app.get("/google/oauth/tokeninfo", async (req, res) => { } }); +app.get("/google/oauth/scopes", (_req, res) => { + res.json({ + recommendedScopes, + all: Array.from(new Set(Object.values(recommendedScopes).flat())) + }); +}); + +app.get("/google/configuration", (_req, res) => { + res.json({ + environmentVariables: [ + { name: "GOOGLE_CLIENT_ID", usedBy: ["/google/oauth/authorize-url", "/google/oauth/token"], alternatives: ["clientId", "X-Google-Client-Id"] }, + { name: "GOOGLE_CLIENT_SECRET", usedBy: ["/google/oauth/token"], alternatives: ["clientSecret", "X-Google-Client-Secret"] }, + { name: "GOOGLE_REDIRECT_URI", usedBy: ["/google/oauth/authorize-url"], alternatives: ["redirectUri", "X-Google-Redirect-Uri"] }, + { name: "GOOGLE_SERVICE_ACCOUNT_EMAIL", usedBy: ["/google/oauth/service-account-token"], alternatives: ["serviceAccountEmail", "X-Google-Service-Account-Email"] }, + { name: "GOOGLE_PRIVATE_KEY", usedBy: ["/google/oauth/service-account-token"], alternatives: ["privateKey", "X-Google-Private-Key"] }, + { name: "GOOGLE_SCOPES", usedBy: ["/google/oauth/service-account-token"], alternatives: ["scope", "scopes", "X-Google-Scopes"] }, + { name: "GOOGLE_ACCESS_TOKEN", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["Authorization: Bearer", "accessToken", "accessTokenEnv", "X-Google-Access-Token", "X-Google-Access-Token-Env"] }, + { name: "GOOGLE_API_KEY", usedBy: ["Google product endpoints", "/google/request"], alternatives: ["apiKey", "apiKeyEnv", "X-Google-Api-Key", "X-Google-Api-Key-Env"] } + ], + note: "Endpoint intentionally returns only variable names and usage metadata, never secret values." + }); +}); + +app.get("/google/oauth/authorize-url", (req, res) => { + const query = req.query as AuthorizationUrlQuery; + const clientId = query.clientId || req.header("x-google-client-id") || process.env.GOOGLE_CLIENT_ID; + const redirectUri = query.redirectUri || req.header("x-google-redirect-uri") || process.env.GOOGLE_REDIRECT_URI; + + if (!clientId || !redirectUri) { + return badRequest(res, "Authorize URL requires GOOGLE_CLIENT_ID and GOOGLE_REDIRECT_URI or clientId and redirectUri query parameters."); + } + + const scopes = parseScopes(query.scope || query.scopes) || Object.values(recommendedScopes).flat(); + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("access_type", query.accessType || "offline"); + url.searchParams.set("prompt", query.prompt || "consent"); + url.searchParams.set("include_granted_scopes", query.includeGrantedScopes || "true"); + + if (query.state) { + url.searchParams.set("state", query.state); + } + + if (query.loginHint) { + url.searchParams.set("login_hint", query.loginHint); + } + + res.json({ + authorizationUrl: url.toString(), + clientId, + redirectUri, + scopes + }); +}); + app.get("/google/calendar/calendars", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://www.googleapis.com", path: "/calendar/v3/users/me/calendarList" }); }); @@ -363,6 +456,17 @@ app.post("/google/sheets/spreadsheets", async (req, res) => { }); }); +app.get("/google/sheets/spreadsheets", async (req, res) => { + await callGoogle(req, res, { + baseUrl: "https://www.googleapis.com", + path: "/drive/v3/files", + query: { + q: "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false", + fields: "files(id,name,mimeType,webViewLink,createdTime,modifiedTime,owners(displayName,emailAddress)),nextPageToken" + } + }); +}); + app.get("/google/sheets/spreadsheets/:spreadsheetId", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", @@ -370,6 +474,63 @@ app.get("/google/sheets/spreadsheets/:spreadsheetId", async (req, res) => { }); }); +app.get("/google/sheets/spreadsheets/:spreadsheetId/sheets", async (req, res) => { + await callGoogle(req, res, { + baseUrl: "https://sheets.googleapis.com", + path: `/v4/spreadsheets/${encodePathParam(req.params.spreadsheetId)}`, + query: { + fields: "spreadsheetId,properties.title,sheets.properties" + } + }); +}); + +app.post("/google/sheets/write-by-url", async (req, res) => { + const body = req.body as SheetWriteByUrlBody; + const spreadsheetUrl = body.spreadsheetUrl; + const sheetName = body.sheetName; + const values = body.values; + + if (!spreadsheetUrl || !sheetName || !Array.isArray(values)) { + return badRequest(res, "Body requires spreadsheetUrl, sheetName and values."); + } + + const spreadsheetId = extractSpreadsheetId(spreadsheetUrl); + if (!spreadsheetId) { + return badRequest(res, "spreadsheetUrl must contain /spreadsheets/d/{spreadsheetId} or id={spreadsheetId}."); + } + + const startCell = body.startCell || "A1"; + const range = `${quoteSheetName(sheetName)}!${startCell}`; + const mode = body.mode || "append"; + const valueInputOption = body.valueInputOption || "USER_ENTERED"; + const requestBody = { + majorDimension: body.majorDimension || "ROWS", + values + }; + + if (mode === "update") { + await callGoogle(req, res, { + baseUrl: "https://sheets.googleapis.com", + path: `/v4/spreadsheets/${encodePathParam(spreadsheetId)}/values/${encodePathParam(range)}`, + method: "PUT", + query: { valueInputOption }, + body: requestBody + }); + return; + } + + await callGoogle(req, res, { + baseUrl: "https://sheets.googleapis.com", + path: `/v4/spreadsheets/${encodePathParam(spreadsheetId)}/values/${encodePathParam(range)}:append`, + method: "POST", + query: { + valueInputOption, + insertDataOption: body.insertDataOption || "INSERT_ROWS" + }, + body: requestBody + }); +}); + app.post("/google/sheets/spreadsheets/:spreadsheetId/batch-update", async (req, res) => { await callGoogle(req, res, { baseUrl: "https://sheets.googleapis.com", @@ -701,6 +862,7 @@ app.delete("/google/tasks/lists/:tasklistId/tasks/:taskId", async (req, res) => app.post("/google/request", async (req, res) => { try { const requestBody = req.body as GoogleRequestBody; + applyCredentialHeaders(req, requestBody); const targetUrl = buildGoogleUrl(requestBody); const headers = buildGoogleHeaders(req, requestBody); const method = (requestBody.method || "GET").toUpperCase(); @@ -756,6 +918,7 @@ async function callGoogle(req: Request, res: Response, options: GoogleRouteOptio apiKeyEnv: readBodyString(req.body, "apiKeyEnv") }; + applyCredentialHeaders(req, requestBody); const targetUrl = buildGoogleUrl(requestBody); const headers = buildGoogleHeaders(req, requestBody); const method = (requestBody.method || "GET").toUpperCase(); @@ -795,6 +958,13 @@ function readBodyString(body: unknown, key: string): string | undefined { return typeof value === "string" ? value : undefined; } +function applyCredentialHeaders(req: Request, body: GoogleRequestBody): void { + body.accessToken ||= req.header("x-google-access-token"); + body.accessTokenEnv ||= req.header("x-google-access-token-env"); + body.apiKey ||= req.header("x-google-api-key"); + body.apiKeyEnv ||= req.header("x-google-api-key-env"); +} + function requireQueryString(req: Request, res: Response, key: string): string | undefined { const value = req.query[key]; if (typeof value === "string" && value) { @@ -809,6 +979,36 @@ function encodePathParam(value: string): string { return encodeURIComponent(value); } +function extractSpreadsheetId(spreadsheetUrl: string): string | undefined { + const fromPath = spreadsheetUrl.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/); + if (fromPath?.[1]) { + return fromPath[1]; + } + + try { + const url = new URL(spreadsheetUrl); + const id = url.searchParams.get("id"); + return id || undefined; + } catch { + return undefined; + } +} + +function quoteSheetName(sheetName: string): string { + return `'${sheetName.replace(/'/g, "''")}'`; +} + +function parseScopes(value: string | undefined): string[] | undefined { + if (!value) { + return undefined; + } + + return value + .split(/[,\s]+/) + .map((scope) => scope.trim()) + .filter(Boolean); +} + function buildGoogleUrl(body: GoogleRequestBody): string { const rawUrl = body.url || joinBaseAndPath(body.baseUrl || "https://www.googleapis.com", body.path || ""); const url = new URL(rawUrl); @@ -983,7 +1183,8 @@ function buildOpenApiDocument(): Record { info: { title: "google-service API", version: "1.0.0", - description: "Obecna proxy a OAuth vrstva pro komunikaci s Google API." + description: + "Kompletni REST wrapper pro Google API. OAuth postup: 1) GET /google/oauth/scopes pro vyber scope, 2) GET /google/oauth/authorize-url pro URL souhlasu, 3) po redirectu vymen code pres POST /google/oauth/token, 4) access_token posilej jako Authorization: Bearer . ID objektu ziskas list endpointy: spreadsheetId pres GET /google/sheets/spreadsheets, calendarId pres GET /google/calendar/calendars, fileId pres GET /google/drive/files." }, servers: [{ url: rootPath || "/" }], tags: [ @@ -1031,20 +1232,69 @@ function buildOpenApiDocument(): Record { post: { tags: ["Google OAuth"], summary: "Vymena authorization code nebo refresh tokenu za access token", + description: + "Pro authorization_code nejdriv otevri authorizationUrl z /google/oauth/authorize-url. Google presmeruje na redirectUri s query parametrem code. Ten posli sem. Pro dalsi obnoveni tokenu pouzij refreshToken.", + parameters: oauthClientHeaderParameters(), requestBody: jsonRequestBody({ grantType: "authorization_code", code: "authorization-code", - redirectUri: "https://example.test/oauth/callback" - }), - responses: { "200": { description: "OAuth token response" }, "400": { description: "Invalid request" } } + redirectUri: "https://example.test/oauth/callback", + clientId: "google-oauth-client-id.apps.googleusercontent.com", + clientSecret: "google-oauth-client-secret" + }, "OAuthTokenRequest"), + responses: { + "200": jsonResponse("OAuth token response", "OAuthTokenResponse"), + "400": { description: "Invalid request" } + } + } + }, + "/google/oauth/scopes": { + get: { + tags: ["Google OAuth"], + summary: "Doporucene OAuth scopes pro podporovane Google sluzby", + description: "Vraci scopes rozdelene podle Calendar, Sheets, Drive, Gmail, Docs, Slides, Forms, People a Tasks.", + responses: { "200": jsonResponse("Recommended scopes", "ScopesResponse") } + } + }, + "/google/configuration": { + get: { + tags: ["Google OAuth"], + summary: "Prehled podporovanych env promennych a request alternativ", + description: + "Vraci jen nazvy env promennych, kde se pouzivaji a jak je predat pres request body/header. Nikdy nevraci hodnoty secrets.", + responses: { "200": jsonResponse("Google service configuration metadata", "ConfigurationResponse") } + } + }, + "/google/oauth/authorize-url": { + get: { + tags: ["Google OAuth"], + summary: "Vygeneruje Google OAuth consent URL", + description: + "Otevri authorizationUrl v prohlizeci. Po souhlasu Google presmeruje na redirectUri s query parametrem code. Tento code vymen pres POST /google/oauth/token.", + parameters: [ + { name: "clientId", in: "query", required: false, schema: { type: "string" }, description: "Fallback je X-Google-Client-Id nebo GOOGLE_CLIENT_ID." }, + { name: "redirectUri", in: "query", required: false, schema: { type: "string" }, description: "Fallback je GOOGLE_REDIRECT_URI." }, + { name: "scopes", in: "query", required: false, schema: { type: "string" }, description: "Scopes oddelene mezerou nebo carkou. Pokud chybi, pouziji se vsechny doporucene scopes." }, + { name: "state", in: "query", required: false, schema: { type: "string" } }, + { name: "loginHint", in: "query", required: false, schema: { type: "string" } }, + ...authorizeHeaderParameters() + ], + responses: { "200": jsonResponse("Authorization URL", "AuthorizationUrlResponse"), "400": { description: "Invalid request" } } } }, "/google/oauth/service-account-token": { post: { tags: ["Google OAuth"], summary: "Vystaveni access tokenu pro service account JWT flow", - requestBody: jsonRequestBody({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }), - responses: { "200": { description: "OAuth token response" }, "400": { description: "Invalid request" } } + description: + "Pouzij pro server-to-server komunikaci. Pro Workspace data uzivatelu je potreba domain-wide delegation a subject.", + parameters: serviceAccountHeaderParameters(), + requestBody: jsonRequestBody({ + serviceAccountEmail: "service-account@project.iam.gserviceaccount.com", + privateKey: "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n", + scopes: ["https://www.googleapis.com/auth/cloud-platform"] + }, "ServiceAccountTokenRequest"), + responses: { "200": jsonResponse("OAuth token response", "OAuthTokenResponse"), "400": { description: "Invalid request" } } } }, "/google/oauth/revoke": { @@ -1071,14 +1321,17 @@ function buildOpenApiDocument(): Record { post: { tags: ["Google API"], summary: "Obecne volani Google REST API", + description: + "Credentials lze predat v body, pres X-Google-* headery, nebo jako env promennou uvedenou v *Env poli. Dostupne env fallbacky jsou GOOGLE_ACCESS_TOKEN a GOOGLE_API_KEY.", security: [{ bearerAuth: [] }], + parameters: googleCredentialHeaderParameters(), requestBody: jsonRequestBody({ method: "GET", baseUrl: "https://www.googleapis.com", path: "/drive/v3/files", query: { pageSize: 10 }, accessTokenEnv: "GOOGLE_ACCESS_TOKEN" - }), + }, "GoogleRequest"), responses: { "200": { description: "Google API response" }, "400": { description: "Invalid request" } } } } @@ -1089,18 +1342,294 @@ function buildOpenApiDocument(): Record { type: "http", scheme: "bearer" } + }, + schemas: buildOpenApiSchemas() + } + }; +} + +function jsonRequestBody(example: Record, schemaName?: string): Record { + return { + required: true, + content: { + "application/json": { + schema: schemaName ? { $ref: `#/components/schemas/${schemaName}` } : { type: "object", additionalProperties: true }, + example } } }; } -function jsonRequestBody(example: Record): Record { +function jsonResponse(description: string, schemaName: string): Record { return { - required: true, + description, content: { "application/json": { - schema: { type: "object", additionalProperties: true }, - example + schema: { $ref: `#/components/schemas/${schemaName}` } + } + } + }; +} + +function buildOpenApiSchemas(): Record { + return { + OAuthTokenRequest: { + type: "object", + properties: { + grantType: { type: "string", enum: ["authorization_code", "refresh_token"], default: "authorization_code" }, + code: { type: "string", description: "Code z Google redirectu pro authorization_code flow." }, + refreshToken: { type: "string", description: "Refresh token pro refresh_token flow." }, + redirectUri: { type: "string", description: "Fallback/alternativa: GOOGLE_REDIRECT_URI se pouziva jen pro authorize-url, token endpoint potrebuje redirectUri v body pro authorization_code." }, + scope: { type: "string" }, + clientId: { type: "string", description: "Alternativa k X-Google-Client-Id nebo env GOOGLE_CLIENT_ID." }, + clientSecret: { type: "string", description: "Alternativa k X-Google-Client-Secret nebo env GOOGLE_CLIENT_SECRET." } + } + }, + OAuthTokenResponse: { + type: "object", + properties: { + access_token: { type: "string", description: "Bearer token pro volani Google API." }, + expires_in: { type: "integer", example: 3599 }, + refresh_token: { type: "string", description: "Vraci se typicky pri prvnim consentu s access_type=offline." }, + scope: { type: "string" }, + token_type: { type: "string", example: "Bearer" }, + id_token: { type: "string" } + } + }, + AuthorizationUrlResponse: { + type: "object", + properties: { + authorizationUrl: { type: "string", description: "URL otevri v prohlizeci pro Google consent." }, + clientId: { type: "string" }, + redirectUri: { type: "string" }, + scopes: { type: "array", items: { type: "string" } } + } + }, + ScopesResponse: { + type: "object", + properties: { + recommendedScopes: { type: "object", additionalProperties: { type: "array", items: { type: "string" } } }, + all: { type: "array", items: { type: "string" } } + } + }, + ConfigurationResponse: { + type: "object", + properties: { + environmentVariables: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", example: "GOOGLE_CLIENT_ID" }, + usedBy: { type: "array", items: { type: "string" } }, + alternatives: { + type: "array", + items: { type: "string" }, + description: "Request body fields, query fields, or X-Google-* headers usable instead of env." + } + } + } + }, + note: { type: "string" } + } + }, + ServiceAccountTokenRequest: { + type: "object", + properties: { + serviceAccountEmail: { type: "string", description: "Alternativa k X-Google-Service-Account-Email nebo env GOOGLE_SERVICE_ACCOUNT_EMAIL." }, + privateKey: { type: "string", description: "Alternativa k X-Google-Private-Key nebo env GOOGLE_PRIVATE_KEY. Podporuje \\n escapovani." }, + scopes: { type: "array", items: { type: "string" }, description: "Alternativa k X-Google-Scopes nebo env GOOGLE_SCOPES." }, + scope: { type: "string" }, + subject: { type: "string", description: "Workspace user pro domain-wide delegation." } + } + }, + GoogleRequest: { + type: "object", + properties: { + method: { type: "string", example: "GET" }, + url: { type: "string", description: "Plna Google API URL. Alternativa k baseUrl+path." }, + baseUrl: { type: "string", example: "https://www.googleapis.com" }, + path: { type: "string", example: "/drive/v3/files" }, + query: { type: "object", additionalProperties: true }, + headers: { type: "object", additionalProperties: { type: "string" } }, + body: { type: "object", additionalProperties: true }, + accessToken: { type: "string", description: "Alternativa k Authorization Bearer nebo X-Google-Access-Token." }, + accessTokenEnv: { type: "string", example: "GOOGLE_ACCESS_TOKEN", description: "Jmeno env promenne s access tokenem." }, + apiKey: { type: "string", description: "Alternativa k X-Google-Api-Key nebo GOOGLE_API_KEY." }, + apiKeyEnv: { type: "string", example: "GOOGLE_API_KEY", description: "Jmeno env promenne s API key." } + } + }, + Calendar: { + type: "object", + properties: { + id: { type: "string", description: "calendarId pouzitelne v /google/calendar/calendars/{calendarId}/events." }, + summary: { type: "string" }, + description: { type: "string" }, + timeZone: { type: "string", example: "Europe/Prague" } + } + }, + CalendarEvent: { + type: "object", + properties: { + id: { type: "string", description: "eventId." }, + summary: { type: "string" }, + description: { type: "string" }, + location: { type: "string" }, + start: { $ref: "#/components/schemas/EventDateTime" }, + end: { $ref: "#/components/schemas/EventDateTime" }, + attendees: { type: "array", items: { type: "object", properties: { email: { type: "string" } } } } + } + }, + EventDateTime: { + type: "object", + properties: { + dateTime: { type: "string", example: "2026-06-15T10:00:00+02:00" }, + date: { type: "string", example: "2026-06-15" }, + timeZone: { type: "string", example: "Europe/Prague" } + } + }, + DriveFile: { + type: "object", + properties: { + id: { type: "string", description: "fileId. U Google Sheets souboru je to zaroven spreadsheetId." }, + name: { type: "string" }, + mimeType: { type: "string" }, + webViewLink: { type: "string" }, + createdTime: { type: "string" }, + modifiedTime: { type: "string" } + } + }, + DriveFileList: { + type: "object", + properties: { + files: { type: "array", items: { $ref: "#/components/schemas/DriveFile" } }, + nextPageToken: { type: "string" } + } + }, + Spreadsheet: { + type: "object", + properties: { + spreadsheetId: { type: "string", description: "ID spreadsheetu pro vsechny /google/sheets/spreadsheets/{spreadsheetId} endpointy." }, + spreadsheetUrl: { type: "string" }, + properties: { type: "object", properties: { title: { type: "string" } } }, + sheets: { type: "array", items: { $ref: "#/components/schemas/Sheet" } } + } + }, + Sheet: { + type: "object", + properties: { + properties: { + type: "object", + properties: { + sheetId: { type: "integer", description: "Numericke ID tabu/listu pro batchUpdate requesty." }, + title: { type: "string", description: "Nazev listu pouzitelny v A1 range, napr. Sheet1!A1:B10." }, + index: { type: "integer" } + } + } + } + }, + ValueRange: { + type: "object", + properties: { + range: { type: "string", example: "Sheet1!A1:C3" }, + majorDimension: { type: "string", example: "ROWS" }, + values: { type: "array", items: { type: "array", items: { type: "string" } } } + } + }, + BatchUpdateValuesRequest: { + type: "object", + properties: { + valueInputOption: { type: "string", example: "USER_ENTERED" }, + data: { type: "array", items: { $ref: "#/components/schemas/ValueRange" } } + } + }, + SheetWriteByUrlRequest: { + type: "object", + required: ["spreadsheetUrl", "sheetName", "values"], + properties: { + spreadsheetUrl: { + type: "string", + description: "Plna URL Google Sheets, napr. https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit#gid=0." + }, + sheetName: { + type: "string", + description: "Nazev listu/tabu ve spreadsheetu. Sluzba sama slozi A1 range a spravne escapuje mezery i apostrofy." + }, + startCell: { type: "string", default: "A1", example: "A1" }, + mode: { type: "string", enum: ["append", "update"], default: "append" }, + majorDimension: { type: "string", enum: ["ROWS", "COLUMNS"], default: "ROWS" }, + valueInputOption: { type: "string", enum: ["RAW", "USER_ENTERED"], default: "USER_ENTERED" }, + insertDataOption: { type: "string", enum: ["OVERWRITE", "INSERT_ROWS"], default: "INSERT_ROWS" }, + values: { + type: "array", + items: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }] } }, + example: [["Datum", "Castka"], ["2026-06-15", 1234]] + }, + accessToken: { type: "string", description: "Volitelne. Lepsi je poslat Authorization: Bearer ." }, + accessTokenEnv: { type: "string", description: "Volitelne jmeno env var s access tokenem." } + } + }, + SheetWriteResponse: { + type: "object", + properties: { + spreadsheetId: { type: "string" }, + tableRange: { type: "string" }, + updatedRange: { type: "string" }, + updatedRows: { type: "integer" }, + updatedColumns: { type: "integer" }, + updatedCells: { type: "integer" }, + updates: { type: "object" } + } + }, + GmailMessage: { + type: "object", + properties: { + id: { type: "string", description: "messageId." }, + threadId: { type: "string" }, + labelIds: { type: "array", items: { type: "string" } }, + snippet: { type: "string" }, + raw: { type: "string", description: "Base64url encoded RFC 2822 message, pouziva se pro send." } + } + }, + GoogleDocument: { + type: "object", + properties: { + documentId: { type: "string" }, + title: { type: "string" }, + body: { type: "object" } + } + }, + Presentation: { + type: "object", + properties: { + presentationId: { type: "string" }, + title: { type: "string" }, + slides: { type: "array", items: { type: "object" } } + } + }, + GoogleForm: { + type: "object", + properties: { + formId: { type: "string" }, + info: { type: "object", properties: { title: { type: "string" } } } + } + }, + Person: { + type: "object", + properties: { + resourceName: { type: "string", description: "Identifikator kontaktu pro People API." }, + names: { type: "array", items: { type: "object" } }, + emailAddresses: { type: "array", items: { type: "object" } } + } + }, + Task: { + type: "object", + properties: { + id: { type: "string", description: "taskId." }, + title: { type: "string" }, + notes: { type: "string" }, + status: { type: "string" }, + due: { type: "string" } } } }; @@ -1130,11 +1659,43 @@ function buildGoogleServiceOpenApiPaths(): Record { post: googleOperation("Google Calendar", "Move event to another calendar", undefined, ["calendarId", "eventId"]) }, "/google/sheets/spreadsheets": { + get: googleOperation("Google Sheets", "List spreadsheet files. spreadsheetId is files[].id in the response"), post: googleOperation("Google Sheets", "Create spreadsheet", { properties: { title: "New spreadsheet" } }) }, "/google/sheets/spreadsheets/{spreadsheetId}": { get: googleOperation("Google Sheets", "Get spreadsheet", undefined, ["spreadsheetId"]) }, + "/google/sheets/spreadsheets/{spreadsheetId}/sheets": { + get: googleOperation("Google Sheets", "List sheets/tabs. sheetId is sheets[].properties.sheetId and A1 ranges use sheets[].properties.title", undefined, ["spreadsheetId"]) + }, + "/google/sheets/write-by-url": { + post: { + tags: ["Google Sheets"], + summary: "Write values by spreadsheet URL and sheet name", + description: + "Nejjednodussi zapis do Google Sheets. Posli plnou spreadsheetUrl, sheetName a values. Sluzba sama vytahne spreadsheetId z URL a slozi A1 range. mode=append prida radky pod existujici tabulku, mode=update prepise hodnoty od startCell.", + security: [{ bearerAuth: [] }], + parameters: googleCredentialHeaderParameters(), + requestBody: jsonRequestBody( + { + spreadsheetUrl: "https://docs.google.com/spreadsheets/d/1abcDEFghiJKLmnopQRstuVWXyz/edit#gid=0", + sheetName: "Objednavky", + startCell: "A1", + mode: "append", + valueInputOption: "USER_ENTERED", + values: [ + ["Datum", "Zakaznik", "Castka"], + ["2026-06-15", "ACME", 1234] + ] + }, + "SheetWriteByUrlRequest" + ), + responses: { + "200": jsonResponse("Google Sheets write response", "SheetWriteResponse"), + "400": { description: "Invalid request or Google API error" } + } + } + }, "/google/sheets/spreadsheets/{spreadsheetId}/batch-update": { post: googleOperation("Google Sheets", "Batch update spreadsheet", { requests: [] }, ["spreadsheetId"]) }, @@ -1242,6 +1803,103 @@ function buildGoogleServiceOpenApiPaths(): Record { }; } +function googleCredentialHeaderParameters(): Record[] { + return [ + { + name: "X-Google-Access-Token", + in: "header", + required: false, + schema: { type: "string" }, + description: "Volitelny Google OAuth access token. Alternativy: Authorization: Bearer , body.accessToken, nebo env pres X-Google-Access-Token-Env/body.accessTokenEnv." + }, + { + name: "X-Google-Access-Token-Env", + in: "header", + required: false, + schema: { type: "string", example: "GOOGLE_ACCESS_TOKEN" }, + description: "Jmeno env promenne obsahujici access token. Hodnota env se nevraci ve vystupu." + }, + { + name: "X-Google-Api-Key", + in: "header", + required: false, + schema: { type: "string" }, + description: "Volitelny Google API key pro endpointy, ktere API key podporuji. Alternativy: body.apiKey nebo env GOOGLE_API_KEY / X-Google-Api-Key-Env." + }, + { + name: "X-Google-Api-Key-Env", + in: "header", + required: false, + schema: { type: "string", example: "GOOGLE_API_KEY" }, + description: "Jmeno env promenne obsahujici Google API key. Hodnota env se nevraci ve vystupu." + } + ]; +} + +function oauthClientHeaderParameters(): Record[] { + return [ + { + name: "X-Google-Client-Id", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k body.clientId nebo env GOOGLE_CLIENT_ID." + }, + { + name: "X-Google-Client-Secret", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k body.clientSecret nebo env GOOGLE_CLIENT_SECRET. Secret se nevraci ve vystupu." + } + ]; +} + +function authorizeHeaderParameters(): Record[] { + return [ + { + name: "X-Google-Client-Id", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k query clientId nebo env GOOGLE_CLIENT_ID." + }, + { + name: "X-Google-Redirect-Uri", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k query redirectUri nebo env GOOGLE_REDIRECT_URI." + } + ]; +} + +function serviceAccountHeaderParameters(): Record[] { + return [ + { + name: "X-Google-Service-Account-Email", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k body.serviceAccountEmail nebo env GOOGLE_SERVICE_ACCOUNT_EMAIL." + }, + { + name: "X-Google-Private-Key", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k body.privateKey nebo env GOOGLE_PRIVATE_KEY. Secret se nevraci ve vystupu." + }, + { + name: "X-Google-Scopes", + in: "header", + required: false, + schema: { type: "string" }, + description: "Alternativa k body.scope/body.scopes nebo env GOOGLE_SCOPES." + } + ]; +} + function googleOperation( tag: string, summary: string, @@ -1251,15 +1909,17 @@ function googleOperation( ): Record { const parameters = [ ...pathParams.map((name) => ({ name, in: "path", required: true, schema: { type: "string" } })), - ...queryParams.map((name) => ({ name, in: "query", required: true, schema: { type: "string" } })) + ...queryParams.map((name) => ({ name, in: "query", required: true, schema: { type: "string" } })), + ...googleCredentialHeaderParameters() ]; const operation: Record = { tags: [tag], summary, + description: descriptionForOperation(tag, summary), security: [{ bearerAuth: [] }], responses: { - "200": { description: "Google API response" }, + "200": jsonResponse("Google API response", responseSchemaForOperation(tag, summary)), "400": { description: "Invalid request or Google API error" } } }; @@ -1269,8 +1929,59 @@ function googleOperation( } if (example) { - operation.requestBody = jsonRequestBody(example); + operation.requestBody = jsonRequestBody(example, requestSchemaForOperation(tag, summary)); } return operation; } + +function responseSchemaForOperation(tag: string, summary: string): string { + if (tag === "Google Sheets" && summary.includes("spreadsheetId is files")) return "DriveFileList"; + if (tag === "Google Calendar" && summary.includes("event")) return "CalendarEvent"; + if (tag === "Google Calendar") return "Calendar"; + if (tag === "Google Sheets" && summary.includes("values")) return "ValueRange"; + if (tag === "Google Sheets") return "Spreadsheet"; + if (tag === "Google Drive" || tag === "Google Sheets" && summary.includes("List")) return "DriveFileList"; + if (tag === "Gmail") return "GmailMessage"; + if (tag === "Google Docs") return "GoogleDocument"; + if (tag === "Google Slides") return "Presentation"; + if (tag === "Google Forms") return "GoogleForm"; + if (tag === "Google People") return "Person"; + if (tag === "Google Tasks") return "Task"; + return "DriveFileList"; +} + +function requestSchemaForOperation(tag: string, summary: string): string | undefined { + if (tag === "Google Calendar" && summary.includes("event")) return "CalendarEvent"; + if (tag === "Google Calendar") return "Calendar"; + if (tag === "Google Sheets" && summary.includes("values")) return summary.includes("Batch") ? "BatchUpdateValuesRequest" : "ValueRange"; + if (tag === "Google Sheets") return "Spreadsheet"; + if (tag === "Google Drive") return "DriveFile"; + if (tag === "Gmail") return "GmailMessage"; + if (tag === "Google Docs") return "GoogleDocument"; + if (tag === "Google Slides") return "Presentation"; + if (tag === "Google Forms") return "GoogleForm"; + if (tag === "Google People") return "Person"; + if (tag === "Google Tasks") return "Task"; + return undefined; +} + +function descriptionForOperation(tag: string, summary: string): string { + if (summary.includes("spreadsheetId is files")) { + return "Pouzij tento endpoint pro ziskani spreadsheetId. V odpovedi je spreadsheetId v poli files[].id. Stejna hodnota je ID Google Drive souboru."; + } + + if (summary.includes("sheetId is sheets")) { + return "Pouzij tento endpoint pro ziskani tabu/listu ve spreadsheetu. Numericky sheetId je sheets[].properties.sheetId. Pro values endpointy pouzij A1 range z title, napr. Sheet1!A1:B10."; + } + + if (tag === "Google Calendar" && summary.includes("List calendars")) { + return "Pouzij pro ziskani calendarId. Hlavni kalendar lze typicky volat jako calendarId=primary, ostatni ID jsou v id."; + } + + if (tag === "Google Drive" && summary.includes("List files")) { + return "Pouzij pro ziskani fileId pro Drive/Docs/Sheets/Slides soubory. Google Sheets spreadsheetId odpovida Drive file id."; + } + + return "Volej s Authorization: Bearer . Token ziskas pres /google/oauth/authorize-url a /google/oauth/token, nebo service account flow."; +}