# Load from Google Drive

## Problem Statement

Currently, Topola Viewer operates primarily on local file uploads, external HTTP
URLs, or integrations with APIs like WikiTree. However, users who maintain their
genealogy files (such as `.ged` and `.gdz` files) on cloud storage platforms
like Google Drive face significant friction when trying to view, share, or
collaborate on their family trees. The lack of integrated cloud storage support
makes sharing interactive trees between collaborators cumbersome, requiring them
to manually download and re-upload files. Integrating Google Drive directly into
Topola Viewer as a secure, read-only storage provider will allow users to
seamlessly load their trees from the cloud and easily collaborate using direct
shared links.

## The Technical Plan

To support Google Drive files, the application needs to orchestrate
authentication, file selection, and download. The integration consists of five
main components working together:

### Major Components

1. **Google Drive Service**: This is a helper component that manages the
   connection to Google's APIs. It loads the Google libraries, triggers the
   login popup, caches the authorization token, and opens the Google Picker file
   selector.
2. **Google Drive Data Source**: A data loader that is registered in Topola's
   data source system. When the app is asked to load a file ID, this component
   uses the active authorization token to download the raw file content directly
   from Google Drive's secure servers.
3. **App Router / Shell**: The main orchestrator of the application. It parses
   parameters from the URL (like `fileId` and `source=google-drive`) and
   initiates the loading process. If the file download fails due to access
   issues, it manages the state of the fallback dialog.
4. **Access Authorization Modal**: A fallback dialog that displays when a user
   clicks a shared link to a Google Drive file that they do not yet have
   permission to view. It guides them to authenticate and select the target file
   using the Google Picker, which dynamically grants the app permission to
   access that file.
5. **Google Drive Menu**: A button added to Topola Viewer's menu bar that allows
   users to connect their Google account and browse their Drive files.

## Alternatives Considered and Rejected

During the design phase, several alternative approaches were considered but
rejected due to security, privacy, or complexity concerns:

### 1. Using the Broad `drive.readonly` Scope

- **Alternative**: Requesting permission to read any file in the user's Google
  Drive so that shared links could be opened immediately without additional user
  interaction.
- **Why Rejected**: Google classifies `drive.readonly` as a restricted scope.
  Publishing an app using this scope requires an annual third-party security
  assessment (CASA audit) which is prohibitively expensive for an open-source,
  non-profit project. Using the `drive.file` scope keeps the application within
  the non-sensitive tier while protecting user privacy by only granting access
  to files the user explicitly selects.

### 2. Anonymous Downloads for Publicly Shared Files

- **Alternative**: Attempting to download files shared with "anyone with the
  link" directly using standard public URLs without requiring a Google sign-in.
- **Why Rejected**: Public Google Drive URLs do not reliably support browser
  CORS headers for anonymous requests, and larger files trigger virus-warning
  pages that return HTML rather than raw file contents. Requiring a Google login
  for all Google Drive operations allows the app to fetch via the official API
  with an authorization token, which avoids both CORS and virus-scan issues and
  simplifies the code.

### 3. Routing Google Drive Traffic Through a CORS Proxy

- **Alternative**: Using the existing `topolaproxy.bieda.it` proxy to download
  files in order to bypass CORS restrictions.
- **Why Rejected**: Routing traffic through a third-party proxy poses a severe
  security risk because it would require transmitting the user's Google OAuth
  access token to an external service. Additionally, family tree files contain
  sensitive personal information, and routing them through an unauthenticated
  proxy violates privacy-first principles. Direct client-side requests to
  `googleapis.com` ensure all traffic remains securely encrypted between the
  user's browser and Google.

### 4. Implementing Write/Edit Support

- **Alternative**: Allowing users to modify their family trees and write back
  the changes directly to Google Drive.
- **Why Rejected**: Topola Viewer is architected as a read-only visualization
  tool. Implementing full write support would require developing a robust GEDCOM
  and GEDZIP serialization engine, state mutation synchronization, and conflict
  resolution mechanisms. Limiting this feature to read-only viewing aligns with
  the application's core purpose and avoids significant, high-risk complexity.

## Detailed Implementation Plan

This section outlines every file that will be created or modified to implement
the Google Drive storage feature, along with the technical rationale for each
change.

---

### 1. Build and Dependencies Setup

#### [MODIFY] [package.json](../package.json)

- **Rationale**: Type safety is critical for global variables introduced by
  Google scripts.
