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

@@ -0,0 +1,187 @@
# Displaying Uploaded Images and Documents in the Details Side Panel
## Problem
When users upload genealogy data using GEDCOM ZIP (GDZ) files or by selecting local images alongside a GEDCOM file, the images display correctly on the family tree chart nodes. However, these same photos, as well as any other uploaded document attachments, fail to render in the details side panel and events list. This occurs because the side panel reads directly from the raw GEDCOM entries, which only contain relative local file paths that the browser cannot resolve. The goal of this feature is to pass the mapped local object URLs to the details panel and resolve these paths dynamically, ensuring a consistent and complete viewing experience for all uploaded media.
## The Technical Plan
To make uploaded images and files visible in the side panel, we will connect the loaded browser URL mapping to the components that display individual details and event lists.
The plan involves four major areas:
1. **Data Model Augmentation**: We will store the temporary file mapping (`images` map) in the main `TopolaData` container so it is available alongside the parsed GEDCOM data.
2. **Propagating the Mapping**: The main `App` component holds the loaded `TopolaData`. We will pass the `images` map as a prop to the `SidePanel` component, which will in turn pass it down to `Details` and `Events`.
3. **Resolving Relative Paths**: We will introduce a utility function that looks up relative paths or filenames in the `images` map. If a relative path is referenced in the GEDCOM, we will replace it with the resolved browser URL.
4. **Permissive File Extraction**: We will modify the parser utility to stop filtering out non-web paths (paths that do not start with `http`), allowing the application to process relative file paths.
## Alternatives Considered
### 1. Pre-resolving URLs Directly in the Raw GEDCOM Data Model
We considered modifying the parsed `GedcomData` object to replace relative file paths with `blob:` URLs at load time, similar to how we pre-process `chartData`.
* **Why Rejected**: `GedcomData` is designed to be a raw, immutable representation of the source GEDCOM file structure. Directly mutating this tree could break features that expect raw tag data (such as exports, raw view logs, or metadata parsing). Keeping the `images` resolver map separate maintains a clean separation of concerns and preserves the integrity of the original GEDCOM entries.
### 2. Base64-Encoding and Persisting Images in Session Storage
We considered serializing the local file contents as Base64 strings so they could be saved in `sessionStorage` and survive page reloads.
* **Why Rejected**: Browser `sessionStorage` is typically limited to 5MB. Genealogy ZIP archives can easily contain tens or hundreds of megabytes of media files, which would instantly exceed this quota and cause the application to crash. The performance cost of encoding and decoding large files in session storage is also prohibitive.
### 3. Rendering Placeholders for Unresolved Local Media References
We considered rendering warning cards or broken image placeholders for relative paths that cannot be resolved (i.e., when a GEDCOM lists a photo that wasn't included in the uploaded files/ZIP).
* **Why Rejected**: GEDCOM files created by desktop software often contain references to hundreds of local photos stored on the user's computer. Since users typically upload only the GEDCOM file (or a zip containing a subset of files), displaying error panels or placeholders for every missing image would clutter the UI. Returning `null` to gracefully hide missing media keeps the side panel clean and focused on available information.
### 4. Storing both full relative path and base filename in the `images` map
We considered storing files under both their full path (e.g. `photos/person.jpg`) and their base filename (e.g. `person.jpg`) inside the `images` map to handle cases where the GEDCOM file does not preserve the folder name.
* **Why Rejected**: We assume the GEDCOM file references files preserving the folder name as exported by the source application. Storing both keys could also lead to collisions if two different folders contain files with the same name.
## Detailed Implementation
This section lists every file that will be created or changed, the step-by-step changes, and the technical rationale.
### 1. [gedcom_util.ts](../src/util/gedcom_util.ts)
#### Proposed Changes:
* Update `TopolaData` interface to add `images?: Map<string, string>;`.
* In `convertGedcom`, return the input `images` map in the output object.
* Remove `entry.data.startsWith('http')` check inside `findFileEntry` and ensure `entry.data` is a non-empty string before running predicate checks to avoid crashes.
* Add `.webp` to the `IMAGE_EXTENSIONS` list to support modern image formats.
* Update `isImageFile` to strip query parameters (`?`) and hashes (`#`) from the path/URL before verifying the extension.
* Centralize filename extraction inside `getFileName`: if `TITL` or `FORM` is missing, extract the filename from `fileEntry.data` (normalizing backslashes to forward slashes and stripping query parameters or hashes first).
* Implement and export `isBrowserLoadable(url: string): boolean` which returns `true` if the URL starts with `http://`, `https://`, `blob:`, `data:`, or `//`.
* Implement and export `resolveFileUrl(url: string, images?: Map<string, string>): string`. This function:
1. Returns the input URL immediately if it is browser-loadable (checked via `isBrowserLoadable`).
2. Replaces all backslashes (`\`) with forward slashes (`/`).
3. Checks the `images` map for the normalized lowercase path first, then for the lowercase base filename.
4. Returns the mapped `blob:` URL if found; otherwise, returns the original normalized URL.
* *Robustness check*: The function will safely check if `images` is a valid `Map` (using `images instanceof Map`) to avoid runtime errors when data is loaded from session storage.
* Refactor `getImageFileEntry` and `getNonImageFileEntry` to `getImageFileEntries` and `getNonImageFileEntries` to return all matching file entries within an `OBJE` tag, supporting multimedia objects with multiple file attachments.
* Update `filterImage` inside `gedcom_util.ts` to resolve the URL using the new `resolveFileUrl` helper, ensuring case-insensitive lookups for chart node images.
#### Rationale:
Extending the model interface `TopolaData` binds the lifetime of the `images` map to the active loaded dataset. Removing `startsWith('http')` allows extracting relative paths. Centralizing the loadability check, URL decoding, case normalization, and base filename extraction helpers avoids code duplication. Supporting multiple file entries per `OBJE` record aligns with the GEDCOM multimedia specifications.
### 2. [load_data.ts](../src/datasource/load_data.ts)
#### Proposed Changes:
* In `prepareData`, pass the `images` map parameter to `convertGedcom` and return it inside the resulting data object.
* Ensure that the `images` map is *not* stored in `sessionStorage` by explicitly deleting or omitting the `images` key before calling `JSON.stringify(data)` in `prepareData`. This guarantees that `data.images` will be `undefined` (and not a plain empty object `{}`) when restored from `sessionStorage`.
* In `loadGedzip`, store the unzipped files in the `images` map using lowercase keys and normalizing backslashes to forward slashes to ensure case-insensitive lookup.
* Wrap the unzipping/extraction loop in `loadGedzip` in a `try-catch` block. If extraction fails or no GEDCOM file is found, revoke all `blob:` URLs created so far in the loop.
* Implement `try-catch` cleanup blocks in both `loadGedcom` and `loadFromUrl` to ensure that if file loading or parsing throws an error, all `blob:` URLs in the `images` map are immediately revoked to prevent memory leaks.
#### Rationale:
Connects the ingestion pipeline to the `TopolaData` model. Deleting `images` prior to `sessionStorage` serialization prevents restoring a broken plain object `{}`. Adding try-catch blocks to cleanup `images` on loading or parsing failures prevents memory leaks from unrevoked `blob:` URLs.
### 3. [side-panel.tsx](../src/sidepanel/side-panel.tsx)
#### Proposed Changes:
* Locate `<Details>` rendering block inside the `tabs` definition.
* Pass the map to the details panel: `images={data.images}`.
#### Rationale:
Acts as the intermediary component that forwards data properties from the root application state to the details view pane.
### 4. [details.tsx](../src/sidepanel/details/details.tsx)
#### Proposed Changes:
* Update the `Props` interface for `Details` to accept `images?: Map<string, string>;`.
* Pass `props.images` to `imageDetails` and `fileDetails` (using inline arrow functions inside `getSectionForEachMatchingEntry` and `combineAllMatchingEntriesIntoSingleSection`), and `<Events ... images={props.images} />`.
* In `imageDetails`, retrieve all image file entries using `getImageFileEntries`. For each, resolve the URL using `resolveFileUrl(..., images)` and render `WrappedImage` with the resolved URL and its filename.
* In `fileDetails`, retrieve all non-image file entries using `getNonImageFileEntries`. For each, resolve the URL using `resolveFileUrl(..., images)` and pass the resolved URLs to `AdditionalFiles`.
#### Rationale:
Directly resolves image and non-image document URLs from the loaded files list. Iterating over all resolved files from `getImageFileEntries` and `getNonImageFileEntries` supports multiple attachments per object record.
### 5. [events.tsx](../src/sidepanel/details/events.tsx)
#### Proposed Changes:
* Update the `Props` interface for `Events` to accept `images?: Map<string, string>;`.
* Pass `images` to `toEvent` -> `toIndiEvent` & `toFamilyEvents` -> `eventImages` & `eventFiles`.
* In `eventImages(entry, gedcom, images)`, extract all image files using `getImageFileEntries`. For each, resolve the URL using `resolveFileUrl` and add to the returned array.
* In `eventFiles(entry, gedcom, images)`, extract all non-image files using `getNonImageFileEntries`. For each, resolve the URL using `resolveFileUrl` and add to the returned array.
#### Rationale:
Allows life events to load, resolve, and render all associated media files, supporting multiple files and case-insensitive resolution.
### 6. [additional-files.tsx](../src/sidepanel/details/additional-files.tsx)
#### Proposed Changes:
* Map file extensions to Semantic UI icons (e.g. `file pdf outline` for PDF, `file image outline` for images, etc.) to improve the design aesthetics.
* Add the `download` attribute to the `<a>` tag for `blob:` URLs to trigger a file download instead of triggering browser security blocks on top-level navigations:
```typescript
download={file.url.startsWith('blob:') ? file.filename || true : undefined}
```
* Update `AdditionalFiles` component to check if the file is browser-loadable using `isBrowserLoadable(file.url)`. For non-loadable URLs:
- Do not render the `<a>` link (thus preventing download/navigation actions).
- Render the filename as grayed-out plain text with an italicized `(File not uploaded)` helper message.
#### Rationale:
Implements premium file icon layouts based on file types. Adding the `download` attribute is critical to bypass browser-level blocks on opening `blob:` URLs in new tabs. Checking loadability prevents users from navigating to broken local paths.
### 7. [wrapped-image.tsx](../src/sidepanel/details/wrapped-image.tsx)
#### Proposed Changes:
* Update `WrappedImage` to check if `props.url` is browser-loadable using `isBrowserLoadable`. If not, immediately render a clean gray card placeholder (displaying the image icon, the title or filename, and a "File not uploaded" badge) without rendering the `<img>` tag or trying to load the image.
#### Rationale:
Prevents browser 404/CORS console errors when attempting to load raw local relative paths as images, presenting a consistent and clean "File not uploaded" message to the user instead.
### 8. [app.tsx](../src/app.tsx)
#### Proposed Changes:
* Add a `useEffect` cleanup hook in the `App` component that monitors changes to the active `data` state. When `data` changes, it iterates over the old `data.images` map and calls `URL.revokeObjectURL(url)` for all `blob:` URLs.
* In the path routing `useEffect` of the `App` component, if the path is not `/view`, explicitly reset the `data` state to `undefined`. This triggers the cleanup hook to revoke all active `blob:` URLs when leaving the chart viewer (e.g. going back to the landing page).
#### Rationale:
Prevents browser memory leaks from unrevoked object URLs when users upload and switch between different genealogy files, or navigate away from the viewer.
### 9. [upload_menu.tsx](../src/menu/upload_menu.tsx)
#### Proposed Changes:
* Update the file input `accept` attribute to allow selecting `.webp` images.
* Remove the local `isImageFileName` helper function and import `isImageFile` from `gedcom_util.ts` to ensure consistent file extension support.
* Convert individually uploaded filenames to lowercase when storing them as keys in the `images` map to ensure case-insensitive matching:
```typescript
images.set(file.name.toLowerCase(), URL.createObjectURL(file));
```
#### Rationale:
Extends modern image format support and unifies the image extension logic across the codebase. Storing keys as lowercase enables case-insensitive lookups for individually uploaded files.
### 10. [Translation JSON Files](../src/translations/)
#### Proposed Changes:
* Add the localization key `"media.not_uploaded"` to all translation files (`bg.json`, `cs.json`, `de.json`, `fr.json`, `it.json`, `pl.json`, `ru.json`).
#### Rationale:
Ensures the new "File not uploaded" text badge is localized in all supported languages of the application.
## Verification Plan
We will verify the changes using automated unit tests, end-to-end regression checks, and manual visual testing.
### Automated Unit Tests
We will run and add unit tests to ensure correct data resolution:
1. **Existing Test Verification**: Run `npm test` to verify all current unit tests pass.
2. **`resolveFileUrl` Unit Tests**: Add tests in `src/util/gedcom_util.spec.ts` to verify:
- Resolving a relative path that matches a key in the `images` map returns the correct `blob:` URL.
- Resolving a filename that matches a key in the `images` map returns the correct `blob:` URL.
- Resolving a URL that doesn't exist in the `images` map returns the original URL unchanged.
3. **`findFileEntry` Permissive Extract**: Add tests in `src/util/gedcom_util.spec.ts` to verify that `findFileEntry` matches and returns local relative `FILE` entries (e.g., `images/photo.jpg`), which were previously filtered out.
4. **Session cache recovery verification**: Add a test that verifies reloading the page correctly loads data from `sessionStorage` when `data.images` is `undefined`, ensuring no crashes occur.
### Playwright Visual & E2E Tests
We will add automated verification of the upload flow and visual rendering:
1. **Add visual test**: Create a new test case in `tests/details_visual.spec.ts`:
- Start at `/`.
- Setup Playwright's `filechooser` listener.
- Trigger the file upload menu, select and upload the `src/datasource/testdata/test.gdz` file.
- Click the node for the individual that contains the photo (e.g., Radobod).
- Wait for the side panel to open and the image to load.
- Match sidebar screenshot against a visual regression baseline (`details-uploaded-gdz-photo.png`).
2. **Add missing file placeholder test**: Verify that if a linked attachment is missing, a "File not uploaded" label or placeholder card renders in the details pane, matching a baseline screenshot.
3. Run `npm run test:visual` to confirm tests execute and pass.
### Manual / Visual Verification
We will manually verify the visual rendering on a local development server:
1. Run `npm run dev` to launch the local application.
2. Prepare a test GDZ file: Zip the contents of the `docker/examples/photos` directory (which contains `family.ged` referencing relative photo paths under a `photos` subfolder).
3. Upload the resulting GDZ file to the application.
4. Verify that:
- The primary individual's photo displays correctly in the side panel Details tab.
- Any documents or images linked to events (such as birth or marriage events) are rendered correctly in the event info cards.
- GEDCOM files referencing missing images render "File not uploaded" placeholders gracefully instead of breaking or showing broken image icons.
## Future Considerations
### IndexedDB for Upload Persistence
Currently, we reject persisting images in `sessionStorage` due to the 5MB size limit. In the future, we could consider using **IndexedDB** to store the zipped `.gdz` archives or the extracted file blobs. Since IndexedDB supports much larger storage quotas (typically hundreds of megabytes or more), this would allow uploaded genealogy trees and their media attachments to persist across page reloads and browser restarts, providing a much smoother user experience.

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),
);
}

View File

@@ -201,4 +201,40 @@ test.describe('Details panel visual validation @visual', () => {
await sidebar.waitFor();
await expect(sidebar).toHaveScreenshot('details-immediate-family.png');
});
test('Media Fallback Rendering Test', async ({page, context}) => {
const fallbackGedcom = dedent`
0 HEAD
1 GEDC
2 VERS 5.5.1
2 FORM Lineage-Linked
1 CHAR UTF-8
0 @I1@ INDI
1 NAME Bonifacy /Gibbs/
1 SEX M
1 FAMS @F1@
1 OBJE @O1@
0 @O1@ OBJE
1 FILE photos/not_uploaded_image.jpg
2 FORM jpeg
2 TITL A Not Uploaded Image
0 @F1@ FAM
1 HUSB @I1@
0 TRLR
`;
await mockGedcomResponse(context, fallbackGedcom);
await page.goto('/#/view?url=https://example.org/family.ged');
const sidebar = page.locator('#sidebar');
await sidebar.waitFor();
// Verify fallback placeholder text is present
await expect(sidebar.getByText('File not uploaded').first()).toBeVisible();
await expect(
sidebar.getByText('A Not Uploaded Image').first(),
).toBeVisible();
await expect(sidebar).toHaveScreenshot('details-media-fallback.png');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB