mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
Extract url utils from app.tsx
This commit is contained in:
@@ -82,7 +82,7 @@ To refactor `app.tsx` safely and maintain continuous code correctness, we will m
|
||||
#### Phase 1: Pure Component and Utility Extraction
|
||||
* [x] **Step 1.1:** Create `src/components/error_display.tsx` and move `ErrorMessage` and `ErrorPopup` from `src/app.tsx`. Update imports in `src/app.tsx`.
|
||||
* [x] **Step 1.2:** Create `src/datasource/instances.ts` and move data source class instantiations. Update references in `src/app.tsx`. Refactor `EmbeddedDataSource` to clean up its message event listener when the loading promise resolves or rejects (or track listener state) to prevent duplicate event listener leaks on multiple page mounts.
|
||||
* [ ] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify.
|
||||
* [x] **Step 1.3:** Create `src/util/url_args.ts` (the parsing utility) and `src/util/url_args.spec.ts` (its Jest unit test suite) together. Extract URL query parameter parsing functions and types from `src/app.tsx`, write comprehensive tests, update imports in `src/app.tsx`, and run `npm test` to verify.
|
||||
* [ ] **Step 1.4:** Modify `src/menu/top_bar.tsx` to make chart-specific props and event handlers optional (e.g. `data`, `allowAllRelativesChart`, `allowPrintAndDownload`, `eventHandlers`, etc.), preparing the component for rendering on the landing screen without dummy properties.
|
||||
|
||||
#### Phase 2: WebMCP Bridge Extraction
|
||||
|
||||
@@ -16,6 +16,8 @@ const config: Config.InitialOptions = {
|
||||
"d3-selection": "<rootDir>/node_modules/d3-selection/dist/d3-selection.js",
|
||||
"d3-timer": "<rootDir>/node_modules/d3-timer/dist/d3-timer.js",
|
||||
"d3-transition": "<rootDir>/node_modules/d3-transition/dist/d3-transition.js",
|
||||
"d3-drag": "<rootDir>/node_modules/d3-drag/dist/d3-drag.js",
|
||||
"d3-zoom": "<rootDir>/node_modules/d3-zoom/dist/d3-zoom.js",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
158
src/app.tsx
158
src/app.tsx
@@ -1,4 +1,3 @@
|
||||
import * as H from 'history';
|
||||
import queryString from 'query-string';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
@@ -48,7 +47,6 @@ import {Intro} from './intro';
|
||||
import {GoogleAuthModal} from './menu/google_auth_modal';
|
||||
import {TopBar} from './menu/top_bar';
|
||||
import {
|
||||
argsToConfig,
|
||||
Config,
|
||||
configToArgs,
|
||||
DEFALUT_CONFIG,
|
||||
@@ -60,29 +58,14 @@ import {analyticsEvent} from './util/analytics';
|
||||
import {TopolaError} from './util/error';
|
||||
import {getI18nMessage} from './util/error_i18n';
|
||||
import {idToIndiMap, TopolaData} from './util/gedcom_util';
|
||||
import {
|
||||
DataSourceSpec,
|
||||
getArguments,
|
||||
getStaticUrl,
|
||||
getUrlForArgs,
|
||||
} from './util/url_args';
|
||||
import {WebMcpBridge} from './webmcp';
|
||||
|
||||
/**
|
||||
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
|
||||
* injected via a meta tag from Caddy server).
|
||||
*
|
||||
* If this static URL is provided, the viewer is switched to
|
||||
* single-tree mode without the option to load other data.
|
||||
*/
|
||||
function getStaticUrl(): string | undefined {
|
||||
const envUrl = import.meta.env.VITE_STATIC_URL;
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
const metaTag = document.querySelector('meta[name="topola-static-url"]');
|
||||
const metaUrl = metaTag?.getAttribute('content');
|
||||
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
|
||||
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
|
||||
return metaUrl;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const staticUrl = getStaticUrl();
|
||||
|
||||
enum AppState {
|
||||
@@ -93,124 +76,6 @@ enum AppState {
|
||||
LOADING_MORE,
|
||||
}
|
||||
|
||||
type DataSourceSpec =
|
||||
| UrlSourceSpec
|
||||
| UploadSourceSpec
|
||||
| WikiTreeSourceSpec
|
||||
| EmbeddedSourceSpec
|
||||
| GoogleDriveSourceSpec;
|
||||
|
||||
/**
|
||||
* Arguments passed to the application, primarily through URL parameters.
|
||||
* Non-optional arguments get populated with default values.
|
||||
*/
|
||||
interface Arguments {
|
||||
sourceSpec?: DataSourceSpec;
|
||||
selection?: IndiInfo;
|
||||
chartType: ChartType;
|
||||
standalone: boolean;
|
||||
showWikiTreeMenus: boolean;
|
||||
freezeAnimation: boolean;
|
||||
showSidePanel: boolean;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
function getParamFromSearch(name: string, search: queryString.ParsedQuery) {
|
||||
const value = search[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve arguments passed into the application through the URL and uploaded
|
||||
* data.
|
||||
*/
|
||||
function getArguments(location: H.Location): Arguments {
|
||||
const search = queryString.parse(location.search);
|
||||
const getParam = (name: string) => getParamFromSearch(name, search);
|
||||
|
||||
const view = getParam('view');
|
||||
const chartTypes = new Map<string | undefined, ChartType>([
|
||||
['relatives', ChartType.Relatives],
|
||||
['fancy', ChartType.Fancy],
|
||||
['donatso', ChartType.Donatso],
|
||||
]);
|
||||
|
||||
const hash = getParam('file');
|
||||
const url = getParam('url');
|
||||
const embedded = getParam('embedded') === 'true'; // False by default.
|
||||
let sourceSpec: DataSourceSpec | undefined = undefined;
|
||||
if (staticUrl) {
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url: staticUrl,
|
||||
handleCors: false,
|
||||
};
|
||||
} else if (getParam('source') === 'wikitree') {
|
||||
const windowSearch = queryString.parse(window.location.search);
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.WIKITREE,
|
||||
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,
|
||||
hash,
|
||||
};
|
||||
} else if (url) {
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url,
|
||||
handleCors: getParam('handleCors') !== 'false', // True by default.
|
||||
};
|
||||
} else if (embedded) {
|
||||
sourceSpec = {source: DataSourceEnum.EMBEDDED};
|
||||
}
|
||||
|
||||
const indi = getParam('indi');
|
||||
const parsedGen = Number(getParam('gen'));
|
||||
const selection = indi
|
||||
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Determines whether the side panel should be shown taking into account the
|
||||
* URL parameter and the viewport size.
|
||||
*
|
||||
* On mobile devices (max-width: 767px), the side panel is hidden by default.
|
||||
* On tablet and desktop, the side panel is shown by default.
|
||||
*/
|
||||
function getShowSidePanel() {
|
||||
if (window.matchMedia('(max-width: 767px)').matches) {
|
||||
// On mobile, hide the side panel by default.
|
||||
return getParam('sidePanel') === 'true';
|
||||
}
|
||||
// On tablet and desktop, show the side panel by default.
|
||||
return getParam('sidePanel') !== 'false';
|
||||
}
|
||||
|
||||
return {
|
||||
sourceSpec,
|
||||
selection,
|
||||
// Hourglass is the default view.
|
||||
chartType: chartTypes.get(view) || ChartType.Hourglass,
|
||||
|
||||
showSidePanel: getShowSidePanel(),
|
||||
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
|
||||
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
|
||||
freezeAnimation: getParam('freeze') === 'true', // False by default
|
||||
config: argsToConfig(search),
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
/** State of the application. */
|
||||
const [state, setState] = useState<AppState>(AppState.INITIAL);
|
||||
@@ -620,13 +485,10 @@ export function App() {
|
||||
});
|
||||
}, [mcpBridge, location]);
|
||||
|
||||
function updateUrl(args: queryString.ParsedQuery<string>) {
|
||||
const search = queryString.parse(location.search);
|
||||
for (const key in args) {
|
||||
search[key] = args[key];
|
||||
}
|
||||
location.search = queryString.stringify(search);
|
||||
navigate(location);
|
||||
function updateUrl(
|
||||
args: Record<string, string | (string | null)[] | null | undefined>,
|
||||
) {
|
||||
navigate(getUrlForArgs(location, args));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
315
src/util/url_args.spec.ts
Normal file
315
src/util/url_args.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals';
|
||||
import * as H from 'history';
|
||||
import {ChartType} from '../chart';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
import {ChartColors, Ids, PlaceDisplay, Sex} from '../sidepanel/config/config';
|
||||
import {
|
||||
getArguments,
|
||||
getParamFromSearch,
|
||||
getStaticUrl,
|
||||
getUrlForArgs,
|
||||
} from './url_args';
|
||||
|
||||
describe('url_args', () => {
|
||||
const originalEnv = process.env;
|
||||
let documentMock: {
|
||||
querySelector: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {...originalEnv};
|
||||
// Mock document globally
|
||||
documentMock = {
|
||||
querySelector: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: documentMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
// Clean up document mock
|
||||
Object.defineProperty(global, 'document', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStaticUrl', () => {
|
||||
it('returns VITE_STATIC_URL if set', () => {
|
||||
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
|
||||
expect(getStaticUrl()).toBe('http://example.com/static.ged');
|
||||
});
|
||||
|
||||
it('returns meta tag url if meta tag is present and valid', () => {
|
||||
const mockMeta = {
|
||||
getAttribute: jest.fn().mockReturnValue('http://example.com/meta.ged'),
|
||||
};
|
||||
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||
|
||||
expect(getStaticUrl()).toBe('http://example.com/meta.ged');
|
||||
expect(documentMock.querySelector).toHaveBeenCalledWith(
|
||||
'meta[name="topola-static-url"]',
|
||||
);
|
||||
expect(mockMeta.getAttribute).toHaveBeenCalledWith('content');
|
||||
});
|
||||
|
||||
it('ignores template placeholder in meta tag', () => {
|
||||
const mockMeta = {
|
||||
getAttribute: jest.fn().mockReturnValue('{{ env "STATIC_URL" }}'),
|
||||
};
|
||||
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||
|
||||
expect(getStaticUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores __ placeholder in meta tag', () => {
|
||||
const mockMeta = {
|
||||
getAttribute: jest.fn().mockReturnValue('__STATIC_URL_PLACEHOLDER__'),
|
||||
};
|
||||
documentMock.querySelector.mockReturnValue(mockMeta);
|
||||
|
||||
expect(getStaticUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined if neither VITE_STATIC_URL nor meta tag is present', () => {
|
||||
expect(getStaticUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParamFromSearch', () => {
|
||||
it('extracts query param value', () => {
|
||||
expect(getParamFromSearch('test', {test: 'val'})).toBe('val');
|
||||
});
|
||||
|
||||
it('returns undefined if param is array or missing', () => {
|
||||
expect(
|
||||
getParamFromSearch('test', {test: ['val1', 'val2']}),
|
||||
).toBeUndefined();
|
||||
expect(getParamFromSearch('missing', {test: 'val'})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArguments', () => {
|
||||
const createLocation = (search: string): H.Location => ({
|
||||
pathname: '/view',
|
||||
search,
|
||||
hash: '',
|
||||
state: null,
|
||||
key: '',
|
||||
});
|
||||
|
||||
it('returns defaults for empty search', () => {
|
||||
const args = getArguments(createLocation(''));
|
||||
expect(args.sourceSpec).toBeUndefined();
|
||||
expect(args.selection).toBeUndefined();
|
||||
expect(args.detail).toBeUndefined();
|
||||
expect(args.chartType).toBe(ChartType.Hourglass);
|
||||
expect(args.standalone).toBe(true);
|
||||
expect(args.showWikiTreeMenus).toBe(true);
|
||||
expect(args.freezeAnimation).toBe(false);
|
||||
expect(args.showSidePanel).toBe(true); // default on desktop
|
||||
expect(args.config).toEqual({
|
||||
color: ChartColors.COLOR_BY_GENERATION,
|
||||
id: Ids.SHOW,
|
||||
sex: Sex.SHOW,
|
||||
place: PlaceDisplay.FULL,
|
||||
placeCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses chart view types correctly', () => {
|
||||
expect(getArguments(createLocation('?view=relatives')).chartType).toBe(
|
||||
ChartType.Relatives,
|
||||
);
|
||||
expect(getArguments(createLocation('?view=fancy')).chartType).toBe(
|
||||
ChartType.Fancy,
|
||||
);
|
||||
expect(getArguments(createLocation('?view=donatso')).chartType).toBe(
|
||||
ChartType.Donatso,
|
||||
);
|
||||
expect(getArguments(createLocation('?view=unknown')).chartType).toBe(
|
||||
ChartType.Hourglass,
|
||||
);
|
||||
});
|
||||
|
||||
it('parses WikiTree source spec', () => {
|
||||
const args = getArguments(
|
||||
createLocation('?source=wikitree&authcode=123'),
|
||||
);
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.WIKITREE,
|
||||
authcode: '123',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Google Drive source spec', () => {
|
||||
const args = getArguments(
|
||||
createLocation('?source=google-drive&fileId=abc'),
|
||||
);
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.GOOGLE_DRIVE,
|
||||
fileId: 'abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Uploaded source spec', () => {
|
||||
const args = getArguments(createLocation('?file=hash123'));
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.UPLOADED,
|
||||
hash: 'hash123',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses GEDCOM URL source spec', () => {
|
||||
const args = getArguments(
|
||||
createLocation('?url=http://example.com/tree.ged'),
|
||||
);
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url: 'http://example.com/tree.ged',
|
||||
handleCors: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses GEDCOM URL source spec with handleCors false', () => {
|
||||
const args = getArguments(
|
||||
createLocation('?url=http://example.com/tree.ged&handleCors=false'),
|
||||
);
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url: 'http://example.com/tree.ged',
|
||||
handleCors: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Embedded source spec', () => {
|
||||
const args = getArguments(createLocation('?embedded=true'));
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.EMBEDDED,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers staticUrl over other source specs', () => {
|
||||
process.env.VITE_STATIC_URL = 'http://example.com/static.ged';
|
||||
const args = getArguments(
|
||||
createLocation('?embedded=true&url=http://other.com'),
|
||||
);
|
||||
expect(args.sourceSpec).toEqual({
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url: 'http://example.com/static.ged',
|
||||
handleCors: false,
|
||||
});
|
||||
expect(args.standalone).toBe(false);
|
||||
});
|
||||
|
||||
it('parses selection correctly', () => {
|
||||
const args = getArguments(createLocation('?indi=I123&gen=4'));
|
||||
expect(args.selection).toEqual({
|
||||
id: 'I123',
|
||||
generation: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults selection generation to 0 if missing or invalid', () => {
|
||||
const args1 = getArguments(createLocation('?indi=I123'));
|
||||
expect(args1.selection).toEqual({
|
||||
id: 'I123',
|
||||
generation: 0,
|
||||
});
|
||||
|
||||
const args2 = getArguments(createLocation('?indi=I123&gen=abc'));
|
||||
expect(args2.selection).toEqual({
|
||||
id: 'I123',
|
||||
generation: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses detail parameter', () => {
|
||||
const args = getArguments(createLocation('?detail=I456'));
|
||||
expect(args.detail).toBe('I456');
|
||||
});
|
||||
|
||||
it('parses showSidePanel setting', () => {
|
||||
// Mock window.matchMedia for desktop
|
||||
Object.defineProperty(global, 'window', {
|
||||
value: {
|
||||
matchMedia: jest.fn().mockReturnValue({matches: false}),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(getArguments(createLocation('')).showSidePanel).toBe(true);
|
||||
expect(
|
||||
getArguments(createLocation('?sidePanel=false')).showSidePanel,
|
||||
).toBe(false);
|
||||
|
||||
// Mock window.matchMedia for mobile
|
||||
Object.defineProperty(global, 'window', {
|
||||
value: {
|
||||
matchMedia: jest.fn().mockReturnValue({matches: true}),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(getArguments(createLocation('')).showSidePanel).toBe(false);
|
||||
expect(
|
||||
getArguments(createLocation('?sidePanel=true')).showSidePanel,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('parses boolean settings (standalone, showWikiTreeMenus, freeze)', () => {
|
||||
const args = getArguments(
|
||||
createLocation('?standalone=false&showWikiTreeMenus=false&freeze=true'),
|
||||
);
|
||||
expect(args.standalone).toBe(false);
|
||||
expect(args.showWikiTreeMenus).toBe(false);
|
||||
expect(args.freezeAnimation).toBe(true);
|
||||
});
|
||||
|
||||
it('parses config object from query parameters', () => {
|
||||
const args = getArguments(createLocation('?c=s&i=h&s=h&p=s&pn=5'));
|
||||
expect(args.config).toEqual({
|
||||
color: ChartColors.COLOR_BY_SEX,
|
||||
id: Ids.HIDE,
|
||||
sex: Sex.HIDE,
|
||||
place: PlaceDisplay.SHORT,
|
||||
placeCount: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrlForArgs', () => {
|
||||
const createLocation = (search: string): H.Location => ({
|
||||
pathname: '/view',
|
||||
search,
|
||||
hash: '#hash-val',
|
||||
state: null,
|
||||
key: '',
|
||||
});
|
||||
|
||||
it('updates query parameter value and preserves pathname/hash', () => {
|
||||
const loc = createLocation('?param1=old¶m2=keep');
|
||||
const updated = getUrlForArgs(loc, {param1: 'new', param3: 'added'});
|
||||
expect(updated).toEqual({
|
||||
pathname: '/view',
|
||||
search: '?param1=new¶m2=keep¶m3=added',
|
||||
hash: '#hash-val',
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes query parameters set to null or undefined', () => {
|
||||
const loc = createLocation('?param1=val1¶m2=val2');
|
||||
const updated = getUrlForArgs(loc, {param1: null, param2: undefined});
|
||||
expect(updated).toEqual({
|
||||
pathname: '/view',
|
||||
search: '',
|
||||
hash: '#hash-val',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
231
src/util/url_args.ts
Normal file
231
src/util/url_args.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import * as H from 'history';
|
||||
import type {ParsedQuery} from 'query-string';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {ChartType} from '../chart';
|
||||
import {DataSourceEnum} from '../datasource/data_source';
|
||||
import {EmbeddedSourceSpec} from '../datasource/embedded';
|
||||
import {GoogleDriveSourceSpec} from '../datasource/google_drive';
|
||||
import {UploadSourceSpec, UrlSourceSpec} from '../datasource/load_data';
|
||||
import {WikiTreeSourceSpec} from '../datasource/wikitree';
|
||||
import {argsToConfig, Config} from '../sidepanel/config/config';
|
||||
|
||||
export type DataSourceSpec =
|
||||
| UrlSourceSpec
|
||||
| UploadSourceSpec
|
||||
| WikiTreeSourceSpec
|
||||
| EmbeddedSourceSpec
|
||||
| GoogleDriveSourceSpec;
|
||||
|
||||
/**
|
||||
* Arguments passed to the application, primarily through URL parameters.
|
||||
* Non-optional arguments get populated with default values.
|
||||
*/
|
||||
export interface Arguments {
|
||||
sourceSpec?: DataSourceSpec;
|
||||
selection?: IndiInfo;
|
||||
detail?: string;
|
||||
chartType: ChartType;
|
||||
standalone: boolean;
|
||||
showWikiTreeMenus: boolean;
|
||||
freezeAnimation: boolean;
|
||||
showSidePanel: boolean;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a query string using URLSearchParams.
|
||||
*/
|
||||
function parseQuery(search: string): ParsedQuery {
|
||||
const params = new URLSearchParams(search);
|
||||
const result: ParsedQuery = {};
|
||||
params.forEach((value, key) => {
|
||||
const existing = result[key];
|
||||
if (existing !== undefined) {
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
result[key] = [existing as string, value];
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringifies a query object using URLSearchParams.
|
||||
*/
|
||||
function stringifyQuery(query: ParsedQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in query) {
|
||||
const val = query[key];
|
||||
if (val !== undefined && val !== null) {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((v) => {
|
||||
if (v !== null && v !== undefined) {
|
||||
params.append(key, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
params.set(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
const str = params.toString();
|
||||
return str ? `?${str}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load GEDCOM URL from environment variable (Vite VITE_STATIC_URL or dynamically
|
||||
* injected via a meta tag from Caddy server).
|
||||
*
|
||||
* If this static URL is provided, the viewer is switched to
|
||||
* single-tree mode without the option to load other data.
|
||||
*/
|
||||
export function getStaticUrl(): string | undefined {
|
||||
const envUrl = import.meta.env.VITE_STATIC_URL;
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
const metaTag = document.querySelector('meta[name="topola-static-url"]');
|
||||
const metaUrl = metaTag?.getAttribute('content');
|
||||
// Safely ignore if it is empty, the raw caddy template expression, or Vite's raw template placeholder
|
||||
if (metaUrl && !metaUrl.startsWith('__') && !metaUrl.includes('{{ env')) {
|
||||
return metaUrl;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getParamFromSearch(name: string, search: ParsedQuery) {
|
||||
const value = search[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve arguments passed into the application through the URL and uploaded
|
||||
* data.
|
||||
*/
|
||||
export function getArguments(location: H.Location): Arguments {
|
||||
const search = parseQuery(location.search);
|
||||
const getParam = (name: string) => getParamFromSearch(name, search);
|
||||
|
||||
const view = getParam('view');
|
||||
const chartTypes = new Map<string | undefined, ChartType>([
|
||||
['relatives', ChartType.Relatives],
|
||||
['fancy', ChartType.Fancy],
|
||||
['donatso', ChartType.Donatso],
|
||||
]);
|
||||
|
||||
const hash = getParam('file');
|
||||
const url = getParam('url');
|
||||
const embedded = getParam('embedded') === 'true'; // False by default.
|
||||
const staticUrl = getStaticUrl();
|
||||
let sourceSpec: DataSourceSpec | undefined = undefined;
|
||||
if (staticUrl) {
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url: staticUrl,
|
||||
handleCors: false,
|
||||
};
|
||||
} else if (getParam('source') === 'wikitree') {
|
||||
const windowSearch =
|
||||
typeof window !== 'undefined' ? parseQuery(window.location.search) : {};
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.WIKITREE,
|
||||
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,
|
||||
hash,
|
||||
};
|
||||
} else if (url) {
|
||||
sourceSpec = {
|
||||
source: DataSourceEnum.GEDCOM_URL,
|
||||
url,
|
||||
handleCors: getParam('handleCors') !== 'false', // True by default.
|
||||
};
|
||||
} else if (embedded) {
|
||||
sourceSpec = {source: DataSourceEnum.EMBEDDED};
|
||||
}
|
||||
|
||||
const indi = getParam('indi');
|
||||
const parsedGen = Number(getParam('gen'));
|
||||
const selection = indi
|
||||
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
|
||||
: undefined;
|
||||
|
||||
const detail = getParam('detail');
|
||||
|
||||
/**
|
||||
* Determines whether the side panel should be shown taking into account the
|
||||
* URL parameter and the viewport size.
|
||||
*
|
||||
* On mobile devices (max-width: 767px), the side panel is hidden by default.
|
||||
* On tablet and desktop, the side panel is shown by default.
|
||||
*/
|
||||
function getShowSidePanel() {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(max-width: 767px)').matches
|
||||
) {
|
||||
// On mobile, hide the side panel by default.
|
||||
return getParam('sidePanel') === 'true';
|
||||
}
|
||||
// On tablet and desktop, show the side panel by default.
|
||||
return getParam('sidePanel') !== 'false';
|
||||
}
|
||||
|
||||
return {
|
||||
sourceSpec,
|
||||
selection,
|
||||
detail,
|
||||
// Hourglass is the default view.
|
||||
chartType: chartTypes.get(view) || ChartType.Hourglass,
|
||||
|
||||
showSidePanel: getShowSidePanel(),
|
||||
standalone: getParam('standalone') !== 'false' && !embedded && !staticUrl,
|
||||
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
|
||||
freezeAnimation: getParam('freeze') === 'true', // False by default
|
||||
config: argsToConfig(search),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a path/query object suitable for passing to React Router's `navigate` function
|
||||
* with new values added or removed from the current URL.
|
||||
*/
|
||||
export function getUrlForArgs(
|
||||
location: H.Location,
|
||||
newArgs: Record<string, string | (string | null)[] | null | undefined>,
|
||||
): {pathname: string; search: string; hash: string} {
|
||||
const search = parseQuery(location.search);
|
||||
for (const key in newArgs) {
|
||||
const val = newArgs[key];
|
||||
if (val === undefined || val === null) {
|
||||
delete search[key];
|
||||
} else if (Array.isArray(val)) {
|
||||
search[key] = val.filter(
|
||||
(v): v is string => v !== null && v !== undefined,
|
||||
);
|
||||
} else {
|
||||
search[key] = val;
|
||||
}
|
||||
}
|
||||
return {
|
||||
pathname: location.pathname,
|
||||
search: stringifyQuery(search),
|
||||
hash: location.hash,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user