- **Changes**: Add the following type definitions to `devDependencies`:
  - `@types/gapi` for the legacy Google API client.
  - `@types/google.picker` for the Google Picker API.
  - `@types/google.accounts` for the modern Google Identity Services (GIS) token
    client.

_Note: In contrast to the initial design where GAPI and GIS scripts were loaded
statically in `index.html`, the final implementation loads these scripts
dynamically on demand using the custom `loadScript()` helper function in
`google_drive_service.ts` to improve performance and only download third-party
assets when the user interacts with Google Drive._

---

### 2. Data Source Layer

#### [MODIFY] [src/datasource/data_source.ts](../src/datasource/data_source.ts)

- **Rationale**: Topola Viewer abstracts data loading using the `DataSource`
  interface and `DataSourceEnum`. We must register the Google Drive type here.
- **Changes**: Add a `GOOGLE_DRIVE` entry to the `DataSourceEnum` export.

#### [MODIFY] [src/datasource/load_data.ts](../src/datasource/load_data.ts)

- **Rationale**: Restructure file loading logic so that Google Drive datasource
  can parse file content and perform initial caching without code duplication.
- **Changes**: Export a new helper function,
  `loadAndPrepareFile(blob: Blob, cacheId: string): Promise<TopolaData>`, which
  calls `loadFile()` and `prepareData()` inside a try-catch block, handling
  `revokeObjectUrls()` in case of parsing errors.

#### [NEW] [src/datasource/google_drive.ts](../src/datasource/google_drive.ts)

- **Rationale**: Defines the logic for checking and loading Google Drive files.
- **Changes**:
  - Define the interface `GoogleDriveSourceSpec` that contains the `fileId` and
    a `source` field mapped to `DataSourceEnum.GOOGLE_DRIVE`.
  - Create `GoogleDriveAuthError` (extends `Error`) to signal authentication
    failures or access denial back to the App controller.
  - Implement the `GoogleDriveDataSource` class matching the `DataSource`
    interface. The `loadData` function:
    1. Checks `sessionStorage` under `google-drive:{fileId}` to see if the
       parsed file is already cached (avoiding network requests on page
       refresh).
    2. If not cached, gets the active access token from `googleDriveService`. If
       missing or expired, throws `GoogleDriveAuthError`.
    3. Executes a direct `fetch` to Google's REST endpoint
       (`https://www.googleapis.com/drive/v3/files/{fileId}?alt=media`) using
       the token.
    4. Inspects response status: if 401, 403, or 404, throws
       `GoogleDriveAuthError`; if any other non-200 code, throws a standard
       `Error`.
    5. Parses the downloaded blob using `loadFile` (reusing existing
       GEDCOM/GEDZIP zip parsing).

#### [NEW] [src/datasource/google_drive_service.ts](../src/datasource/google_drive_service.ts)

- **Rationale**: Keeps all interactions with Google Identity Services and GAPI
  initialization isolated, protecting the core app from dependency leakage.
