---
title: "Google 表格、Excel 和 Airtable"
description: "使用您自己的数据源提供程序将图表链接到 Google 表格、Excel 工作簿或 Airtable 表格。"
canonical_url: "https://tiptap.zhcndoc.com/diagrams/guides/spreadsheets"
---

# Google 表格、Excel 和 Airtable

使用您自己的数据源提供程序将图表链接到 Google 表格、Excel 工作簿或 Airtable 表格。

## 如何组合在一起

提供商调用服务的 API 并返回图表数据。该文档存储地址和快照，对于每个 [Data Source](https://tiptap.zhcndoc.com/diagrams/data/sources.md).提供商在您创建令牌时从您的登录或服务器获取令牌，而不是从地址获取令牌。

| 服务        | 提供商 ID          | 地址                                                   | 凭据                      |
| --------- | --------------- | ---------------------------------------------------- | ----------------------- |
| Google 表格 | `google-sheets` | `{ spreadsheetId, range }`                           | 用户的 Google 登录 (OAuth)   |
| Excel     | `excel`         | `{ itemId, table }` 或 `{ itemId, worksheet, range }` | 用户的 Microsoft 登录 (MSAL) |
| Airtable  | `airtable`      | `{ baseId, table, fields }`                          | 服务器上的令牌                 |

您选择提供商 ID。 [编写提供者](https://tiptap.zhcndoc.com/diagrams/data/sources.md#writing-a-provider) 表示提供者必须具备的内容。

## Google 表格

### 已发布的工作表，没有凭据

以 CSV 形式发布的工作表不需要您自己的提供商。内置 `url-csv` 提供程序读取它。

1. 在 Google 表格中，选择“文件”、“共享”、“发布到网络”。
2. 选择工作表和逗号分隔值 (.csv)，然后发布。
3. 将图表链接到其显示的 URL。

```ts
editor.commands.linkChartSource({
  provider: 'url-csv',
  address: {
    url: 'https://docs.google.com/spreadsheets/d/e/2PACX-…/pub?gid=0&single=true&output=csv',
  },
  readOnly: true,
})
```

- 知道 URL 的任何人都可以阅读已发布的工作表。
- Google 在更改后几分钟更新已发布的工作表，而不是立即更新。

### 通过 Sheets API 的私有工作表

要设置的内容：

- 启用了 Google Sheets API 的 Google Cloud 项目。
- Web 应用程序类型的 OAuth 客户端 ID，您的网站作为授权的 JavaScript 源。
- 范围 `https://www.googleapis.com/auth/spreadsheets.readonly`。
- 返回访问令牌的登录，例如 Google Identity Services 的令牌客户端。

提供程序读取范围 [`spreadsheets.values.get`](https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/get):

```ts
import {
  ChartSourceError,
  gridToChartData,
  type ChartSourceProvider,
} from '@tiptap-pro/extension-diagrams/core'

function sheetAddress(address: unknown): { spreadsheetId: string; range: string } {
  if (
    typeof address === 'object' &&
    address !== null &&
    'spreadsheetId' in address &&
    typeof address.spreadsheetId === 'string' &&
    'range' in address &&
    typeof address.range === 'string'
  ) {
    return { spreadsheetId: address.spreadsheetId, range: address.range }
  }
  throw new ChartSourceError('The address needs a spreadsheetId and a range.')
}

/** Reads a range of a Google Sheet. `getAccessToken` comes from your own Google sign-in. */
export function googleSheetsSource(options: {
  getAccessToken: () => Promise<string>
}): ChartSourceProvider {
  return {
    id: 'google-sheets',
    read: async (address, { fetch }) => {
      const { spreadsheetId, range } = sheetAddress(address)
      const url =
        `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}` +
        `/values/${encodeURIComponent(range)}` +
        '?valueRenderOption=UNFORMATTED_VALUE&dateTimeRenderOption=FORMATTED_STRING'
      const response = await fetch(url, {
        headers: { Authorization: `Bearer ${await options.getAccessToken()}` },
      })
      if (!response.ok) throw new ChartSourceError(`Google Sheets answered ${response.status}.`)

      const { values = [] }: { values?: unknown[][] } = await response.json()
      return gridToChartData(
        values.map((row) => row.map((cell) => String(cell ?? ''))),
        { decimalSeparator: '.' },
      )
    },
    describe: (address) => `Google Sheet, ${sheetAddress(address).range}`,
  }
}
```

注册它并链接图表：

- `getGoogleAccessToken` 是返回登录用户的访问令牌的函数。
- `spreadsheetId` 是工作表 URL 中的长 ID。
- `range` 采用 A1 表示法，带有工作表名称。

```ts
import { Diagrams } from '@tiptap-pro/extension-diagrams'

Diagrams.configure({
  sources: [googleSheetsSource({ getAccessToken: getGoogleAccessToken })],
})

editor.commands.linkChartSource({
  provider: 'google-sheets',
  address: { spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', range: 'Sales!A1:D13' },
  readOnly: true,
})
```

第一行是标题，第一列包含标签。数字以数字形式到达，因为 `UNFORMATTED_VALUE` 返回 `1200.5` 而不是 `$1,200.50`。日期如表所示通过 `FORMATTED_STRING` 到达。 API 在行末尾留下了空单元格，并且图表将它们显示为无值。

Sheets API 允许每个项目每分钟 300 个读取请求，每个用户每分钟 60 个。除此之外，它会回答 429。

## Excel

提供程序通过 Microsoft Graph 读取 OneDrive 或 SharePoint 中的 Excel 工作簿。

设置内容：

- Microsoft Entra ID 中的应用程序注册，具有单页应用程序重定向 URI。
- 委派权限 `Files.ReadWrite`。提供程序仅读取，但 Microsoft Graph 将 `Files.ReadWrite` 列为读取范围的权限。
- MSAL (`@azure/msal-browser`) 用于让用户登录并获取令牌。
- `.xlsx` 文件形式的工作簿。其 `itemId` 是 Microsoft Graph 为文件返回的驱动器项目 ID。

提供程序会读取[表格区域](https://learn.microsoft.com/en-us/graph/api/table-range?view=graph-rest-1.0)或[工作表区域](https://learn.microsoft.com/en-us/graph/api/range-get?view=graph-rest-1.0)：

```ts
import {
  ChartSourceError,
  gridToChartData,
  type ChartSourceProvider,
} from '@tiptap-pro/extension-diagrams/core'

const text = (value: unknown): string => (typeof value === 'string' ? value : '')

/** `{ itemId, table }` or `{ itemId, worksheet, range }`, with a `driveId` for SharePoint. */
function rangePath(address: unknown): string {
  const field = (key: string) =>
    typeof address === 'object' && address !== null && key in address
      ? text(Reflect.get(address, key))
      : ''
  if (!field('itemId')) throw new ChartSourceError('The address needs an itemId.')

  const drive = field('driveId') ? `drives/${field('driveId')}` : 'me/drive'
  const workbook = `${drive}/items/${field('itemId')}/workbook`
  if (field('table')) return `${workbook}/tables/${encodeURIComponent(field('table'))}/range`
  const sheet = encodeURIComponent(field('worksheet'))
  return `${workbook}/worksheets/${sheet}/range(address='${field('range')}')`
}

/** Reads an Excel workbook in OneDrive or SharePoint. `getAccessToken` comes from MSAL. */
export function excelSource(options: {
  getAccessToken: () => Promise<string>
}): ChartSourceProvider {
  return {
    id: 'excel',
    read: async (address, { fetch }) => {
      const url = `https://graph.microsoft.com/v1.0/${rangePath(address)}?$select=values,text`
      const response = await fetch(url, {
        headers: { Authorization: `Bearer ${await options.getAccessToken()}` },
      })
      if (!response.ok) throw new ChartSourceError(`Microsoft Graph answered ${response.status}.`)

      const range: { values: unknown[][]; text: string[][] } = await response.json()
      // A date is a number in Excel, so the label column is read as the workbook shows it
      return gridToChartData(
        range.values.map((row, r) =>
          row.map((value, c) =>
            c > 0 && typeof value === 'number' ? String(value) : text(range.text[r]?.[c]),
          ),
        ),
        { decimalSeparator: '.' },
      )
    },
  }
}
```

来自 MSAL 的令牌，可以时以静默方式登录，必须时会弹出窗口：

```ts
import { PublicClientApplication } from '@azure/msal-browser'

