Add support for loading files from Google Drive

#vibecoded
This commit is contained in:
Przemek Więch
2026-05-19 00:38:16 +02:00
parent 60e076fca1
commit 55a1626cf4
26 changed files with 2005 additions and 107 deletions

View File

@@ -7,6 +7,7 @@ export enum DataSourceEnum {
GEDCOM_URL,
WIKITREE,
EMBEDDED,
GOOGLE_DRIVE,
}
/** Source specification together with individual selection. */

View File

@@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
import {Blob} from 'buffer';
import dedent from 'dedent';
import {DataSourceEnum, SourceSelection} from './data_source';
import {
GoogleDriveAuthError,
GoogleDriveDataSource,
GoogleDriveSourceSpec,
} from './google_drive';
import {
GOOGLE_DRIVE_CACHE_KEY_PREFIX,
googleDriveService,
} from './google_drive_service';
import {mockSessionStorage} from './test_helpers';
// Mock googleDriveService
jest.mock('./google_drive_service', () => ({
googleDriveService: {
getAccessToken: jest.fn(),
init: jest.fn(),
signOut: jest.fn(),
},
GOOGLE_DRIVE_CACHE_KEY_PREFIX: 'google-drive:',
}));
describe('GoogleDriveDataSource', () => {
let dataSource: GoogleDriveDataSource;
let sessionStorageMock: {[key: string]: string};
beforeEach(() => {
dataSource = new GoogleDriveDataSource();
sessionStorageMock = mockSessionStorage();
// Mock fetch
global.fetch = jest.fn() as any;
global.window = global as any;
jest.clearAllMocks();
});
describe('isNewData', () => {
it('returns true if fileId is different', () => {
const isNew = dataSource.isNewData(
{spec: {source: DataSourceEnum.GOOGLE_DRIVE, fileId: 'file-1'}},
{spec: {source: DataSourceEnum.GOOGLE_DRIVE, fileId: 'file-2'}},
);
expect(isNew).toBe(true);
});
it('returns false if fileId is the same', () => {
const isNew = dataSource.isNewData(
{spec: {source: DataSourceEnum.GOOGLE_DRIVE, fileId: 'file-1'}},
{spec: {source: DataSourceEnum.GOOGLE_DRIVE, fileId: 'file-1'}},
);
expect(isNew).toBe(false);
});
});
describe('loadData', () => {
const spec: SourceSelection<GoogleDriveSourceSpec> = {
spec: {
source: DataSourceEnum.GOOGLE_DRIVE,
fileId: 'test-file-id',
},
};
it('returns cached data from sessionStorage if present', async () => {
const cached = {gedcom: 'INDI', chartData: {}};
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}test-file-id`] =
JSON.stringify(cached);
const result = await dataSource.loadData(spec);
expect(result).toEqual(cached as any);
expect(googleDriveService.getAccessToken).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
it('throws GoogleDriveAuthError if no access token is available', async () => {
jest.mocked(googleDriveService.getAccessToken).mockReturnValue(null);
await expect(dataSource.loadData(spec)).rejects.toThrow(
GoogleDriveAuthError,
);
});
it('throws GoogleDriveAuthError if fetch returns 401, 403, or 404', async () => {
jest
.mocked(googleDriveService.getAccessToken)
.mockReturnValue('mock-token');
jest.mocked(global.fetch).mockResolvedValue({
status: 403,
statusText: 'Forbidden',
} as any);
await expect(dataSource.loadData(spec)).rejects.toThrow(
GoogleDriveAuthError,
);
});
it('throws generic error if fetch returns other non-200 status', async () => {
jest
.mocked(googleDriveService.getAccessToken)
.mockReturnValue('mock-token');
jest.mocked(global.fetch).mockResolvedValue({
status: 500,
statusText: 'Internal Server Error',
} as any);
await expect(dataSource.loadData(spec)).rejects.toThrow(
'Failed to fetch file from Google Drive (HTTP 500)',
);
});
it('successfully fetches, parses and returns data', async () => {
jest
.mocked(googleDriveService.getAccessToken)
.mockReturnValue('mock-token');
const gedcomContent = dedent`
0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME John /Doe/
1 FAMS @F1@
0 @F1@ FAM
1 HUSB @I1@
0 TRLR
`;
const mockBlob = new Blob([gedcomContent]) as any;
jest.mocked(global.fetch).mockResolvedValue({
status: 200,
blob: jest.fn<() => Promise<Blob>>().mockResolvedValue(mockBlob),
} as any);
const result = await dataSource.loadData(spec);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/drive/v3/files/test-file-id?alt=media',
{
headers: {
Authorization: 'Bearer mock-token',
},
},
);
expect(result.gedcom.indis['I1']).toBeDefined();
expect(result.gedcom.indis['I1'].tag).toBe('INDI');
expect(result.chartData.indis[0].id).toBe('I1');
});
});
});

View File

@@ -0,0 +1,93 @@
import {TopolaData} from '../util/gedcom_util';
import {DataSource, DataSourceEnum, SourceSelection} from './data_source';
import {
GOOGLE_DRIVE_CACHE_KEY_PREFIX,
googleDriveService,
} from './google_drive_service';
import {loadAndPrepareFile} from './load_data';
/** Specification for Google Drive data source. */
export interface GoogleDriveSourceSpec {
source: DataSourceEnum.GOOGLE_DRIVE;
/** The unique ID of the file in Google Drive. */
fileId: string;
}
/** Error thrown when Google Drive authentication or authorization fails or is missing. */
export class GoogleDriveAuthError extends Error {
constructor(message?: string) {
super(message);
this.name = 'GoogleDriveAuthError';
// Set the prototype explicitly.
Object.setPrototypeOf(this, GoogleDriveAuthError.prototype);
}
}
/** Data source representing family tree data loaded from Google Drive. */
export class GoogleDriveDataSource implements DataSource<GoogleDriveSourceSpec> {
isNewData(
newSource: SourceSelection<GoogleDriveSourceSpec>,
oldSource: SourceSelection<GoogleDriveSourceSpec>,
_data?: TopolaData,
): boolean {
return newSource.spec.fileId !== oldSource.spec.fileId;
}
async loadData(
source: SourceSelection<GoogleDriveSourceSpec>,
): Promise<TopolaData> {
const fileId = source.spec.fileId;
const cacheKey = `${GOOGLE_DRIVE_CACHE_KEY_PREFIX}${fileId}`;
// 1. Check sessionStorage cache
try {
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
return JSON.parse(cachedData);
}
} catch (e) {
console.warn('Failed to load data from session storage: ' + e);
}
// 2. Get the active access token
const token = googleDriveService.getAccessToken();
if (!token) {
throw new GoogleDriveAuthError(
'No active Google Drive access token found.',
);
}
// 3. Fetch file content from Google Drive API
const response = await window
.fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.catch((error) => {
throw new Error(
`Network error fetching file from Google Drive: ${(error as Error).message}`,
);
});
// 4. Inspect response status
if (
response.status === 401 ||
response.status === 403 ||
response.status === 404
) {
throw new GoogleDriveAuthError(
`Access denied or file not found (HTTP ${response.status}).`,
);
}
if (response.status !== 200) {
throw new Error(
`Failed to fetch file from Google Drive (HTTP ${response.status}): ${response.statusText}`,
);
}
// 5. Parse and prepare the file content
const blob = await response.blob();
return loadAndPrepareFile(blob, cacheKey);
}
}

View File

@@ -0,0 +1,284 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-var-requires */
import {beforeEach, describe, expect, it, jest} from '@jest/globals';
import {
GOOGLE_DRIVE_CACHE_KEY_PREFIX,
GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY,
GOOGLE_DRIVE_TOKEN_KEY,
GoogleDriveService,
clearGoogleDriveCache,
isGoogleDriveConfigured,
} from './google_drive_service';
import {mockSessionStorage} from './test_helpers';
describe('GoogleDriveService', () => {
let service: GoogleDriveService;
let sessionStorageMock: {[key: string]: string};
let mockTokenClient: any;
let initTokenClientSpy: any;
let revokeSpy: any;
let headMock: any;
let documentMock: any;
beforeEach(() => {
sessionStorageMock = mockSessionStorage();
// Mock document
headMock = {
appendChild: jest.fn((el: any) => {
if (el.onload) {
setTimeout(() => el.onload(), 0);
}
}),
};
documentMock = {
querySelector: jest.fn().mockReturnValue(null),
createElement: jest.fn().mockImplementation(() => ({
src: '',
async: false,
defer: false,
onload: null,
onerror: null,
remove: jest.fn(),
})),
head: headMock,
};
Object.defineProperty(global, 'document', {
value: documentMock,
writable: true,
configurable: true,
});
// Mock global window objects
(global as any).window = global;
(global as any).gapi = {
load: jest.fn((apiName: string, config: any) => {
if (config && config.callback) {
config.callback();
}
}),
};
mockTokenClient = {
requestAccessToken: jest.fn(),
};
initTokenClientSpy = jest.fn().mockReturnValue(mockTokenClient);
revokeSpy = jest.fn((token: string, callback: () => void) => callback());
(global as any).google = {
accounts: {
oauth2: {
initTokenClient: initTokenClientSpy,
revoke: revokeSpy,
},
},
picker: {
DocsView: jest.fn().mockImplementation(() => ({
setMimeTypes: jest.fn().mockReturnThis(),
})),
PickerBuilder: jest.fn().mockImplementation(() => ({
addView: jest.fn().mockReturnThis(),
setOAuthToken: jest.fn().mockReturnThis(),
setDeveloperKey: jest.fn().mockReturnThis(),
setCallback: jest.fn().mockReturnThis(),
setSize: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnValue({
setVisible: jest.fn(),
}),
})),
ViewId: {
DOCS: 'docs',
},
},
};
// Set up env vars for tests
process.env.VITE_GOOGLE_CLIENT_ID = 'test-client-id';
process.env.VITE_GOOGLE_API_KEY = 'test-api-key';
// Reset modules so that module-scoped variables (like loadingScripts and retryCounts)
// are fresh for every test.
jest.resetModules();
const {
GoogleDriveService: FreshGoogleDriveService,
} = require('./google_drive_service');
service = new FreshGoogleDriveService();
jest.clearAllMocks();
});
describe('isGoogleDriveConfigured', () => {
it('returns true when client ID and API key are configured', () => {
expect(isGoogleDriveConfigured()).toBe(true);
});
it('returns false when client ID is missing', () => {
process.env.VITE_GOOGLE_CLIENT_ID = '';
expect(isGoogleDriveConfigured()).toBe(false);
});
});
describe('init', () => {
it('resolves immediately if not configured', async () => {
process.env.VITE_GOOGLE_CLIENT_ID = '';
await expect(service.init()).resolves.not.toThrow();
expect(initTokenClientSpy).not.toHaveBeenCalled();
});
it('successfully initializes scripts and token client', async () => {
await service.init();
expect(initTokenClientSpy).toHaveBeenCalledWith({
client_id: 'test-client-id',
scope: 'https://www.googleapis.com/auth/drive.file',
callback: expect.any(Function),
});
expect(gapi.load).toHaveBeenCalledWith('picker', expect.any(Object));
});
it('throws error and clears initPromise on load failure', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {
// No-op
});
const oldGapi = (global as any).gapi;
delete (global as any).gapi;
await expect(service.init()).rejects.toThrow();
(global as any).gapi = oldGapi;
await expect(service.init()).resolves.not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('getAccessToken', () => {
it('returns null when no token is cached', () => {
expect(service.getAccessToken()).toBeNull();
});
it('returns null when token is expired', () => {
sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY] = 'expired-token';
sessionStorageMock[GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY] = String(
Date.now() - 1000,
);
expect(service.getAccessToken()).toBeNull();
});
it('returns token when cached token is valid', () => {
sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY] = 'valid-token';
sessionStorageMock[GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY] = String(
Date.now() + 60000,
);
expect(service.getAccessToken()).toBe('valid-token');
});
});
describe('requestToken', () => {
it('returns cached valid token without initiating login popup', async () => {
sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY] = 'valid-token';
sessionStorageMock[GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY] = String(
Date.now() + 60000,
);
const token = await service.requestToken();
expect(token).toBe('valid-token');
expect(mockTokenClient.requestAccessToken).not.toHaveBeenCalled();
});
it('triggers requestAccessToken when no valid token cached', async () => {
const tokenPromise = service.requestToken();
// Wait for async script loaders and GAPI load callback in init() to execute
await new Promise((resolve) => setTimeout(resolve, 0));
const callback = initTokenClientSpy.mock.calls[0][0].callback;
callback({
access_token: 'new-auth-token',
expires_in: '3600',
});
const token = await tokenPromise;
expect(token).toBe('new-auth-token');
expect(sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY]).toBe('new-auth-token');
expect(mockTokenClient.requestAccessToken).toHaveBeenCalledWith({
prompt: '',
});
});
it('rejects previous pending promise when superseded by a forced select request', async () => {
const firstTokenPromise = service.requestToken(false);
const secondTokenPromise = service.requestToken(true);
await expect(firstTokenPromise).rejects.toThrow(
'Authentication superseded by a new request.',
);
const callback = initTokenClientSpy.mock.calls[0][0].callback;
callback({
access_token: 'superseded-flow-token',
expires_in: '3600',
});
const secondToken = await secondTokenPromise;
expect(secondToken).toBe('superseded-flow-token');
});
});
describe('signOut', () => {
it('revokes the token and clears storage keys', async () => {
sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY] = 'my-token';
sessionStorageMock[GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY] = '9999999999999';
await service.signOut();
expect(revokeSpy).toHaveBeenCalledWith('my-token', expect.any(Function));
expect(sessionStorageMock[GOOGLE_DRIVE_TOKEN_KEY]).toBeUndefined();
expect(
sessionStorageMock[GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY],
).toBeUndefined();
});
});
describe('clearGoogleDriveCache', () => {
it('clears all google-drive: keys except the specified key', () => {
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file1`] = 'data1';
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file2`] = 'data2';
sessionStorageMock['other-key'] = 'other-data';
clearGoogleDriveCache(`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file1`);
expect(sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file1`]).toBe(
'data1',
);
expect(
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file2`],
).toBeUndefined();
expect(sessionStorageMock['other-key']).toBe('other-data');
});
it('clears all google-drive: keys if no key is excepted', () => {
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file1`] = 'data1';
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file2`] = 'data2';
clearGoogleDriveCache();
expect(
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file1`],
).toBeUndefined();
expect(
sessionStorageMock[`${GOOGLE_DRIVE_CACHE_KEY_PREFIX}file2`],
).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,337 @@
const GOOGLE_IDENTITY_SERVICES_SCRIPT =
'https://accounts.google.com/gsi/client';
const GOOGLE_API_CLIENT_SCRIPT = 'https://apis.google.com/js/api.js';
/** Mapping of dynamically loaded script URLs to the global variables they export on window. */
const SCRIPT_GLOBALS: Record<string, string> = {
[GOOGLE_IDENTITY_SERVICES_SCRIPT]: 'google',
[GOOGLE_API_CLIENT_SCRIPT]: 'gapi',
};
/** Keys used for sessionStorage storage of Google Drive state and credentials. */
export const GOOGLE_DRIVE_TOKEN_KEY = 'google-drive-token';
export const GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY =
'google-drive-token-expires-at';
export const GOOGLE_DRIVE_CACHE_KEY_PREFIX = 'google-drive:';
/** Cache of script loading promises to prevent duplicate fetch operations for the same script. */
const loadingScripts = new Map<string, Promise<void>>();
/** Helper to load a script dynamically. */
function loadScript(src: string): Promise<void> {
const cachedPromise = loadingScripts.get(src);
if (cachedPromise) {
return cachedPromise;
}
// If a script tag with this URL is already present in the DOM, check if it loaded successfully.
// If the script's global export is initialized on window, we can reuse it immediately.
// Otherwise (e.g. if script loading was blocked or failed), remove the stale script element
// so we can attempt a clean reload.
const existingScript = document.querySelector(`script[src="${src}"]`);
if (existingScript) {
const globalVar = SCRIPT_GLOBALS[src];
if (
globalVar &&
(window as unknown as Record<string, unknown>)[globalVar]
) {
return Promise.resolve();
}
existingScript.remove();
}
const promise = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.async = true;
script.defer = true;
script.onload = () => {
resolve();
};
script.onerror = () => {
loadingScripts.delete(src);
script.remove();
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
loadingScripts.set(src, promise);
return promise;
}
/** Centralized helper to check if Google Drive is configured. */
export function isGoogleDriveConfigured(): boolean {
return (
!!import.meta.env.VITE_GOOGLE_CLIENT_ID &&
!!import.meta.env.VITE_GOOGLE_API_KEY
);
}
/** Clears cached Google Drive files from sessionStorage, except for the specified key. */
export function clearGoogleDriveCache(exceptKey?: string): void {
try {
const keysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (
key &&
key.startsWith(GOOGLE_DRIVE_CACHE_KEY_PREFIX) &&
key !== exceptKey
) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => sessionStorage.removeItem(k));
} catch (err) {
console.warn(
'Failed to clear Google Drive cache from session storage:',
err,
);
}
}
const SUPPORTED_MIME_TYPES = [
'application/x-gedcom',
'text/vnd.familysearch.gedcom',
'application/x-zip',
].join(',');
export class GoogleDriveService {
/** Cached promise for the SDK initialization flow. */
private initPromise: Promise<void> | null = null;
/** Google accounts OAuth2 TokenClient instance used to request an access token. */
private tokenClient: google.accounts.oauth2.TokenClient | null = null;
/** Resolve callback for a pending authentication promise. */
private pendingAuthResolve: ((token: string) => void) | null = null;
/** Reject callback for a pending authentication promise. */
private pendingAuthReject: ((error: unknown) => void) | null = null;
/** Cached promise for a pending authentication request to prevent concurrent popup triggers. */
private pendingAuthPromise: Promise<string> | null = null;
/** Initialize Google Drive integration if client ID and API Key are present. */
init(): Promise<void> {
if (this.initPromise) {
return this.initPromise;
}
if (!isGoogleDriveConfigured()) {
this.initPromise = Promise.resolve();
return this.initPromise;
}
this.initPromise = (async () => {
try {
// Dynamically load scripts if not present
await Promise.all([
loadScript(GOOGLE_IDENTITY_SERVICES_SCRIPT),
loadScript(GOOGLE_API_CLIENT_SCRIPT),
]);
if (!window.gapi || !window.google) {
throw new Error(
'Google SDK scripts loaded but global variables are missing.',
);
}
// Load GAPI Picker client
await new Promise<void>((resolve, reject) => {
gapi.load('picker', {
callback: resolve,
onerror: () =>
reject(new Error('Failed to load Google Picker API')),
});
});
// Initialize modern GIS Token Client
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
this.tokenClient = google.accounts.oauth2.initTokenClient({
client_id: clientId,
scope: 'https://www.googleapis.com/auth/drive.file',
callback: (response) => {
if (response.error) {
if (this.pendingAuthReject) {
this.pendingAuthReject(response);
}
return;
}
if (response.access_token) {
// Cache in sessionStorage to persist page refreshes.
const expiresAt = Date.now() + Number(response.expires_in) * 1000;
sessionStorage.setItem(
GOOGLE_DRIVE_TOKEN_KEY,
response.access_token,
);
sessionStorage.setItem(
GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY,
String(expiresAt),
);
if (this.pendingAuthResolve) {
this.pendingAuthResolve(response.access_token);
}
} else {
if (this.pendingAuthReject) {
this.pendingAuthReject(new Error('No access token returned.'));
}
}
},
});
} catch (err) {
console.error('Error during Google SDK initialization:', err);
this.initPromise = null; // Clear cached promise on failure to allow retry.
throw err;
}
})();
return this.initPromise;
}
/** Gets cached access token if still valid. */
getAccessToken(): string | null {
const token = sessionStorage.getItem(GOOGLE_DRIVE_TOKEN_KEY);
const expiresAtStr = sessionStorage.getItem(
GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY,
);
if (token && expiresAtStr) {
const expiresAt = Number(expiresAtStr);
if (Date.now() < expiresAt) {
return token;
}
}
return null;
}
/** Triggers the Google OAuth popup to request a new access token. */
async requestToken(forceAccountSelect = false): Promise<string> {
await this.init();
if (!this.tokenClient) {
throw new Error(
'Google Identity Services not initialized or credentials missing.',
);
}
// Check if we already have a valid token in cache
const cachedToken = this.getAccessToken();
if (cachedToken && !forceAccountSelect) {
return cachedToken;
}
if (this.pendingAuthPromise) {
if (forceAccountSelect) {
if (this.pendingAuthReject) {
this.pendingAuthReject(
new Error('Authentication superseded by a new request.'),
);
}
this.pendingAuthPromise = null;
this.pendingAuthResolve = null;
this.pendingAuthReject = null;
} else {
return this.pendingAuthPromise;
}
}
const authPromise = new Promise<string>((resolve, reject) => {
this.pendingAuthResolve = (token) => {
this.pendingAuthPromise = null;
this.pendingAuthResolve = null;
this.pendingAuthReject = null;
resolve(token);
};
this.pendingAuthReject = (err) => {
this.pendingAuthPromise = null;
this.pendingAuthResolve = null;
this.pendingAuthReject = null;
reject(err);
};
const tokenClient = this.tokenClient;
if (tokenClient) {
tokenClient.requestAccessToken({
prompt: forceAccountSelect ? 'select_account' : '',
});
}
});
if (!forceAccountSelect) {
this.pendingAuthPromise = authPromise;
}
return authPromise;
}
/** Shows the responsive Google Picker to select a .ged or .gdz file. */
async showPicker(
onPicked: (fileId: string) => void,
onCancel?: () => void,
): Promise<void> {
await this.init();
const token = this.getAccessToken();
if (!token) {
throw new Error('User not authenticated.');
}
const apiKey = import.meta.env.VITE_GOOGLE_API_KEY;
if (!apiKey) {
throw new Error('Google API key is missing.');
}
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const appId = clientId ? clientId.split('-')[0] : '';
// Compute dimensions based on window size
const viewWidth = Math.min(800, window.innerWidth - 40);
const viewHeight = Math.min(600, window.innerHeight - 40);
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes(SUPPORTED_MIME_TYPES)
.setMode(google.picker.DocsViewMode.LIST);
const picker = new google.picker.PickerBuilder()
.addView(docsView)
.setOAuthToken(token)
.setDeveloperKey(apiKey)
.setAppId(appId)
.setSize(viewWidth, viewHeight)
.setOrigin(window.location.origin)
.setCallback((data: unknown) => {
const responseObj = data as Record<string, unknown>;
if (responseObj.action === google.picker.Action.PICKED) {
const docs = responseObj[google.picker.Response.DOCUMENTS] as Record<
string,
unknown
>[];
const doc = docs[0];
const fileId = doc[google.picker.Document.ID] as string;
onPicked(fileId);
} else if (responseObj.action === google.picker.Action.CANCEL) {
if (onCancel) {
onCancel();
}
}
})
.build();
picker.setVisible(true);
}
/** Revokes current token and purges tokens from local memory and sessionStorage. */
async signOut(): Promise<void> {
await this.init();
const token = this.getAccessToken();
if (token) {
try {
google.accounts.oauth2.revoke(token, () => {
// Callback required by Google API, no action needed on token revocation completion.
});
} catch (err) {
console.warn('Failed to revoke Google OAuth token:', err);
}
}
sessionStorage.removeItem(GOOGLE_DRIVE_TOKEN_KEY);
sessionStorage.removeItem(GOOGLE_DRIVE_TOKEN_EXPIRES_AT_KEY);
}
}
export const googleDriveService = new GoogleDriveService();