- **Changes**:
  - Create a singleton `googleDriveService` class.
  - Implement an initialization method `init()` that dynamically loads GAPI and
    GIS scripts using a helper function `loadScript(src)`. It then loads GAPI's
    `picker` library via `gapi.load('picker', ...)`. Access `window.gapi` and
    `window.google` using direct global references or `(window as any)` casting
    to prevent TypeScript compilation errors.
  - Handle OAuth initialization via `google.accounts.oauth2.initTokenClient` and
    store the access token in memory. Cache the token and its computed absolute
    expiration timestamp (`Date.now() + expires_in * 1000`) in `sessionStorage`
    to survive page refreshes in the same tab.
  - **Asynchronous Initialization**: Store the initialization Promise as
    `this.initPromise` (which resolves when global scripts are loaded and
    GAPI/GIS are initialized). All public service methods must await
    `this.initPromise` before executing to prevent race conditions and runtime
    errors.
  - **Promise-based OAuth Request**: Since `initTokenClient` accepts a single
    static callback, `requestToken` should store the current Promise's `resolve`
    and `reject` handlers on the service instance (`this.pendingAuthResolve` and
    `this.pendingAuthReject`) and invoke them inside the GIS callback.
  - Provide helper methods:
    - `getAccessToken()`: Returns the cached token if it has not expired
      (checked against the absolute expiration timestamp stored in
      `sessionStorage`).
    - `requestToken(forceAccountSelect)`: Returns a Promise that resolves when
      the Google Identity Services popup completes auth. If `forceAccountSelect`
      is true, sets the GIS configuration `prompt` parameter to
      `'select_account'` (critical for switching accounts on 403 errors).
    - `showPicker(onPicked, onCancel)`: Calculates display dimensions based on
      the window size (`Math.min` bounds) to ensure responsiveness on mobile,
      then builds and opens the Google Picker.
      - Set the developer API key via
        `.setDeveloperKey(import.meta.env.VITE_GOOGLE_API_KEY)` on the
        `PickerBuilder`.
      - Configure `DocsView` using `google.picker.ViewId.DOCS` and restrict it
        to files matching
        `.setMimeTypes('application/x-gedcom,text/vnd.familysearch.gedcom,application/x-zip')`
        to cover `.ged` and `.gdz` formats.
      - Set the origin using `.setOrigin(window.location.origin)` to prevent
        cross-origin issues between the Picker iframe and the viewer page.
      - In the Picker callback, check for
        `data.action === google.picker.Action.PICKED` and retrieve the file ID
        via
        `data[google.picker.Response.DOCUMENTS][0][google.picker.Document.ID]`,
        triggering `onPicked`.
      - If `data.action === google.picker.Action.CANCEL`, trigger the `onCancel`
        callback (if provided) to handle dialog closing gracefully.
    - `signOut()`: Revokes the token using `google.accounts.oauth2.revoke`,
      clears memory and `sessionStorage` tokens, and resets local state.

#### [NEW] [src/datasource/test_helpers.ts](../src/datasource/test_helpers.ts)

- **Rationale**: Provides a mock `sessionStorage` utility for test environments.
- **Changes**:
  - Define `mockSessionStorage()` which mocks `global.sessionStorage` with a
    key-value dictionary and returns it for inspection.

#### [NEW] [tests/import_meta_transformer.js](../tests/import_meta_transformer.js)

- **Rationale**: Translates Vite's `import.meta.env` to `process.env` so that
  tests running in Jest (a Node environment) can correctly access configuration
  properties.

#### [MODIFY] [jest.config.ts](../jest.config.ts)

- **Rationale**: Registers the custom `import_meta_transformer.js` for TS and
  TSX files.

#### [NEW] [src/datasource/google_drive.spec.ts](../src/datasource/google_drive.spec.ts)

- **Rationale**: Unit testing for the `GoogleDriveDataSource` class, verifying
  that the data source is instantiated, fetches, and parses data correctly,
  handles cached items, and throws authentication errors when expected.

#### [NEW] [src/datasource/google_drive_service.spec.ts](../src/datasource/google_drive_service.spec.ts)

- **Rationale**: Unit testing for the `GoogleDriveService` class including
  script loading, token caching, account selection, token revocation, and cache
  clearing.

---

### 3. User Interface Integration

#### [NEW] [src/menu/google_drive_menu.tsx](../src/menu/google_drive_menu.tsx)

- **Rationale**: Provides the user entry point in the navigation bar to open
  files.
- **Changes**:
  - Implement `GoogleDriveMenu` using the `MenuItem` abstraction.
  - When clicked, it requests an OAuth access token via `googleDriveService`. If
    successful, it launches the Google Picker.
  - When a file is selected, it extracts the ID and navigates to `/view` with
    search query params set to `source=google-drive&fileId={id}`.

#### [MODIFY] [src/menu/top_bar.tsx](../src/menu/top_bar.tsx)

- **Rationale**: TopBar contains open actions for uploads, URL loading, and
  WikiTree. We must inject the Google Drive options here.
- **Changes**:
  - Add props: `onGoogleSignOut?: () => void`, `hasGoogleToken: boolean`, and
    `onGoogleTokenAcquired?: () => void`.
  - Check if `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` exist in the
    build environment.
  - If present, render `GoogleDriveMenu` alongside other menus in the desktop
    and mobile file selectors.
  - Inject a **Google Drive Sign Out / Disconnect** button into the top bar in
    the same location where WikiTree login/logout options appear (on the
    right-aligned side of the menu for desktop, and list for mobile), rendering
    only if `hasGoogleToken` is true. Clicking it triggers a sign-out flow that:
    1. Calls the `onGoogleSignOut` callback.

#### [NEW] [src/menu/google_auth_modal.tsx](../src/menu/google_auth_modal.tsx)

