Files
topola-viewer/src/datasource/load_data.ts
2026-05-31 09:59:12 +02:00

274 lines
7.4 KiB
TypeScript

import {Buffer} from 'buffer';
import {strFromU8, unzip, Unzipped} from 'fflate';
import {IndiInfo, JsonGedcomData} from 'topola';
import {analyticsEvent} from '../util/analytics';
import {TopolaError} from '../util/error';
import {convertGedcom, getSoftware, TopolaData} from '../util/gedcom_util';
import {DataSource, DataSourceEnum, SourceSelection} from './data_source';
/**
* Returns a valid IndiInfo object, either with the given indi and generation
* or with an individual taken from the data and generation 0.
*/
export function getSelection(
data: JsonGedcomData,
selection?: IndiInfo,
): IndiInfo {
// If ID is not given or it doesn't exist in the data, use the first ID in
// the data.
const id =
selection && data.indis.some((i) => i.id === selection.id)
? selection.id
: data.indis[0].id;
return {id, generation: selection?.generation || 0};
}
function prepareData(
gedcom: string,
cacheId: string,
images?: Map<string, string>,
): TopolaData {
const data = convertGedcom(gedcom, images || new Map());
if (!images || images.size === 0) {
const dataToSerialize = {...data};
delete dataToSerialize.images;
const serializedData = JSON.stringify(dataToSerialize);
try {
sessionStorage.setItem(cacheId, serializedData);
} catch (e) {
console.warn('Failed to store data in session storage: ' + e);
}
}
return data;
}
/**
* Revokes browser-created Object URLs (blob URLs) from a map or list of images
* to free up memory and prevent resource leaks.
*/
export function revokeObjectUrls(
images?: Map<string, string> | Iterable<string>,
): void {
if (!images) {
return;
}
const urls = images instanceof Map ? images.values() : images;
for (const url of urls) {
if (url.startsWith('blob:')) {
try {
URL.revokeObjectURL(url);
} catch (e) {
// Ignore.
}
}
}
}
async function loadGedzip(
blob: Blob,
): Promise<{gedcom: string; images: Map<string, string>}> {
const buffer = Buffer.from(await blob.arrayBuffer());
const unzipped: Unzipped = await new Promise((resolve, reject) => {
unzip(buffer, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
});
let gedcom = undefined;
const images = new Map<string, string>();
try {
for (const fileName of Object.keys(unzipped)) {
if (fileName.endsWith('.ged')) {
if (gedcom) {
console.warn('Multiple GEDCOM files found in zip archive.');
} else {
gedcom = strFromU8(unzipped[fileName]);
}
} else {
// Save image for later.
const normalizedKey = fileName.replace(/\\/g, '/').toLowerCase();
images.set(
normalizedKey,
URL.createObjectURL(new Blob([unzipped[fileName] as BlobPart])),
);
}
}
if (!gedcom) {
throw new Error('GEDCOM file not found in zip archive.');
}
} catch (error) {
revokeObjectUrls(images);
throw error;
}
return {gedcom, images};
}
export async function loadFile(
blob: Blob,
): Promise<{gedcom: string; images: Map<string, string>}> {
const fileHeader = await blob.slice(0, 2).text();
if (fileHeader === 'PK') {
return loadGedzip(blob);
}
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,
handleCors: boolean,
): Promise<TopolaData> {
try {
const cachedData = sessionStorage.getItem(url);
if (cachedData) {
return JSON.parse(cachedData);
}
} catch (e) {
console.warn('Failed to load data from session storage: ' + e);
}
const driveUrlMatch1 = url.match(
/https:\/\/drive\.google\.com\/file\/d\/(.*)\/.*/,
);
if (driveUrlMatch1) {
url = `https://drive.google.com/uc?id=${driveUrlMatch1[1]}&export=download`;
}
const driveUrlMatch2 = url.match(
/https:\/\/drive\.google\.com\/open\?id=([^&]*)&?.*/,
);
if (driveUrlMatch2) {
url = `https://drive.google.com/uc?id=${driveUrlMatch2[1]}&export=download`;
}
const urlToFetch = handleCors ? 'https://topolaproxy.bieda.it/' + url : url;
const response = await window.fetch(urlToFetch);
if (response.status !== 200) {
throw new Error(response.statusText);
}
return loadAndPrepareFile(await response.blob(), url);
}
/** Loads data from the given GEDCOM file contents. */
export async function loadGedcom(
hash: string,
gedcom?: string,
images?: Map<string, string>,
): Promise<TopolaData> {
try {
const cachedData = sessionStorage.getItem(hash);
if (cachedData) {
return JSON.parse(cachedData);
}
} catch (e) {
console.warn('Failed to load data from session storage: ' + e);
}
if (!gedcom) {
throw new TopolaError(
'ERROR_LOADING_UPLOADED_FILE',
'Error loading data. Please upload your file again.',
);
}
try {
return prepareData(gedcom, hash, images);
} catch (error) {
revokeObjectUrls(images);
throw error;
}
}
export interface UploadSourceSpec {
source: DataSourceEnum.UPLOADED;
gedcom?: string;
/** Hash of the GEDCOM contents. */
hash: string;
images?: Map<string, string>;
}
export interface UploadLocationState {
data: string;
images: Map<string, string>;
}
/** Files opened from the local computer. */
export class UploadedDataSource implements DataSource<UploadSourceSpec> {
// isNewData(args: Arguments, state: State): boolean {
isNewData(
newSource: SourceSelection<UploadSourceSpec>,
oldSource: SourceSelection<UploadSourceSpec>,
_data?: TopolaData,
): boolean {
return newSource.spec.hash !== oldSource.spec.hash;
}
async loadData(
source: SourceSelection<UploadSourceSpec>,
): Promise<TopolaData> {
try {
const data = await loadGedcom(
source.spec.hash,
source.spec.gedcom,
source.spec.images,
);
const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', {
event_label: software,
event_value: (source.spec.images && source.spec.images.size) || 0,
});
return data;
} catch (error) {
analyticsEvent('upload_file_error');
throw error;
}
}
}
export interface UrlSourceSpec {
source: DataSourceEnum.GEDCOM_URL;
/** URL of the data that is loaded or is being loaded. */
url: string;
handleCors: boolean;
}
/** GEDCOM file loaded by pointing to a URL. */
export class GedcomUrlDataSource implements DataSource<UrlSourceSpec> {
isNewData(
newSource: SourceSelection<UrlSourceSpec>,
oldSource: SourceSelection<UrlSourceSpec>,
_data?: TopolaData,
): boolean {
return newSource.spec.url !== oldSource.spec.url;
}
async loadData(source: SourceSelection<UrlSourceSpec>): Promise<TopolaData> {
try {
const data = await loadFromUrl(source.spec.url, source.spec.handleCors);
const software = getSoftware(data.gedcom.head);
analyticsEvent('upload_file_loaded', {event_label: software});
return data;
} catch (error) {
analyticsEvent('url_file_error');
throw error;
}
}
}