View File

@@ -117,6 +117,20 @@ export async function loadFile(
return {gedcom: await blob.text(), images: new Map()};
}
/** Parses the given file and prepares the TopolaData structure, revoking URLs on error. */
export async function loadAndPrepareFile(
blob: Blob,
cacheId: string,
): Promise<TopolaData> {
const {gedcom, images} = await loadFile(blob);
try {
return prepareData(gedcom, cacheId, images);
} catch (error) {
revokeObjectUrls(images);
throw error;
}
}
/** Fetches data from the given URL. Uses cors-anywhere if handleCors is true. */
export async function loadFromUrl(
url: string,
@@ -151,13 +165,7 @@ export async function loadFromUrl(
throw new Error(response.statusText);
}
const {gedcom, images} = await loadFile(await response.blob());
try {
return prepareData(gedcom, url, images);
} catch (error) {
revokeObjectUrls(images);
throw error;
}
return loadAndPrepareFile(await response.blob(), url);
}
/** Loads data from the given GEDCOM file contents. */

View File

@@ -0,0 +1,34 @@
import {jest} from '@jest/globals';
/**
* Mocks the global sessionStorage object and returns the underlying dictionary object
* that stores the items. This dictionary can be inspected or modified by tests.
*/
export function mockSessionStorage(): {[key: string]: string} {
const sessionStorageMock: {[key: string]: string} = {};
Object.defineProperty(global, 'sessionStorage', {
value: {
getItem: jest.fn((key: string) => sessionStorageMock[key] || null),
setItem: jest.fn((key: string, value: string) => {
sessionStorageMock[key] = value;
}),
removeItem: jest.fn((key: string) => {
delete sessionStorageMock[key];
}),
clear: jest.fn(() => {
for (const key in sessionStorageMock) {
delete sessionStorageMock[key];
}
}),
get length() {
return Object.keys(sessionStorageMock).length;
},
key: jest.fn((index: number) => {
return Object.keys(sessionStorageMock)[index] || null;
}),
},
writable: true,
configurable: true,
});
return sessionStorageMock;
}