- **Rationale**: Essential for private file sharing support under `drive.file`
  and bypasses browser popup blockers.
- **Changes**:
  - Define props interface:
    - `failedFileId: string`: The ID of the file that failed to load.
    - `onAuthSuccess: (fileId: string) => void`: Callback triggered when
      permission is successfully granted or a file is picked.
    - `onCancel: () => void`: Callback triggered when the user cancels the
      modal.
  - Create a modal view overlay displayed in the center of the screen.
  - Prompt: "Google Drive Access Required".
  - Provides a button to initiate connection. The modal implements a two-tier
    strategy tailored to `drive.file` constraints:
    1. Clicking the button first runs a quick OAuth request
       (`googleDriveService.requestToken()`) and attempts to download the target
       file ID. Due to `drive.file` limitations, this direct request will fail
       (with 403 or 404) on the first try if the app has not been authorized for
       this file yet in the user's Google Drive.
    2. In case of access failure (403/404), the modal UI will display detailed
       instructions guiding the user to select the file manually using the
       Google Picker. Selecting the file in the Picker explicitly grants the app
       permission to read it.
    3. **Shared Link Constraint**: If the file was shared via "Anyone with the
       link can view", it may not appear in the user's Picker search or "Shared
       with me" list until they open the link once in Google Drive or add a
       shortcut to "My Drive". The modal should document this instruction to
       guide users opening shared links.
  - **Popup Blocker Handling**: The OAuth popup is only triggered when the user
    explicitly clicks the "Connect" button (a direct user gesture). The modal
    must never attempt to open the OAuth popup automatically on mount.
  - **Non-blocking Loader**: When the login flow is pending, show a spinner on
    the button itself or a cancelable loader. Do not show an un-cancelable
    full-screen loading overlay, as Google Identity Services does not notify the
    app if the user closes the sign-in popup. The connect button must not be
    permanently disabled while loading; it should allow the user to click it
    again to retry or provide a manual reset/timeout in case they closed the
    login popup.
  - **Switch Account Button**: Provides a "Switch Account" button that allows
    users to authenticate with a different Google account directly from the
    modal dialog. In the implementation, this button is rendered conditionally
    once picker instructions are visible (after a failed direct download
    attempt).
  - **Cancel Action**: Provide a "Cancel" button. If clicked, calls
    `props.onCancel()` which redirects the user back to the homepage `/`
    (Intro).

---

### 4. Main App Orchestration

#### [MODIFY] [src/app.tsx](../src/app.tsx)

- **Rationale**: The central application container responsible for routing,
  state tracking, error displaying, and data orchestration.
- **Changes**:
  - **Type & Registration**: Add `GoogleDriveSourceSpec` to the `DataSourceSpec`
    union. Instantiate `googleDriveDataSource` via
    `const googleDriveDataSource = new GoogleDriveDataSource();` and register it
    in both the `loadData()` and `isNewData()` switch statements in `app.tsx`.
  - **Credentials Check & Error Handling**: During data loading, if
    `source=google-drive` but `VITE_GOOGLE_CLIENT_ID` or `VITE_GOOGLE_API_KEY`
    is missing from the environment, throw an error signaling that Google Drive
    integration is not configured.
  - **"Open with" Query Parameter**: Add a root-level `useEffect` or route
    handler to inspect the `state` query parameter using React Router's
    `location.search` hook. If present:
    1. Wrap the query parsing logic in a `try/catch` block to handle cases where
       the `state` parameter is not valid JSON (e.g. from other OAuth
       providers).
    2. Check that the parsed JSON contains Google Drive specific keys
       (`action === 'open'` and a non-empty `ids` array) before triggering the
       redirect.
    3. If valid, extract the first file ID (`ids[0]`) from the array and ignore
       any other IDs. Perform a client-side redirect (soft navigate) to
       `/view?source=google-drive&fileId={fileId}` using `{ replace: true }` so
       it does not pollute the browser history and break the back button.
    4. Clear the `state` query parameter from the URL by performing the
       client-side redirect using `{ replace: true }`, which replaces the URL in
       the history and implicitly clears the `state` parameter to prevent
       infinite redirect loops on subsequent rendering and routing cycles.
  - **Argument Parsing**: Update `getArguments()` to support
    `source=google-drive` and `fileId` query params, returning a
    `GoogleDriveSourceSpec`.
  - **Google Auth State**: Add React state for showing the
    `<GoogleAuthModal />`, storing the `failedFileId`, and tracking
    `hasGoogleToken` (updated upon successful login or logout to ensure proper
    reactivity in `TopBar`).
  - **Catching Auth Errors**: Update the main `useEffect` data loading sequence.
    If `loadData()` throws `GoogleDriveAuthError`, do _not_ set `state` to
    `AppState.ERROR` (which shows a scary red error banner). Instead, keep a
    clean background (e.g. keep `AppState.LOADING` or transition to a non-error
    background) and set `showAuthModal` to true.
  - **Resolving Auth Fallback (Avoiding Deadlock)**:
    - If the user selects a _new_ file in the Picker, perform a soft `navigate`
      to update the URL parameters, triggering `isNewData` and initiating a
      normal reload.
    - If the user successfully authorizes and selects the _same_ file ID
      (matching `failedFileId`), do _not_ navigate (as the URL matches and would
      result in a no-op). Instead, explicitly reset `state` to
      `AppState.INITIAL`. This forces the `useEffect` to execute the load
      sequence again using the newly acquired access token.
  - If the user successfully selects the target file in the auth modal's
    callback, trigger url updating to refresh the page and successfully render
    the chart.
  - **Disconnect Callback**: Pass `onGoogleSignOut` to `TopBar` that:
    1. Revokes the token using `googleDriveService.signOut()`.
    2. Clears the active `data` and `selection` state in `App` and revokes all
       media Object URLs.
    3. Clears the active `sessionStorage` cache (all keys starting with
       `google-drive:`) to prevent unauthorized access by subsequent users on
       shared/public devices.
    4. Redirects the user back to the home route `/` (Intro).

