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

@@ -1,6 +1,6 @@
import * as H from 'history';
import queryString from 'query-string';
import {useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {Navigate, Route, Routes, useLocation, useNavigate} from 'react-router';
import {
@@ -21,6 +21,16 @@ import {
} from './chart';
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
import {
GoogleDriveAuthError,
GoogleDriveDataSource,
GoogleDriveSourceSpec,
} from './datasource/google_drive';
import {
clearGoogleDriveCache,
googleDriveService,
isGoogleDriveConfigured,
} from './datasource/google_drive_service';
import {
GedcomUrlDataSource,
getSelection,
@@ -38,6 +48,7 @@ import {
} from './datasource/wikitree';
import {DonatsoChart} from './donatso-chart';
import {Intro} from './intro';
import {GoogleAuthModal} from './menu/google_auth_modal';
import {TopBar} from './menu/top_bar';
import {
argsToConfig,
@@ -49,6 +60,7 @@ import {
} from './sidepanel/config/config';
import {SidePanel} from './sidepanel/side-panel';
import {analyticsEvent} from './util/analytics';
import {TopolaError} from './util/error';
import {getI18nMessage} from './util/error_i18n';
import {idToIndiMap, TopolaData} from './util/gedcom_util';
import {WebMcpBridge} from './webmcp';
@@ -125,7 +137,8 @@ type DataSourceSpec =
| UrlSourceSpec
| UploadSourceSpec
| WikiTreeSourceSpec
| EmbeddedSourceSpec;
| EmbeddedSourceSpec
| GoogleDriveSourceSpec;
/**
* Arguments passed to the application, primarily through URL parameters.
@@ -179,6 +192,14 @@ function getArguments(location: H.Location<UploadLocationState>): Arguments {
authcode:
getParam('authcode') || getParamFromSearch('authcode', windowSearch),
};
} else if (getParam('source') === 'google-drive') {
const fileId = getParam('fileId');
if (fileId) {
sourceSpec = {
source: DataSourceEnum.GOOGLE_DRIVE,
fileId,
};
}
} else if (hash) {
sourceSpec = {
source: DataSourceEnum.UPLOADED,
@@ -232,6 +253,11 @@ function getArguments(location: H.Location<UploadLocationState>): Arguments {
};
}
const uploadedDataSource = new UploadedDataSource();
const gedcomUrlDataSource = new GedcomUrlDataSource();
const embeddedDataSource = new EmbeddedDataSource();
const googleDriveDataSource = new GoogleDriveDataSource();
export function App() {
/** State of the application. */
const [state, setState] = useState<AppState>(AppState.INITIAL);
@@ -260,24 +286,51 @@ export function App() {
const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
/** Freeze animations after initial chart render. */
const [freezeAnimation, setFreezeAnimation] = useState(false);
/** Configuration settings for chart display options (e.g. colors, hiding IDs). */
const [config, setConfig] = useState(DEFALUT_CONFIG);
/** MCP bridge to communicate with external tools or servers (Model Context Protocol). */
const [mcpBridge] = useState(() => new WebMcpBridge());
/** Controls the visibility of the Google Drive OAuth permission modal. */
const [showAuthModal, setShowAuthModal] = useState(false);
/** Stores the file ID that failed to load from Google Drive due to authorization errors. */
const [failedFileId, setFailedFileId] = useState<string>();
/** Tracks whether the user has a valid cached Google Drive OAuth access token. */
const [hasGoogleToken, setHasGoogleToken] = useState(
() => !!googleDriveService.getAccessToken(),
);
const intl = useIntl();
const navigate = useNavigate();
const location = useLocation();
/** Sets the state with a new individual selection and chart type. */
function updateDisplay(newSelection: IndiInfo) {
if (
!selection ||
selection.id !== newSelection.id ||
selection.generation !== newSelection.generation
) {
setSelection(newSelection);
setDetailIndi(newSelection.id);
}
}
/** Prevents the Google Drive "Open with" state from being processed more than once. */
const stateProcessed = useRef(false);
/** Tracks whether the component is currently mounted to prevent state updates after unmount. */
const isMountedRef = useRef(true);
/** Incremented with each load request to ensure only the latest asynchronous load result is applied. */
const fetchIdRef = useRef(0);
/** Manages the mount lifecycle ref to avoid setting state on unmounted components. */
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const updateDisplay = useCallback(
(newSelection: IndiInfo) => {
if (
!selection ||
selection.id !== newSelection.id ||
selection.generation !== newSelection.generation
) {
setSelection(newSelection);
setDetailIndi(newSelection.id);
}
},
[selection],
);
function updateChartWithConfig(config: Config, data: TopolaData | undefined) {
if (data === undefined) {
@@ -306,72 +359,143 @@ export function App() {
setState(AppState.ERROR);
}
const uploadedDataSource = new UploadedDataSource();
const gedcomUrlDataSource = new GedcomUrlDataSource();
const wikiTreeDataSource = new WikiTreeDataSource(intl);
const embeddedDataSource = new EmbeddedDataSource();
const wikiTreeDataSource = useMemo(
() => new WikiTreeDataSource(intl),
[intl],
);
function isNewData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) {
// New data source means new data.
return true;
}
const newSource = {spec: newSourceSpec, selection: newSelection};
const oldSouce = {
spec: sourceSpec,
selection: selection,
};
switch (newSource.spec.source) {
case DataSourceEnum.UPLOADED:
return uploadedDataSource.isNewData(
newSource as SourceSelection<UploadSourceSpec>,
oldSouce as SourceSelection<UploadSourceSpec>,
data,
);
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.isNewData(
newSource as SourceSelection<UrlSourceSpec>,
oldSouce as SourceSelection<UrlSourceSpec>,
data,
);
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.isNewData(
newSource as SourceSelection<WikiTreeSourceSpec>,
oldSouce as SourceSelection<WikiTreeSourceSpec>,
data,
);
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.isNewData(
newSource as SourceSelection<EmbeddedSourceSpec>,
oldSouce as SourceSelection<EmbeddedSourceSpec>,
data,
);
}
}
const isNewData = useCallback(
(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => {
if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) {
// New data source means new data.
return true;
}
const newSource = {spec: newSourceSpec, selection: newSelection};
const oldSouce = {
spec: sourceSpec,
selection: selection,
};
switch (newSource.spec.source) {
case DataSourceEnum.UPLOADED:
return uploadedDataSource.isNewData(
newSource as SourceSelection<UploadSourceSpec>,
oldSouce as SourceSelection<UploadSourceSpec>,
data,
);
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.isNewData(
newSource as SourceSelection<UrlSourceSpec>,
oldSouce as SourceSelection<UrlSourceSpec>,
data,
);
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.isNewData(
newSource as SourceSelection<WikiTreeSourceSpec>,
oldSouce as SourceSelection<WikiTreeSourceSpec>,
data,
);
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.isNewData(
newSource as SourceSelection<EmbeddedSourceSpec>,
oldSouce as SourceSelection<EmbeddedSourceSpec>,
data,
);
case DataSourceEnum.GOOGLE_DRIVE:
return googleDriveDataSource.isNewData(
newSource as SourceSelection<GoogleDriveSourceSpec>,
oldSouce as SourceSelection<GoogleDriveSourceSpec>,
data,
);
}
},
[sourceSpec, selection, data, wikiTreeDataSource],
);
function loadData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
switch (newSourceSpec.source) {
case DataSourceEnum.UPLOADED:
return uploadedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
const loadData = useCallback(
(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => {
switch (newSourceSpec.source) {
case DataSourceEnum.UPLOADED:
return uploadedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.GOOGLE_DRIVE:
if (!isGoogleDriveConfigured()) {
throw new TopolaError(
'GOOGLE_DRIVE_NOT_CONFIGURED',
'Google Drive integration is not configured.',
);
}
return googleDriveDataSource.loadData({
spec: newSourceSpec as GoogleDriveSourceSpec,
selection: newSelection,
});
}
},
[wikiTreeDataSource],
);
// Google Drive "Open with" flow state checking.
useEffect(() => {
const search = queryString.parse(location.search);
const stateParam = search.state;
if (typeof stateParam === 'string' && !stateProcessed.current) {
try {
const parsedState = JSON.parse(stateParam);
if (
parsedState &&
parsedState.action === 'open' &&
Array.isArray(parsedState.ids) &&
parsedState.ids.length > 0
) {
stateProcessed.current = true;
const fileId = parsedState.ids[0];
// Soft redirect to view file
navigate(
{
pathname: '/view',
search: queryString.stringify({
source: 'google-drive',
fileId,
}),
},
{replace: true},
);
}
} catch (err) {
// Silently catch JSON parsing errors for state parameters not meant for us (e.g. from other auth tools)
console.warn(
'Google Drive state query parameter JSON parsing failed or action mismatch:',
err,
);
}
}
}, [navigate, location.search]);
async function onGoogleSignOut() {
await googleDriveService.signOut();
setHasGoogleToken(false);
setData(undefined);
setSelection(undefined);
setDetailIndi(undefined);
// Purge sessionStorage keys starting with "google-drive:"
clearGoogleDriveCache();
navigate({pathname: '/'}, {replace: true});
}
useEffect(() => {
@@ -406,15 +530,29 @@ export function App() {
setChartType(args.chartType);
setFreezeAnimation(args.freezeAnimation);
setConfig(args.config);
const currentFetchId = ++fetchIdRef.current;
try {
const data = await loadData(args.sourceSpec, args.selection);
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
return;
}
// Set state with data.
setData(data);
updateChartWithConfig(args.config, data);
setShowSidePanel(args.showSidePanel);
setState(AppState.SHOWING_CHART);
} catch (error: unknown) {
setErrorMessage(getI18nMessage(error as Error, intl));
if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) {
return;
}
if (error instanceof GoogleDriveAuthError) {
if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) {
setFailedFileId(args.sourceSpec.fileId);
setShowAuthModal(true);
}
} else {
setErrorMessage(getI18nMessage(error as Error, intl));
}
}
} else if (
state === AppState.SHOWING_CHART ||
@@ -431,9 +569,16 @@ export function App() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
updateDisplay(getSelection(data!.chartData, args.selection));
if (loadMoreFromWikitree) {
const currentFetchId = ++fetchIdRef.current;
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await loadWikiTree(args.selection!.id, intl);
if (
!isMountedRef.current ||
fetchIdRef.current !== currentFetchId
) {
return;
}
const newSelection = getSelection(data.chartData, args.selection);
setData(data);
setSelection(newSelection);
@@ -441,6 +586,12 @@ export function App() {
setState(AppState.SHOWING_CHART);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (
!isMountedRef.current ||
fetchIdRef.current !== currentFetchId
) {
return;
}
setState(AppState.SHOWING_CHART);
displayErrorPopup(
intl.formatMessage(
@@ -455,7 +606,17 @@ export function App() {
}
}
})();
});
}, [
location,
state,
selection,
data,
navigate,
intl,
isNewData,
loadData,
updateDisplay,
]);
useEffect(() => {
mcpBridge.registerTools();
@@ -664,6 +825,9 @@ export function App() {
showWikiTreeMenus={
sourceSpec?.source === DataSourceEnum.WIKITREE && showWikiTreeMenus
}
hasGoogleToken={hasGoogleToken}
onGoogleSignOut={onGoogleSignOut}
onGoogleTokenAcquired={() => setHasGoogleToken(true)}
/>
{staticUrl ? (
<Routes>
@@ -677,6 +841,33 @@ export function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)}
{showAuthModal && failedFileId && (
<GoogleAuthModal
failedFileId={failedFileId}
onAuthSuccess={(fileId) => {
setShowAuthModal(false);
setHasGoogleToken(true);
if (fileId === failedFileId) {
setState(AppState.INITIAL);
} else {
navigate(
{
pathname: '/view',
search: queryString.stringify({
source: 'google-drive',
fileId,
}),
},
{replace: true},
);
}
}}
onCancel={() => {
setShowAuthModal(false);
navigate({pathname: '/'}, {replace: true});
}}
/>
)}
</>
);
}

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;
}

View File

@@ -0,0 +1,189 @@
import {useEffect, useRef, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {Button, Header, Icon, Message, Modal} from 'semantic-ui-react';
import {googleDriveService} from '../datasource/google_drive_service';
interface Props {
/** The Google Drive file ID that initially failed to load. */
failedFileId: string;
/** Callback triggered when the authentication and file selection are successful. */
onAuthSuccess: (fileId: string) => void;
/** Callback triggered when the user cancels the modal. */
onCancel: () => void;
}
/**
* Modal dialog that prompts the user to authenticate and select a specific file
* from Google Drive when direct file loading fails due to insufficient permissions.
*/
export function GoogleAuthModal(props: Props) {
const [loading, setLoading] = useState(false);
const [showPickerInstructions, setShowPickerInstructions] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
async function completeAuthFlow(token: string) {
const response = await window.fetch(
`https://www.googleapis.com/drive/v3/files/${props.failedFileId}?alt=media`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.status === 200) {
setLoading(false);
props.onAuthSuccess(props.failedFileId);
return;
}
setShowPickerInstructions(true);
googleDriveService.showPicker(
(pickedFileId) => {
setLoading(false);
props.onAuthSuccess(pickedFileId);
},
() => {
setLoading(false);
},
);
}
async function handleConnect() {
setLoading(true);
setErrorMessage(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setLoading(false);
timeoutRef.current = null;
}, 20000);
try {
const token = await googleDriveService.requestToken(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await completeAuthFlow(token);
} catch (err: unknown) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
console.error('Auth error in fallback modal:', err);
setErrorMessage(
(err as Error).message || 'An error occurred during authentication.',
);
setLoading(false);
}
}
async function handleSwitchAccount() {
setLoading(true);
setErrorMessage(null);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setLoading(false);
timeoutRef.current = null;
}, 20000);
try {
const token = await googleDriveService.requestToken(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
await completeAuthFlow(token);
} catch (err: unknown) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
console.error('Switch account error in fallback modal:', err);
setErrorMessage(
(err as Error).message || 'An error occurred during authentication.',
);
setLoading(false);
}
}
return (
<Modal open={true} onClose={props.onCancel} centered={true} size="tiny">
<Header>
<Icon name="google drive" />
<FormattedMessage
id="google_auth.title"
defaultMessage="Google Drive Access Required"
/>
</Header>
<Modal.Content>
<p>
<FormattedMessage
id="google_auth.instructions"
defaultMessage="To view this file, you must authenticate and select the file from your Google Drive to grant permissions."
/>
</p>
{showPickerInstructions && (
<Message warning>
<Message.Header>
<FormattedMessage
id="google_auth.picker_instructions_header"
defaultMessage="Permissions Required"
/>
</Message.Header>
<p>
<FormattedMessage
id="google_auth.picker_instructions"
defaultMessage="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."
/>
</p>
</Message>
)}
{errorMessage && (
<Message negative>
<p>{errorMessage}</p>
</Message>
)}
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={props.onCancel}>
<FormattedMessage id="google_auth.cancel" defaultMessage="Cancel" />
</Button>
{showPickerInstructions && (
<Button basic onClick={handleSwitchAccount} loading={loading}>
<FormattedMessage
id="google_auth.switch_account_button"
defaultMessage="Switch Account"
/>
</Button>
)}
<Button primary onClick={handleConnect} loading={loading}>
<FormattedMessage
id="google_auth.grant_button"
defaultMessage="Grant Access & Select File"
/>
</Button>
</Modal.Actions>
</Modal>
);
}

View File

@@ -0,0 +1,59 @@
import queryString from 'query-string';
import {FormattedMessage} from 'react-intl';
import {useNavigate} from 'react-router';
import {Icon} from 'semantic-ui-react';
import {
googleDriveService,
isGoogleDriveConfigured,
} from '../datasource/google_drive_service';
import {MenuItem, MenuType} from './menu_item';
interface Props {
menuType: MenuType;
onTokenAcquired?: () => void;
}
/**
* Component representing the Google Drive menu option.
* It handles authenticating the user and launching the Google Picker to select files.
*/
export function GoogleDriveMenu(props: Props) {
const navigate = useNavigate();
// Do not render the menu item if Google Drive API credentials are not configured.
if (!isGoogleDriveConfigured()) {
return null;
}
async function handleClick() {
try {
// 1. Request access token
await googleDriveService.requestToken();
if (props.onTokenAcquired) {
props.onTokenAcquired();
}
// 2. Open Google Picker
await googleDriveService.showPicker((fileId) => {
navigate({
pathname: '/view',
search: queryString.stringify({
source: 'google-drive',
fileId,
}),
});
});
} catch (err) {
console.error('Failed Google Drive login/file picking:', err);
}
}
return (
<MenuItem onClick={handleClick} menuType={props.menuType}>
<Icon name="google drive" />
<FormattedMessage
id="menu.load_from_google_drive"
defaultMessage="Load from Google Drive"
/>
</MenuItem>
);
}

View File

@@ -3,8 +3,10 @@ import {FormattedMessage} from 'react-intl';
import {Link, useLocation, useNavigate} from 'react-router';
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
import {IndiInfo, JsonGedcomData} from 'topola';
import {isGoogleDriveConfigured} from '../datasource/google_drive_service';
import {Media} from '../util/media';
import {MenuType} from './menu_item';
import {GoogleDriveMenu} from './google_drive_menu';
import {MenuItem, MenuType} from './menu_item';
import {SearchBar} from './search';
import {UploadMenu} from './upload_menu';
import {UrlMenu} from './url_menu';
@@ -35,6 +37,12 @@ interface Props {
eventHandlers: EventHandlers;
/** Whether to show additional WikiTree menus. */
showWikiTreeMenus: boolean;
/** Whether the user has authorized Google Drive and has an active token. */
hasGoogleToken: boolean;
/** Callback to sign out of Google Drive. */
onGoogleSignOut?: () => void;
/** Callback triggered when a new Google Drive token is acquired. */
onGoogleTokenAcquired?: () => void;
}
export function TopBar(props: Props) {
@@ -202,6 +210,43 @@ export function TopBar(props: Props) {
);
}
function googleDriveDisconnectMenu(screenSize: ScreenSize) {
if (!props.hasGoogleToken || !isGoogleDriveConfigured()) {
return null;
}
return (
<>
<MenuItem
menuType={
screenSize === ScreenSize.SMALL ? MenuType.Dropdown : MenuType.Menu
}
onClick={props.onGoogleSignOut}
>
<Icon name="sign out" />
<FormattedMessage
id="menu.google_sign_out"
defaultMessage="Disconnect Google Drive"
/>
</MenuItem>
{screenSize === ScreenSize.SMALL ? <Dropdown.Divider /> : null}
</>
);
}
function fileMenuItems(menuType: MenuType) {
return (
<>
<UploadMenu menuType={menuType} {...props} />
<UrlMenu menuType={menuType} {...props} />
<WikiTreeMenu menuType={menuType} {...props} />
<GoogleDriveMenu
menuType={menuType}
onTokenAcquired={props.onGoogleTokenAcquired}
/>
</>
);
}
function fileMenus(screenSize: ScreenSize) {
// In standalone WikiTree mode, show only the "Select WikiTree ID" menu.
if (!props.standalone && props.showWikiTreeMenus) {
@@ -237,26 +282,16 @@ export function TopBar(props: Props) {
}
className="item"
>
<Dropdown.Menu>
<UploadMenu menuType={MenuType.Dropdown} {...props} />
<UrlMenu menuType={MenuType.Dropdown} {...props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...props} />
</Dropdown.Menu>
<Dropdown.Menu>{fileMenuItems(MenuType.Dropdown)}</Dropdown.Menu>
</Dropdown>
) : (
<>
<UploadMenu menuType={MenuType.Menu} {...props} />
<UrlMenu menuType={MenuType.Menu} {...props} />
<WikiTreeMenu menuType={MenuType.Menu} {...props} />
</>
fileMenuItems(MenuType.Menu)
);
case ScreenSize.SMALL:
return (
<>
<UploadMenu menuType={MenuType.Dropdown} {...props} />
<UrlMenu menuType={MenuType.Dropdown} {...props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...props} />
{fileMenuItems(MenuType.Dropdown)}
<Dropdown.Divider />
</>
);
@@ -295,6 +330,7 @@ export function TopBar(props: Props) {
<Dropdown.Menu>
{fileMenus(ScreenSize.SMALL)}
{chartMenus(ScreenSize.SMALL)}
{googleDriveDisconnectMenu(ScreenSize.SMALL)}
{wikiTreeLoginMenu(ScreenSize.SMALL)}
<Dropdown.Item
@@ -330,6 +366,7 @@ export function TopBar(props: Props) {
{fileMenus(ScreenSize.LARGE)}
{chartMenus(ScreenSize.LARGE)}
<Menu.Menu position="right">
{googleDriveDisconnectMenu(ScreenSize.LARGE)}
{wikiTreeLoginMenu(ScreenSize.LARGE)}
<Menu.Item
href="https://github.com/PeWu/topola-viewer"

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Неизвестен съпруг/а",
"family.children": "Деца",
"family.child": "Дете",
"media.not_uploaded": "Файлът не е качен"
"media.not_uploaded": "Файлът не е качен",
"menu.load_from_google_drive": "Зареждане от Google Диск",
"menu.google_sign_out": "Прекъсване на връзката с Google Диск",
"google_auth.title": "Изисква се достъп до Google Диск",
"google_auth.instructions": "За да прегледате този файл, трябва да се удостоверите и да изберете файла от вашия Google Диск, за да предоставите разрешения.",
"google_auth.picker_instructions_header": "Изискват се разрешения",
"google_auth.picker_instructions": "Приложението няма разрешение да чете този файл. Моля, изберете го в изскачащия прозорец на браузъра за файлове, за да предоставите достъп. Ако това е споделен файл, който не се показва, опитайте първо да добавите пряк път към вашия Диск.",
"google_auth.cancel": "Отказ",
"google_auth.grant_button": "Предоставяне на достъп и избор на файл",
"google_auth.switch_account_button": "Смяна на акаунт",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "Интеграцията с Google Drive не е конфигурирана."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Neznámý manžel/ka",
"family.children": "Děti",
"family.child": "Dítě",
"media.not_uploaded": "Soubor nebyl nahrán"
"media.not_uploaded": "Soubor nebyl nahrán",
"menu.load_from_google_drive": "Načíst z Google Disku",
"menu.google_sign_out": "Odpojit Google Disk",
"google_auth.title": "Je vyžadován přístup k Disku Google",
"google_auth.instructions": "Chcete-li tento soubor zobrazit, musíte se ověřit a vybrat soubor z Disku Google, abyste udělili oprávnění.",
"google_auth.picker_instructions_header": "Vyžadována oprávnění",
"google_auth.picker_instructions": "Aplikace nemá oprávnění ke čtení tohoto souboru. Vyberte jej prosím v rozevíracím seznamu prohlížeče souborů pro udělení přístupu. Pokud se jedná o sdílený soubor, který se nezobrazuje, zkuste nejprve přidat zástupce na svůj Disk.",
"google_auth.cancel": "Zrušit",
"google_auth.grant_button": "Udělit přístup a vybrat soubor",
"google_auth.switch_account_button": "Přepnout účet",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "Integrace s Diskem Google není nakonfigurována."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Unbekannter Ehepartner",
"family.children": "Kinder",
"family.child": "Kind",
"media.not_uploaded": "Datei nicht hochgeladen"
"media.not_uploaded": "Datei nicht hochgeladen",
"menu.load_from_google_drive": "Von Google Drive laden",
"menu.google_sign_out": "Google Drive trennen",
"google_auth.title": "Google Drive-Zugriff erforderlich",
"google_auth.instructions": "Um diese Datei anzuzeigen, müssen Sie sich authentifizieren und die Datei aus Ihrem Google Drive auswählen, um Berechtigungen zu erteilen.",
"google_auth.picker_instructions_header": "Berechtigungen erforderlich",
"google_auth.picker_instructions": "Die Anwendung hat keine Berechtigung, diese Datei zu lesen. Bitte wählen Sie sie im Dateibrowser-Popup aus, um Zugriff zu gewähren. Wenn es sich um eine freigegebene Datei handelt, die nicht angezeigt wird, versuchen Sie zuerst, eine Verknüpfung zu Ihrem Drive hinzuzufügen.",
"google_auth.cancel": "Abbrechen",
"google_auth.grant_button": "Zugriff gewähren & Datei auswählen",
"google_auth.switch_account_button": "Konto wechseln",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "Die Google Drive-Integration ist nicht konfiguriert."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Conjoint inconnu",
"family.children": "Enfants",
"family.child": "Enfant",
"media.not_uploaded": "Fichier non téléversé"
"media.not_uploaded": "Fichier non téléversé",
"menu.load_from_google_drive": "Charger depuis Google Drive",
"menu.google_sign_out": "Déconnecter Google Drive",
"google_auth.title": "Accès à Google Drive requis",
"google_auth.instructions": "Pour afficher ce fichier, vous devez vous authentifier et sélectionner le fichier depuis votre Google Drive pour accorder les autorisations.",
"google_auth.picker_instructions_header": "Autorisations requises",
"google_auth.picker_instructions": "L'application n'a pas l'autorisation de lire ce fichier. Veuillez le sélectionner dans la fenêtre contextuelle du navigateur de fichiers pour accorder l'accès. S'il s'agit d'un fichier partagé qui ne s'affiche pas, essayez d'abord d'ajouter un raccourci vers votre Drive.",
"google_auth.cancel": "Annuler",
"google_auth.grant_button": "Accorder l'accès et sélectionner le fichier",
"google_auth.switch_account_button": "Changer de compte",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "L'intégration de Google Drive n'est pas configurée."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Coniuge sconosciuto",
"family.children": "Figli",
"family.child": "Figlio",
"media.not_uploaded": "File non caricato"
"media.not_uploaded": "File non caricato",
"menu.load_from_google_drive": "Carica da Google Drive",
"menu.google_sign_out": "Disconnetti Google Drive",
"google_auth.title": "Accesso a Google Drive richiesto",
"google_auth.instructions": "Per visualizzare questo file, devi autenticarti e selezionare il file dal tuo Google Drive per concedere le autorisations.",
"google_auth.picker_instructions_header": "Autorizzazioni richieste",
"google_auth.picker_instructions": "L'applicazione non ha l'autorizzazione di leggere questo file. Selezionalo nel popup del browser dei file per concedere l'accesso. Se si tratta di un file condiviso che non viene visualizzato, prova prima ad aggiungere una scorciatoia al tuo Drive.",
"google_auth.cancel": "Annulla",
"google_auth.grant_button": "Concedi l'accesso e seleziona il file",
"google_auth.switch_account_button": "Cambia account",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "L'integrazione con Google Drive non è configurata."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Nieznany współmałżonek",
"family.children": "Dzieci",
"family.child": "Dziecko",
"media.not_uploaded": "Plik nieprzesłany"
"media.not_uploaded": "Plik nieprzesłany",
"menu.load_from_google_drive": "Otwórz z Dysku Google",
"menu.google_sign_out": "Rozłącz Dysk Google",
"google_auth.title": "Wymagany dostęp do Dysku Google",
"google_auth.instructions": "Aby wyświetlić ten plik, musisz się zalogować i wybrać plik z Dysku Google, aby przyznać uprawnienia.",
"google_auth.picker_instructions_header": "Wymagane uprawnienia",
"google_auth.picker_instructions": "Aplikacja nie ma uprawnień do odczytu tego pliku. Wybierz go w oknie wyszukiwania plików, aby przyznać dostęp. Jeśli to jest plik udostępniony, który się nie wyświetla, spróbuj najpierw dodać skrót do swojego Dysku.",
"google_auth.cancel": "Anuluj",
"google_auth.grant_button": "Przejdź do autoryzacji i wyboru pliku",
"google_auth.switch_account_button": "Przełącz konto",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "Integracja z Dyskiem Google nie jest skonfigurowana."
}

View File

@@ -144,5 +144,15 @@
"family.unknown_spouse": "Неизвестный супруг(а)",
"family.children": "Дети",
"family.child": "Ребёнок",
"media.not_uploaded": "Файл не загружен"
"media.not_uploaded": "Файл не загружен",
"menu.load_from_google_drive": "Загрузить с Google Диска",
"menu.google_sign_out": "Отключить Google Диск",
"google_auth.title": "Требуется доступ к Google Диску",
"google_auth.instructions": "Чтобы просмотреть этот файл, необходимо пройти авторизацию и выбрать файл на Google Диске для предоставления разрешений.",
"google_auth.picker_instructions_header": "Требуются разрешения",
"google_auth.picker_instructions": "У приложения нет разрешения на чтение этого файла. Пожалуйста, выберите его во всплывающем окне выбора файлов, чтобы предоставить доступ. Если это общий файл, который не отображается, попробуйте сначала добавить ярлык на свой Диск.",
"google_auth.cancel": "Отмена",
"google_auth.grant_button": "Предоставить доступ и выбрать файл",
"google_auth.switch_account_button": "Сменить аккаунт",
"error.GOOGLE_DRIVE_NOT_CONFIGURED": "Интеграция с Google Диском не настроена."
}