mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-25 05:01:50 +00:00
Display images in details panel fromm gedzip files
This commit is contained in:
10
src/app.tsx
10
src/app.tsx
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -138,7 +138,7 @@ export function EventExtras(props: Props) {
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="extras.files"
|
||||
defaultMessage="Additonal files"
|
||||
defaultMessage="Additional files"
|
||||
/>
|
||||
}
|
||||
size="mini"
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -143,5 +143,6 @@
|
||||
"family.wife": "Съпруга",
|
||||
"family.unknown_spouse": "Неизвестен съпруг/а",
|
||||
"family.children": "Деца",
|
||||
"family.child": "Дете"
|
||||
"family.child": "Дете",
|
||||
"media.not_uploaded": "Файлът не е качен"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -143,5 +143,6 @@
|
||||
"family.wife": "Жена",
|
||||
"family.unknown_spouse": "Неизвестный супруг(а)",
|
||||
"family.children": "Дети",
|
||||
"family.child": "Ребёнок"
|
||||
"family.child": "Ребёнок",
|
||||
"media.not_uploaded": "Файл не загружен"
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user