---

### 5. Localization Integration

#### [MODIFY] [src/translations/\*.json](../src/translations) and [src/app.tsx](../src/app.tsx)

- **Rationale**: Ensure all user-facing Google Drive integration UI strings are
  fully translated and localized.
- **Changes**: Add translation keys for the new UI elements across all 7
  localization files (`bg.json`, `cs.json`, `de.json`, `fr.json`, `it.json`,
  `pl.json`, `ru.json`). For the default English catalog, declare messages using
  the `defaultMessage` prop in components or as arguments in
  `intl.formatMessage` calls. Use a `TopolaError` with code
  `'GOOGLE_DRIVE_NOT_CONFIGURED'` to propagate configuration errors so they can
  be parsed and translated by `getI18nMessage()`.
  - `menu.load_from_google_drive`: "Load from Google Drive" (or language
    equivalent)
  - `menu.google_sign_out`: "Disconnect Google Drive" / "Sign out"
  - `google_auth.title`: "Google Drive Access Required"
  - `google_auth.instructions`: "To view this file, you must authenticate and
    select the file from your Google Drive to grant permissions."
  - `google_auth.grant_button`: "Grant Access & Select File"
  - `google_auth.cancel`: "Cancel"
  - `google_auth.picker_instructions_header`: "Permissions Required"
  - `google_auth.picker_instructions`: "The application does not have permission
    to read this file. Please select it in the file browser popup to grant
    access. If this is a shared file that doesn't show up, try adding a shortcut
    to your Drive first."
  - `google_auth.switch_account_button`: "Switch Account"
  - `error.GOOGLE_DRIVE_NOT_CONFIGURED`: "Google Drive integration is not
    configured."

---

## Google Cloud Platform Setup Guide

To support authentication and file picking, Topola Viewer must be connected to a
Google Cloud Platform (GCP) project. Below are the step-by-step instructions to
configure the GCP resources, credentials, and Workspace Marketplace listings.

### Step 1: Create a Google Cloud Project

1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
2. Click the project dropdown in the top navigation and select **New Project**.
3. Enter a name (e.g., `Topola Viewer`) and click **Create**.

### Step 2: Enable Required APIs

1. Navigate to **APIs & Services** $\rightarrow$ **Library**.
2. Search for and enable the following APIs:
   - **Google Drive API** (for file downloading).
   - **Google Picker API** (for the file browser popup).
   - **Google Workspace Marketplace SDK** (required to enable the "Open with"
     context menu inside Google Drive).

### Step 3: Configure the OAuth Consent Screen

1. Navigate to **APIs & Services** $\rightarrow$ **OAuth consent screen**.
2. Select **External** for User Type (so any Google user can log in) and click
   **Create**.