const msal = new PublicClientApplication({ auth: { clientId: 'your-client-id' } })
await msal.initialize()

async function getAccessToken(): Promise<string> {
  const request = { scopes: ['Files.ReadWrite'], account: msal.getAllAccounts()[0] }
  try {
    return (await msal.acquireTokenSilent(request)).accessToken
  } catch {
    return (await msal.acquireTokenPopup(request)).accessToken
  }
}
```

注册它并将图表链接到名为 `Sales` 的表：

```ts
Diagrams.configure({ sources: [excelSource({ getAccessToken })] })

editor.commands.linkChartSource({
  provider: 'excel',
  address: { itemId: '01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K', table: 'Sales' },
  readOnly: true,
})
```

首选表作为地址。表会随着新行的增加而增长，而像 `A1:D13` 这样的范围则不会。 SharePoint 中的工作簿或与用户共享的工作簿也需要在地址中包含 `driveId`。

数字来自 `values`，标签来自 `text`，如工作簿所示。

Microsoft Graph 允许一个租户中每个应用每 10 秒发出 1,500 个请求。除此之外，它会回答 429。

## Airtable

提供商通过服务器上的路由读取 Airtable 表的记录，这会添加令牌。

设置内容：

- 具有 `data.records:read` 范围和对基础的访问权限的个人访问令牌。它保留在您的服务器上。
- 基本 ID (`app…`) 和表名称或 ID (`tbl…`)，均位于表的 URL 中。
- 您服务器上的以下路由。

打开应用程序网页的任何人都可以在那里读取令牌，并且令牌会到达它有权访问的每个基地。该路由仅发送 `GET` 请求，并且仅发送到您允许的基础：

```ts
/**
 * Runs on your server. Forwards `GET /api/airtable/<baseId>/<table>?…` to Airtable with a
 * personal access token, for the bases you allow.
 */
