mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 09:41:47 +00:00
274 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|