3. Complete the **App information** (App name, Support email, Developer contact
   information) and click **Save and Continue**.
4. In the **Scopes** step, click **Add or Remove Scopes**:
   - Add the scope: `https://www.googleapis.com/auth/drive.file`
   - This scope is classified as non-sensitive, meaning Topola Viewer does _not_
     require expensive security reviews or CASA audits to be verified by Google.
5. Save and continue to finish the configuration.

### Step 4: Create OAuth 2.0 Credentials

1. Navigate to **APIs & Services** $\rightarrow$ **Credentials**.
2. Click **Create Credentials** $\rightarrow$ **OAuth client ID**.
3. Set the **Application type** to **Web application**.
4. Under **Authorized JavaScript origins**, add the domains where the app is
   deployed, as well as local environments:
   - `https://pewu.github.io` (Production)
   - `https://apps.wikitree.com` (Wikitree Integration)
   - `http://localhost:3000` (Local testing and development)
     > [!IMPORTANT] Google's OAuth validation is strict. In local development,
     > ensure you access the application via `http://localhost:3000` rather than
     > `http://127.0.0.1:3000`, as using the raw IP address will result in
     > origin authorization failures unless `http://127.0.0.1:3000` is also
     > explicitly added to this list.
5. Click **Create**. Copy the generated **Client ID** to use in the app
   environment variables (`VITE_GOOGLE_CLIENT_ID`).

### Step 5: Create an API Key (for Google Picker)

1. On the **Credentials** screen, click **Create Credentials** $\rightarrow$
   **API key**.
2. Edit the newly created API key to add restrictions:
   - **Application restrictions**: Choose **HTTP referrers (web sites)**.
   - Add the authorized referrers:
     - `https://pewu.github.io/topola/viewer/*`
     - `https://apps.wikitree.com/apps/wiech13/topola-viewer/*`
     - `http://localhost:3000/*`
   - **API restrictions**: Restrict the key to only allow requests to **both**
     the **Google Picker API** and the **Google Drive API** (the Picker
     component queries user files via the Drive API under the hood; restricting
     it strictly to the Picker API will cause requests to fail).
3. Save the key. Copy the generated **API Key** to use in the app environment
   variables (`VITE_GOOGLE_API_KEY`).

### Step 6: Configure the Workspace Marketplace SDK ("Open with" Integration)

1. Navigate to **APIs & Services** $\rightarrow$ **Enabled APIs & Services**,
   and select the **Google Workspace Marketplace SDK**.
2. Click **App Integration** $\rightarrow$ **Configuration**.
3. Enable the **Drive Extension** integration check.
4. Configure the **Open URL**:
   - URL: `https://pewu.github.io/topola-viewer/` (or the corresponding deploy
     URL).
5. Set up **File Handlers**:
   - Under **Default File Extensions**, register `.ged` and `.gdz`.
6. (Optional) Configure the **Store Listing** to publish the extension to the
   public Google Workspace Marketplace, making it searchable and installable for
   anyone.

## Testing Strategy

Due to the dependency on third-party external Google APIs and credentials,
testing this feature is divided into automated mocking (for CI environments and
unit tests) and manual verification (for local development).

### 1. Automated Unit Testing

All automated unit tests are run via **Jest**. Since Node environments do not
load Google's external CDN scripts, we must isolate and mock these dependencies.

#### Mocking Global Scripts

Create a mock utility in the tests setup file to intercept calls to the global
`google` and `gapi` interfaces:

```typescript
global.gapi = {
  load: jest.fn((api: string, callback: () => void) => callback()),
};

global.google = {
  accounts: {
    oauth2: {
      initTokenClient: jest.fn(() => ({
        requestAccessToken: jest.fn(),
      })),
    },
  },
  picker: {
    PickerBuilder: jest.fn(() => ({
      addView: jest.fn().mockReturnThis(),
      setOAuthToken: jest.fn().mockReturnThis(),
      setDeveloperKey: jest.fn().mockReturnThis(),
      setCallback: jest.fn().mockReturnThis(),
      build: jest.fn(() => ({
        setVisible: jest.fn(),
      })),
    })),
    DocsView: jest.fn().mockImplementation(() => ({
      setMimeTypes: jest.fn().mockReturnThis(),
      setQuery: jest.fn().mockReturnThis(),
    })),
    ViewId: {
      DOCS: 'doc',
      SHARED_WITH_ME: 'shared-with-me',
    },
  },
};
```