export function airtableProxy(options: {
  token: string
  bases: string[]
}): (request: Request) => Promise<Response> {
  return async (request) => {
    const url = new URL(request.url)
    const path = url.pathname.replace(/^\/api\/airtable\//, '')
    if (request.method !== 'GET' || !options.bases.includes(path.split('/')[0] ?? '')) {
      return new Response('Not allowed', { status: 403 })
    }

    const response = await fetch(`https://api.airtable.com/v0/${path}${url.search}`, {
      headers: { Authorization: `Bearer ${options.token}` },
    })
    return new Response(response.body, {
      status: response.status,
      headers: { 'Content-Type': 'application/json' },
    })
  }
}
```

将其安装在您的服务器接受请求的位置，例如 `app.get('/api/airtable/*', …)`。检查登录用户是否可以阅读该库。

提供程序读取[表的记录](https://airtable.com/developers/web/api/list-records)，每页 100 条，并跟随 `offset` 进入下一页：

```ts
import {
  ChartSourceError,
  gridToChartData,
  type ChartSourceProvider,
} from '@tiptap-pro/extension-diagrams/core'

function tableAddress(address: unknown): { baseId: string; table: string; fields: string[] } {
  if (
    typeof address === 'object' &&
    address !== null &&
    'baseId' in address &&
    typeof address.baseId === 'string' &&
    'table' in address &&
    typeof address.table === 'string' &&
    'fields' in address &&
    Array.isArray(address.fields)
  ) {
    return { baseId: address.baseId, table: address.table, fields: address.fields.map(String) }
  }
  throw new ChartSourceError('The address needs a baseId, a table and its fields.')
}

/** Reads the records of an Airtable table through `proxyUrl`, a route of your server. */
export function airtableSource(options: { proxyUrl: string }): ChartSourceProvider {
  return {
    id: 'airtable',
    read: async (address, { fetch }) => {
      const { baseId, table, fields } = tableAddress(address)
      const records: Record<string, unknown>[] = []
      let offset = ''
      do {
        const query = new URLSearchParams(offset ? { offset } : {})
        for (const field of fields) query.append('fields[]', field)
        const path = `${encodeURIComponent(baseId)}/${encodeURIComponent(table)}`
        const response = await fetch(`${options.proxyUrl}/${path}?${query}`)
        if (!response.ok) throw new ChartSourceError(`Airtable answered ${response.status}.`)

        const page: { records: { fields: Record<string, unknown> }[]; offset?: string } =
          await response.json()
        records.push(...page.records.map((record) => record.fields))
        offset = page.offset ?? ''
      } while (offset)

      // Airtable leaves out empty fields, so the columns are the fields the address names
      const cell = (value: unknown) =>
        typeof value === 'string' || typeof value === 'number' ? String(value) : ''
      return gridToChartData(
        [fields, ...records.map((record) => fields.map((field) => cell(record[field])))],
        { decimalSeparator: '.' },
      )
    },
    describe: (address) => `Airtable, ${tableAddress(address).table}`,
  }
}
```

注册它并将图表链接到表的两个字段：

```ts
Diagrams.configure({ sources: [airtableSource({ proxyUrl: '/api/airtable' })] })

editor.commands.linkChartSource({
  provider: 'airtable',
  address: { baseId: 'appXXXXXXXXXXXXXX', table: 'Deals', fields: ['Stage', 'Count'] },
  readOnly: true,
})
```

地址中的 `fields` 是按顺序排列的列。 Airtable 在记录中留下了空字段，因此记录本身并不能说明有哪些列。第一个字段包含标签。提供者读取文本和数字。任何其他字段（例如附件或链接记录）均显示为无值。

Airtable 允许每个基地每秒 5 个请求。之后，它会回答 429 并在 30 秒后再次接受请求。包含 1,000 条记录的表需要 10 个请求。

## 使图表保持最新

链接的图表会显示其快照，直到您将其同步。

```ts
// The selected chart
editor.commands.syncChartData()

// Every chart that reads this range
await editor.storage.diagrams.requestSync({
  provider: 'google-sheets',
  address: { spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms', range: 'Sales!A1:D13' },
})
```

读取失败将转到 `onSourceError` 选项。上述提供商会因地址错误或服务错误答案而抛出 `ChartSourceError`。其他故障（例如连接丢失或令牌请求失败）会以其自己的错误类型到达。 [图表同步时](https://tiptap.zhcndoc.com/diagrams/data/sources.md#when-a-chart-synchronizes) 具有读取失败的规则、`requestSync` 和`setInterval` 的计划以及服务限制。

## 需要注意的事项

- 切勿将令牌放入地址中，因为文档会与打开该文档的每个人共享该令牌。
- 适用于每个数据源的规则：与 `readOnly: true` 链接（[只读](https://tiptap.zhcndoc.com/diagrams/data/sources.md#read-only)），将 `encoding` 设置为仅显示某些列（[仅显示某些列](https://tiptap.zhcndoc.com/diagrams/data/sources.md#showing-only-some-columns)），并使用 `detachChartSource` 和 `relinkChartSource` 手动编辑（[链接或分离](https://tiptap.zhcndoc.com/diagrams/data/sources.md#linked-or-detached)）。
- 对于 Google 表格和 Excel，每个人都使用自己的帐户登录。没有访问权限的人仍然可以看到快照，但同步失败。
