Display images in details panel fromm gedzip files

This commit is contained in:
Przemek Więch
2026-05-17 22:09:10 +02:00
parent 708d26d561
commit a9cd34684c
20 changed files with 610 additions and 98 deletions

View File

@@ -24,6 +24,7 @@ import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
import {
GedcomUrlDataSource,
getSelection,
revokeObjectUrls,
UploadedDataSource,
UploadLocationState,
UploadSourceSpec,
@@ -379,6 +380,7 @@ export function App() {
if (state !== AppState.INITIAL) {
setState(AppState.INITIAL);
}
setData(undefined);
return;
}
@@ -462,6 +464,14 @@ export function App() {
};
}, [mcpBridge]);
// Clean up object URLs created for uploaded images/files when the dataset
// changes or the app unmounts to prevent memory leaks.
useEffect(() => {
return () => {
revokeObjectUrls(data?.images);
};
}, [data]);
useEffect(() => {
mcpBridge.setData(data || null);
}, [data, mcpBridge]);

View File

@@ -29,15 +29,41 @@ function prepareData(
images?: Map<string, string>,
): TopolaData {
const data = convertGedcom(gedcom, images || new Map());
const serializedData = JSON.stringify(data);
try {
sessionStorage.setItem(cacheId, serializedData);
} catch (e) {
console.warn('Failed to store data in session storage: ' + e);
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>}> {
@@ -54,20 +80,29 @@ async function loadGedzip(
let gedcom = undefined;
const images = new Map<string, string>();
for (const fileName of Object.keys(unzipped)) {
if (fileName.endsWith('.ged')) {
if (gedcom) {
console.warn('Multiple GEDCOM files found in zip archive.');
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 {
gedcom = strFromU8(unzipped[fileName]);
// Save image for later.
const normalizedKey = fileName.replace(/\\/g, '/').toLowerCase();
images.set(
normalizedKey,
URL.createObjectURL(new Blob([unzipped[fileName]])),
);
}
} else {
// Save image for later.
images.set(fileName, URL.createObjectURL(new Blob([unzipped[fileName]])));
}
}
if (!gedcom) {
throw new Error('GEDCOM file not found in zip archive.');
if (!gedcom) {
throw new Error('GEDCOM file not found in zip archive.');
}
} catch (error) {
revokeObjectUrls(images);
throw error;
}
return {gedcom, images};
}
@@ -117,7 +152,12 @@ export async function loadFromUrl(
}
const {gedcom, images} = await loadFile(await response.blob());
return prepareData(gedcom, url, images);
try {
return prepareData(gedcom, url, images);
} catch (error) {
revokeObjectUrls(images);
throw error;
}
}
/** Loads data from the given GEDCOM file contents. */
@@ -140,7 +180,12 @@ export async function loadGedcom(
'Error loading data. Please upload your file again.',
);
}
return prepareData(gedcom, hash, images);
try {
return prepareData(gedcom, hash, images);
} catch (error) {
revokeObjectUrls(images);
throw error;
}
}
export interface UploadSourceSpec {

View File

@@ -6,13 +6,9 @@ import {useLocation, useNavigate} from 'react-router';
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
import {loadFile} from '../datasource/load_data';
import {analyticsEvent} from '../util/analytics';
import {isImageFile} from '../util/gedcom_util';
import {MenuType} from './menu_item';
function isImageFileName(fileName: string) {
const lower = fileName.toLowerCase();
return lower.endsWith('.jpg') || lower.endsWith('.png');
}
interface Props {
menuType: MenuType;
}
@@ -42,10 +38,10 @@ export function UploadMenu(props: Props) {
// Convert uploaded images to object URLs.
filesArray
.filter(
(file) => file.name !== gedcomFile.name && isImageFileName(file.name),
)
.forEach((file) => images.set(file.name, URL.createObjectURL(file)));
.filter((file) => file.name !== gedcomFile.name && isImageFile(file.name))
.forEach((file) =>
images.set(file.name.toLowerCase(), URL.createObjectURL(file)),
);
// Hash GEDCOM contents with uploaded image file names.
const imageFileNames = Array.from(images.keys()).sort().join('|');
@@ -88,7 +84,7 @@ export function UploadMenu(props: Props) {
<input
className="hidden"
type="file"
accept=".ged,.gdz,.gedzip,.zip,image/*"
accept=".ged,.gdz,.gedzip,.zip,.webp,image/*"
id="fileInput"
multiple
onChange={handleUpload}

View File

@@ -10,6 +10,7 @@ import {
getImageFileEntry,
getNonImageFileEntry,
mapToSource,
resolveFileUrl,
} from '../../util/gedcom_util';
import {Config, Ids} from '../config/config';
import {AdditionalFiles, FileEntry} from './additional-files';
@@ -91,7 +92,11 @@ function attributeDetails(entry: GedcomEntry) {
}
}
function imageDetails(objectEntryReference: GedcomEntry, gedcom: GedcomData) {
function imageDetails(
objectEntryReference: GedcomEntry,
gedcom: GedcomData,
images?: Map<string, string>,
) {
const imageEntry = dereference(
objectEntryReference,
gedcom,
@@ -107,7 +112,7 @@ function imageDetails(objectEntryReference: GedcomEntry, gedcom: GedcomData) {
return (
<div className="person-image">
<WrappedImage
url={imageFileEntry.data}
url={resolveFileUrl(imageFileEntry.data, images)}
filename={getFileName(imageFileEntry) || ''}
/>
</div>
@@ -138,7 +143,11 @@ function sourceDetails(
);
}
function fileDetails(objectEntries: GedcomEntry[], gedcom: GedcomData) {
function fileDetails(
objectEntries: GedcomEntry[],
gedcom: GedcomData,
images?: Map<string, string>,
) {
const files: FileEntry[] = [];
objectEntries
.map((objectEntry) =>
@@ -148,7 +157,7 @@ function fileDetails(objectEntries: GedcomEntry[], gedcom: GedcomData) {
const fileEntry = getNonImageFileEntry(objectEntry);
if (fileEntry) {
files.push({
url: fileEntry.data,
url: resolveFileUrl(fileEntry.data, images),
filename: getFileName(fileEntry),
titl: objectEntry.tree.find((entry) => entry.tag === 'TITL')?.data,
});
@@ -249,12 +258,14 @@ function getSectionForEachMatchingEntry(
detailsFunction: (
entry: GedcomEntry,
gedcom: GedcomData,
images?: Map<string, string>,
) => React.ReactNode | null,
images?: Map<string, string>,
): React.ReactNode[] {
return flatMap(tags, (tag) =>
entries
.filter((entry) => entry.tag === tag)
.map((entry) => detailsFunction(entry, gedcom)),
.map((entry) => detailsFunction(entry, gedcom, images)),
)
.filter((element) => element !== null)
.map((element, index) => (
@@ -271,14 +282,16 @@ function combineAllMatchingEntriesIntoSingleSection(
detailsFunction: (
entries: GedcomEntry[],
gedcom: GedcomData,
images?: Map<string, string>,
) => React.ReactNode | null,
images?: Map<string, string>,
): React.ReactNode {
const entriesWithMatchingTag = flatMap(tags, (tag) =>
entries.filter((entry) => entry.tag === tag),
).filter((element) => element !== null);
const sectionWithDetails = entriesWithMatchingTag.length
? detailsFunction(entriesWithMatchingTag, gedcom)
? detailsFunction(entriesWithMatchingTag, gedcom, images)
: null;
if (!sectionWithDetails) {
@@ -334,6 +347,7 @@ interface Props {
gedcom: GedcomData;
indi: string;
config: Config;
images?: Map<string, string>;
}
export function Details(props: Props) {
@@ -353,9 +367,15 @@ export function Details(props: Props) {
props.gedcom,
['OBJE'],
imageDetails,
props.images,
)}
<ImmediateFamily gedcom={props.gedcom} indi={props.indi} />
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
<Events
gedcom={props.gedcom}
entries={entries}
indi={props.indi}
images={props.images}
/>
{props.config.id === Ids.SHOW ? getSectionForId(props.indi) : null}
{getSectionForEachMatchingEntry(
entries,
@@ -375,6 +395,7 @@ export function Details(props: Props) {
props.gedcom,
['OBJE'],
fileDetails,
props.images,
)}
{combineAllMatchingEntriesIntoSingleSection(
entries,

View File

@@ -138,7 +138,7 @@ export function EventExtras(props: Props) {
content={
<FormattedMessage
id="extras.files"
defaultMessage="Additonal files"
defaultMessage="Additional files"
/>
}
size="mini"

View File

@@ -14,6 +14,7 @@ import {
getNonImageFileEntry,
mapToSource,
resolveDate,
resolveFileUrl,
resolveType,
Source,
} from '../../util/gedcom_util';
@@ -26,6 +27,7 @@ interface Props {
gedcom: GedcomData;
indi: string;
entries: GedcomEntry[];
images?: Map<string, string>;
}
interface EventData {
@@ -164,42 +166,55 @@ function eventPlace(entry: GedcomEntry) {
return place?.data ? getData(place) : undefined;
}
function eventImages(entry: GedcomEntry, gedcom: GedcomData): Image[] {
return entry.tree
function eventImages(
entry: GedcomEntry,
gedcom: GedcomData,
images?: Map<string, string>,
): Image[] {
const result: Image[] = [];
entry.tree
.filter((subEntry) => 'OBJE' === subEntry.tag)
.map((objectEntry) =>
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
)
.map((objectEntry) => getImageFileEntry(objectEntry))
.flatMap((imageFileEntry) =>
imageFileEntry
? [
{
url: imageFileEntry?.data || '',
filename: getFileName(imageFileEntry) || '',
},
]
: [],
);
.forEach((objectEntryReference) => {
const objectEntry = dereference(
objectEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
const fileEntry = getImageFileEntry(objectEntry);
if (fileEntry) {
result.push({
url: resolveFileUrl(fileEntry.data, images),
filename: getFileName(fileEntry) || '',
});
}
});
return result;
}
function eventFiles(entry: GedcomEntry, gedcom: GedcomData): Image[] {
return entry.tree
function eventFiles(
entry: GedcomEntry,
gedcom: GedcomData,
images?: Map<string, string>,
): FileEntry[] {
const result: FileEntry[] = [];
entry.tree
.filter((subEntry) => 'OBJE' === subEntry.tag)
.map((objectEntry) =>
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
)
.map((objectEntry) => getNonImageFileEntry(objectEntry))
.flatMap((fileEntry) =>
fileEntry
? [
{
url: fileEntry?.data || '',
filename: getFileName(fileEntry) || '',
},
]
: [],
);
.forEach((objectEntryReference) => {
const objectEntry = dereference(
objectEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
const fileEntry = getNonImageFileEntry(objectEntry);
if (fileEntry) {
result.push({
url: resolveFileUrl(fileEntry.data, images),
filename: getFileName(fileEntry),
titl: objectEntry.tree.find((entry) => entry.tag === 'TITL')?.data,
});
}
});
return result;
}
function eventSources(entry: GedcomEntry, gedcom: GedcomData): Source[] {
@@ -235,11 +250,12 @@ function toEvent(
gedcom: GedcomData,
indi: string,
intl: IntlShape,
images?: Map<string, string>,
): EventData[] {
if (entry.tag === 'FAMS') {
return toFamilyEvents(entry, gedcom, indi);
return toFamilyEvents(entry, gedcom, indi, images);
}
return toIndiEvent(entry, gedcom, indi, intl);
return toIndiEvent(entry, gedcom, indi, intl, images);
}
function toIndiEvent(
@@ -247,6 +263,7 @@ function toIndiEvent(
gedcom: GedcomData,
indi: string,
intl: IntlShape,
images?: Map<string, string>,
): EventData[] {
const date = resolveDate(entry) || null;
return [
@@ -256,8 +273,8 @@ function toIndiEvent(
type: resolveType(entry),
age: getAge(entry, indi, gedcom, intl),
place: eventPlace(entry),
images: eventImages(entry, gedcom),
files: eventFiles(entry, gedcom),
images: eventImages(entry, gedcom, images),
files: eventFiles(entry, gedcom, images),
notes: eventNotes(entry, gedcom),
sources: eventSources(entry, gedcom),
indi: indi,
@@ -269,6 +286,7 @@ function toFamilyEvents(
entry: GedcomEntry,
gedcom: GedcomData,
indi: string,
images?: Map<string, string>,
): EventData[] {
const family = dereference(entry, gedcom, (gedcom) => gedcom.fams);
return flatMap(FAMILY_EVENT_TAGS, (tag) =>
@@ -281,8 +299,8 @@ function toFamilyEvents(
type: resolveType(familyEvent),
personLink: getSpouse(indi, family, gedcom),
place: eventPlace(familyEvent),
images: eventImages(familyEvent, gedcom),
files: eventFiles(familyEvent, gedcom),
images: eventImages(familyEvent, gedcom, images),
files: eventFiles(familyEvent, gedcom, images),
notes: eventNotes(familyEvent, gedcom),
sources: eventSources(familyEvent, gedcom),
indi: indi,
@@ -320,7 +338,9 @@ export function Events(props: Props) {
const events = flatMap(SORTED_EVENT_TYPE_GROUPS, (eventTypeGroup) =>
props.entries
.filter((entry) => eventTypeGroup.includes(entry.tag))
.map((eventEntry) => toEvent(eventEntry, props.gedcom, props.indi, intl))
.map((eventEntry) =>
toEvent(eventEntry, props.gedcom, props.indi, intl, props.images),
)
.flatMap((events) => events)
.sort((event1, event2) => compareDates(event1.date, event2.date)),
);

View File

@@ -1,6 +1,7 @@
import {SyntheticEvent, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {
Card,
Container,
Icon,
Image,
@@ -9,6 +10,7 @@ import {
Modal,
Placeholder,
} from 'semantic-ui-react';
import {isBrowserLoadable} from '../../util/gedcom_util';
interface Props {
url: string;
@@ -22,6 +24,33 @@ export function WrappedImage(props: Props) {
const [imageFailed, setImageFailed] = useState(false);
const [imageSrc, setImageSrc] = useState('');
if (!isBrowserLoadable(props.url)) {
return (
<Card
centered
style={{width: '100%', maxWidth: '290px', margin: '0 auto 14px'}}
>
<Card.Content textAlign="center" style={{backgroundColor: '#f9f9f9'}}>
<Icon
name="image"
size="huge"
color="grey"
style={{marginBottom: '10px', opacity: 0.6}}
/>
<Card.Header style={{fontSize: '14px', wordBreak: 'break-all'}}>
{props.title || props.filename}
</Card.Header>
<Card.Meta style={{marginTop: '5px', fontStyle: 'italic'}}>
<FormattedMessage
id="media.not_uploaded"
defaultMessage="File not uploaded"
/>
</Card.Meta>
</Card.Content>
</Card>
);
}
if (imageLoaded && imageSrc !== props.url) {
setImageLoaded(false);
}

View File

@@ -31,7 +31,12 @@ export function SidePanel({
defaultMessage: 'Info',
}),
render: () => (
<Details gedcom={data.gedcom} indi={selectedIndiId} config={config} />
<Details
gedcom={data.gedcom}
indi={selectedIndiId}
config={config}
images={data.images}
/>
),
},
{

View File

@@ -143,5 +143,6 @@
"family.wife": "Съпруга",
"family.unknown_spouse": "Неизвестен съпруг/а",
"family.children": "Деца",
"family.child": "Дете"
"family.child": "Дете",
"media.not_uploaded": "Файлът не е качен"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Manželka",
"family.unknown_spouse": "Neznámý manžel/ka",
"family.children": "Děti",
"family.child": "Dítě"
"family.child": "Dítě",
"media.not_uploaded": "Soubor nebyl nahrán"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Ehefrau",
"family.unknown_spouse": "Unbekannter Ehepartner",
"family.children": "Kinder",
"family.child": "Kind"
"family.child": "Kind",
"media.not_uploaded": "Datei nicht hochgeladen"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Femme",
"family.unknown_spouse": "Conjoint inconnu",
"family.children": "Enfants",
"family.child": "Enfant"
"family.child": "Enfant",
"media.not_uploaded": "Fichier non téléversé"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Moglie",
"family.unknown_spouse": "Coniuge sconosciuto",
"family.children": "Figli",
"family.child": "Figlio"
"family.child": "Figlio",
"media.not_uploaded": "File non caricato"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Żona",
"family.unknown_spouse": "Nieznany współmałżonek",
"family.children": "Dzieci",
"family.child": "Dziecko"
"family.child": "Dziecko",
"media.not_uploaded": "Plik nieprzesłany"
}

View File

@@ -143,5 +143,6 @@
"family.wife": "Жена",
"family.unknown_spouse": "Неизвестный супруг(а)",
"family.children": "Дети",
"family.child": "Ребёнок"
"family.child": "Ребёнок",
"media.not_uploaded": "Файл не загружен"
}

View File

@@ -4,10 +4,16 @@ import {
findRelationshipPath,
getAncestors,
getDescendants,
getFileName,
getImageFileEntry,
getName,
getNonImageFileEntry,
idToFamMap,
idToIndiMap,
isBrowserLoadable,
isImageFile,
normalizeGedcom,
resolveFileUrl,
} from './gedcom_util';
describe('normalizeGedcom()', () => {
@@ -188,3 +194,122 @@ describe('Relationship algorithms', () => {
expect(descendants).toContain('I3');
});
});
describe('Media Resolution and Utilities', () => {
describe('isImageFile()', () => {
it('returns true for common images', () => {
expect(isImageFile('test.jpg')).toBe(true);
expect(isImageFile('test.png')).toBe(true);
expect(isImageFile('test.gif')).toBe(true);
expect(isImageFile('test.webp')).toBe(true);
});
it('returns false for non-images', () => {
expect(isImageFile('test.pdf')).toBe(false);
expect(isImageFile('test.txt')).toBe(false);
});
it('ignores query parameters and hashes', () => {
expect(isImageFile('test.jpg?version=123')).toBe(true);
expect(isImageFile('test.png#anchor')).toBe(true);
expect(isImageFile('test.webp?a=1&b=2#h')).toBe(true);
expect(isImageFile('test.pdf?img=test.jpg')).toBe(false);
});
});
describe('isBrowserLoadable()', () => {
it('returns true for browser loadable protocols', () => {
expect(isBrowserLoadable('http://example.com/a.jpg')).toBe(true);
expect(isBrowserLoadable('https://example.com/a.jpg')).toBe(true);
expect(isBrowserLoadable('blob:http://localhost:3000/uuid')).toBe(true);
expect(isBrowserLoadable('data:image/png;base64,abc')).toBe(true);
expect(isBrowserLoadable('//example.com/a.jpg')).toBe(true);
});
it('returns false for relative local paths', () => {
expect(isBrowserLoadable('photos/a.jpg')).toBe(false);
expect(isBrowserLoadable('C:\\Users\\a.jpg')).toBe(false);
});
});
describe('getFileName()', () => {
it('prefers TITL and FORM if present', () => {
const entry = {
level: 2,
pointer: '',
tag: 'FILE',
data: 'photos/ignored.jpg',
tree: [
{level: 3, pointer: '', tag: 'TITL', data: 'myphoto', tree: []},
{level: 3, pointer: '', tag: 'FORM', data: 'png', tree: []},
],
};
expect(getFileName(entry)).toBe('myphoto.png');
});
it('falls back to data path name if TITL/FORM is missing', () => {
const entry = {
level: 2,
pointer: '',
tag: 'FILE',
data: 'photos/realname.jpg?width=100',
tree: [],
};
expect(getFileName(entry)).toBe('realname.jpg');
});
});
describe('resolveFileUrl()', () => {
it('passes through browser loadable URLs', () => {
expect(resolveFileUrl('https://example.com/img.jpg')).toBe(
'https://example.com/img.jpg',
);
expect(resolveFileUrl('blob:uuid')).toBe('blob:uuid');
});
it('matches path case-insensitively from images map', () => {
const images = new Map([['photos/img.jpg', 'blob:resolved']]);
expect(resolveFileUrl('photos/img.jpg', images)).toBe('blob:resolved');
expect(resolveFileUrl('PHOTOS\\IMG.JPG', images)).toBe('blob:resolved');
});
it('does not match base filename from images map', () => {
const images = new Map([['img.jpg', 'blob:resolved-base']]);
expect(resolveFileUrl('photos/IMG.JPG', images)).toBe('photos/IMG.JPG');
});
it('falls back to normalized path if no match found', () => {
expect(resolveFileUrl('photos\\img.jpg')).toBe('photos/img.jpg');
});
});
describe('findFileEntries() via getters', () => {
const objectEntry = {
level: 1,
pointer: '@O1@',
tag: 'OBJE',
data: '',
tree: [
{level: 2, pointer: '', tag: 'FILE', data: 'photos/a.jpg', tree: []},
{level: 2, pointer: '', tag: 'FILE', data: 'documents/b.pdf', tree: []},
{
level: 2,
pointer: '',
tag: 'FILE',
data: 'https://example.com/c.png',
tree: [],
},
],
};
it('extracts images including relative and web URLs', () => {
const image = getImageFileEntry(objectEntry);
expect(image?.data).toBe('photos/a.jpg');
});
it('extracts non-images', () => {
const nonImage = getNonImageFileEntry(objectEntry);
expect(nonImage?.data).toBe('documents/b.pdf');
});
});
});

View File

@@ -25,6 +25,7 @@ export interface GedcomData {
export interface TopolaData {
chartData: JsonGedcomData;
gedcom: GedcomData;
images?: Map<string, string>;
}
export interface Source {
@@ -204,11 +205,12 @@ export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
return sortSpouses(sortChildren(gedcom));
}
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'];
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/** Returns true if the given file name has a known image extension. */
export function isImageFile(fileName: string): boolean {
const lowerName = fileName.toLowerCase();
const cleanName = fileName.split(/[?#]/)[0];
const lowerName = cleanName.toLowerCase();
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
}
@@ -216,22 +218,41 @@ export function isImageFile(fileName: string): boolean {
* Removes images that are not HTTP links or do not have known image extensions.
* Does not modify the input object.
*/
export function isBrowserLoadable(url: string): boolean {
return /^(https?:|blob:|data:|\/\/)/i.test(url);
}
export function resolveFileUrl(
url: string,
images?: Map<string, string>,
): string {
if (isBrowserLoadable(url)) {
return url;
}
const normalizedUrl = url.replace(/\\/g, '/');
if (images instanceof Map) {
const lowercasePath = normalizedUrl.toLowerCase();
const mappedUrl = images.get(lowercasePath);
if (mappedUrl) {
return mappedUrl;
}
}
return normalizedUrl;
}
function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
if (!indi.images || indi.images.length === 0) {
return indi;
}
const newImages: JsonImage[] = [];
indi.images.forEach((image) => {
const filePath = image.url.replaceAll('\\', '/');
const fileName = filePath.split('/').pop() || '';
const fileUrl = images.get(filePath);
const nameUrl = images.get(fileName);
if (fileUrl) {
newImages.push({url: fileUrl, title: image.title});
} else if (nameUrl) {
newImages.push({url: nameUrl, title: image.title});
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
newImages.push(image);
const resolvedUrl = resolveFileUrl(image.url, images);
const normalizedUrl = image.url.replace(/\\/g, '/');
if (
resolvedUrl !== normalizedUrl ||
(isBrowserLoadable(resolvedUrl) && isImageFile(resolvedUrl))
) {
newImages.push({url: resolvedUrl, title: image.title});
}
});
return Object.assign({}, indi, {images: newImages});
@@ -276,6 +297,7 @@ export function convertGedcom(
return {
chartData: filterImages(normalizeGedcom(json), images),
gedcom: prepareGedcom(entries),
images,
};
}
@@ -309,7 +331,17 @@ export function getFileName(fileEntry: GedcomEntry): string | undefined {
(entry) => entry.tag === 'FORM',
)?.data;
return fileTitle && fileExtension && fileTitle + '.' + fileExtension;
if (fileTitle && fileExtension) {
return fileTitle + '.' + fileExtension;
}
if (fileEntry && fileEntry.data) {
const path = fileEntry.data.replace(/\\/g, '/');
const cleanPath = path.split(/[?#]/)[0];
return cleanPath.split('/').pop();
}
return undefined;
}
function findFileEntry(
@@ -317,8 +349,7 @@ function findFileEntry(
predicate: (entry: GedcomEntry) => boolean,
): GedcomEntry | undefined {
return objectEntry.tree.find(
(entry) =>
entry.tag === 'FILE' && entry.data.startsWith('http') && predicate(entry),
(entry) => entry.tag === 'FILE' && entry.data && predicate(entry),
);
}