#### Data Source Tests

In `src/datasource/google_drive.spec.ts` [NEW]:

- **Verify `isNewData()`**: Confirm it returns true only when the `fileId`
  changes.
- **Verify `loadData()` Success**: Mock `window.fetch` to return a mock blob,
  mock `getAccessToken()` to return a dummy token, and verify the data source
  parses the file stream correctly.
- **Verify `loadData()` Auth Failure**: Mock `getAccessToken()` to return `null`
  or configure `window.fetch` to return `403 Forbidden`, and assert that
  `GoogleDriveAuthError` is thrown.

#### URL Parsing & Orchestration Tests

In existing test suites or manual validation:

- Verify that URL query arguments containing `source=google-drive&fileId=XYZ`
  are parsed into the correct `GoogleDriveSourceSpec`.
- Verify that if `loadData()` throws `GoogleDriveAuthError`, the application
  transitions state, avoids setting a global error message, and triggers the
  rendering of the `<GoogleAuthModal />` fallback popup.

---

### 2. Manual Verification Checklist

Manual testing should be performed locally to verify the full OAuth loop and UI
presentation:

| Test Case                     | Action                                                                                                      | Expected Result                                                                                                                          |
| :---------------------------- | :---------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |
| **Missing Credentials**       | Run the app with empty `VITE_GOOGLE_CLIENT_ID` / `VITE_GOOGLE_API_KEY` values.                              | The Google Drive option is completely hidden from the TopBar menus; local file upload and WikiTree operations continue working normally. |
| **Picker Load**               | Click "Load from Google Drive" in the menu, log in when prompted, select a `.ged`/`.gdz` file.              | Google Picker popup closes, selected file is fetched directly from Google APIs, URL hash updates, and the family chart renders.          |
| **Shared Link Auth Fallback** | Load: `http://localhost:3000/#/view?source=google-drive&fileId=UNAUTHORIZED_ID`                             | The "Google Drive Access Required" modal overlays the screen, preventing rendering.                                                      |
| **Grant Access Flow**         | Click "Grant Access" in the fallback modal, authenticate, and pick the matching file from "Shared with me". | Modal closes, OAuth token gets authorized for the specific file ID, tree downloads and renders successfully.                             |
| **Google Drive UI Open**      | Navigate to: `http://localhost:3000/?state={"ids":["FILE_ID"],"action":"open"}`                             | The app detects the `state` param, automatically prompts Google authentication, downloads the file, and opens the chart.                 |
| **Invalid Selection**         | Pick a non-matching file from the fallback modal's picker.                                                  | App detects the mismatch, updates the URL to the new file's ID, downloads the newly picked file, and displays the tree.                  |

---

### 3. Continuous Integration (CI) Safety

- In CI pipelines (e.g. GitHub Actions workflows), Google API keys are not
  present in the environment.
- Because the feature checks for the presence of environment variables at
  compile/build time, the Google Drive menus are automatically omitted.
- This ensures that visual regression tests (`npm run test:visual`) and E2E test
  runs (`npm run test:e2e`) pass without requiring live OAuth secrets or mock
  servers.

---

## Build and Deployment CI/CD Configuration

To enable the Google Drive integration on the official public deployments, the
Google API credentials must be injected during the automated build process in
GitHub Actions.

### Required GitHub Secrets

You must configure the following repository secrets in your GitHub project
settings:

1.  `GOOGLE_CLIENT_ID`: The official OAuth Client ID for `pewu.github.io` /
    `apps.wikitree.com`.
2.  `GOOGLE_API_KEY`: The official Google Picker API Key.

### Workflow Modifications

The build step (`npm run build`) in the deployment workflows must receive these
secrets as environment variables.

#### 1. Deployment to GitHub Pages (`.github/workflows/deploy-gh-pages.yml`)

Pass the secrets to the build step:

```yaml
- run: npm run build
  env:
    VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
    VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
```

#### 2. Deployment to WikiTree Apps (`.github/workflows/deploy-wikitree-apps.yml`)

Pass the secrets to the build step:

```yaml
- run: npm run build
  env:
    VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
    VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
```

#### 3. Reusable Workflow Inheritance (`.github/workflows/deploy-everywhere.yml`)

Enable secret inheritance on jobs calling these workflows to ensure secrets are
propagated:

```yaml
deploy-gh-pages:
  uses: ./.github/workflows/deploy-gh-pages.yml
  secrets: inherit
```

---

## Security and Privacy

Since Topola Viewer is a purely client-side application that manages sensitive
genealogical data, security and user privacy are core design pillars for this
integration.

### 1. Protection of Public API Credentials

The application's client-side architecture requires compiling the public Google
`Client ID` and `API Key` directly into the JavaScript bundle. These values are
protected from abuse using Google Cloud Platform constraints:

- **OAuth Domain Whitelisting**: The `Client ID` restricts **Authorized
  JavaScript Origins** and **Redirect URIs** to `https://pewu.github.io`,
  `https://apps.wikitree.com`, and `http://localhost:3000`. Google's
  authorization server will automatically block login attempts initiated from
  any other domains.
- **API Key Restrictions**: The `API Key` enforces **HTTP Referrer
  Restrictions** matching the authorized domains. Additionally, the key is
  scoped via **API Restrictions** to only permit requests to the **Google Picker
  API** and **Google Drive API**, preventing its use on other Google Cloud
  services.
- **No Secrets Compiled**: The application never utilizes or compiles an OAuth
  `Client Secret`, which is only required for server-side integrations.

### 2. Data Privacy & Zero-Server Architecture

- **Direct Client-to-Google Communication**: All requests to Google Drive APIs
  are initiated directly from the user's browser.
- **No Third-Party Proxies**: To prevent token leakage and maintain data
  confidentiality, Google Drive downloads **never** route through the
  `topolaproxy.bieda.it` CORS proxy.
- **Local Parsing**: The fetched GEDCOM or GEDZIP file content is parsed
  entirely within the browser using local JavaScript. No family data, files, or
  access tokens are ever transmitted to, processed by, or stored on external
  servers.
- **Short-Lived Auth Tokens**: The application uses short-lived access tokens
  that expire automatically. No persistent refresh tokens are requested or
  stored.
- **Session Storage Cache Security**: To optimize load times, parsed tree data
  is cached in `sessionStorage` under `google-drive:{fileId}`. To protect user
  privacy on shared or public computers, signing out or disconnecting Google
  Drive explicitly purges all `google-drive:*` keys from the browser's
  `sessionStorage`.

### 3. Principle of Least Privilege (OAuth Scopes)

Instead of requesting the broad `drive.readonly` scope, which grants visibility
into a user's entire Google Drive, this feature utilizes the restrictive
`drive.file` scope. Under this scope:

- The application only has permission to view files that the user has explicitly
  opened with the app (either by selecting the file in the Google Picker or
  opening it via the "Open with" Google Drive UI option).
- Topola Viewer remains blind to all other files and directories in the user's
  Google Drive.

## Known Limitations

1. **Image Loading from Plain `.ged` Files**: Only `.gdz` archives are supported
   for viewing media files from Google Drive. Plain `.ged` files will only
   display genealogical data, and any relative/external image paths will render
   as broken images because standard HTML `<img>` tags cannot transmit the
   `Authorization` header required for private Google Drive requests.
2. **Iframe Embedding (WikiTree)**: Running Google Drive authentication and
   loading files from Google Drive inside an embedding iframe (such as on
   `apps.wikitree.com`) can fail due to iframe popup sandboxing and origin
   mismatch restrictions. This configuration is not actively supported.
3. **Session Refresh Cache**: When a page is refreshed, zipped images cached in
   `sessionStorage` might have invalid Object URLs. We choose to ignore this
   problem until it becomes a visible UX issue.
4. **Access Modal Lockout**: If a user logs into a Google account that does not
   have permissions to read the file, they are prompted with the "Access
   Required" modal. Since this modal blocks the viewport, they cannot click the
   top-bar "Disconnect" button to switch Google accounts, requiring them to
   click "Cancel" to return to the homepage first.

---

## Future Improvements

1. **Self-Hosting Runtime Configuration Support**: Read `google-client-id` and
   `google-api-key` from HTML `<meta>` tags at runtime (similar to
   `topola-static-url`), allowing self-hosters to deploy and configure Google
   Drive support without rebuilding the static assets.
2. **Fetch Cancellation & Race Condition Mitigation**: Implement an
   `AbortController` or active boolean flag cancellation check in `app.tsx`'s
   `useEffect` loader to ensure slow background downloads do not overwrite newer
   datasets if a user changes the source/file before the previous request
   finishes.
