mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-05-26 15:16:14 +00:00
Display images in details panel fromm gedzip files
This commit is contained in:
187
docs/UPLOADED_IMAGES_IN_DETAILS.md
Normal file
187
docs/UPLOADED_IMAGES_IN_DETAILS.md
Normal 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.
|
||||||
|
|
||||||
10
src/app.tsx
10
src/app.tsx
@@ -24,6 +24,7 @@ import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
|
|||||||
import {
|
import {
|
||||||
GedcomUrlDataSource,
|
GedcomUrlDataSource,
|
||||||
getSelection,
|
getSelection,
|
||||||
|
revokeObjectUrls,
|
||||||
UploadedDataSource,
|
UploadedDataSource,
|
||||||
UploadLocationState,
|
UploadLocationState,
|
||||||
UploadSourceSpec,
|
UploadSourceSpec,
|
||||||
@@ -379,6 +380,7 @@ export function App() {
|
|||||||
if (state !== AppState.INITIAL) {
|
if (state !== AppState.INITIAL) {
|
||||||
setState(AppState.INITIAL);
|
setState(AppState.INITIAL);
|
||||||
}
|
}
|
||||||
|
setData(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +464,14 @@ export function App() {
|
|||||||
};
|
};
|
||||||
}, [mcpBridge]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
mcpBridge.setData(data || null);
|
mcpBridge.setData(data || null);
|
||||||
}, [data, mcpBridge]);
|
}, [data, mcpBridge]);
|
||||||
|
|||||||
@@ -29,15 +29,41 @@ function prepareData(
|
|||||||
images?: Map<string, string>,
|
images?: Map<string, string>,
|
||||||
): TopolaData {
|
): TopolaData {
|
||||||
const data = convertGedcom(gedcom, images || new Map());
|
const data = convertGedcom(gedcom, images || new Map());
|
||||||
const serializedData = JSON.stringify(data);
|
if (!images || images.size === 0) {
|
||||||
try {
|
const dataToSerialize = {...data};
|
||||||
sessionStorage.setItem(cacheId, serializedData);
|
delete dataToSerialize.images;
|
||||||
} catch (e) {
|
const serializedData = JSON.stringify(dataToSerialize);
|
||||||
console.warn('Failed to store data in session storage: ' + e);
|
try {
|
||||||
|
sessionStorage.setItem(cacheId, serializedData);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to store data in session storage: ' + e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return data;
|
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(
|
async function loadGedzip(
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
): Promise<{gedcom: string; images: Map<string, string>}> {
|
): Promise<{gedcom: string; images: Map<string, string>}> {
|
||||||
@@ -54,20 +80,29 @@ async function loadGedzip(
|
|||||||
|
|
||||||
let gedcom = undefined;
|
let gedcom = undefined;
|
||||||
const images = new Map<string, string>();
|
const images = new Map<string, string>();
|
||||||
for (const fileName of Object.keys(unzipped)) {
|
try {
|
||||||
if (fileName.endsWith('.ged')) {
|
for (const fileName of Object.keys(unzipped)) {
|
||||||
if (gedcom) {
|
if (fileName.endsWith('.ged')) {
|
||||||
console.warn('Multiple GEDCOM files found in zip archive.');
|
if (gedcom) {
|
||||||
|
console.warn('Multiple GEDCOM files found in zip archive.');
|
||||||
|
} else {
|
||||||
|
gedcom = strFromU8(unzipped[fileName]);
|
||||||
|
}
|
||||||
} else {
|
} 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) {
|
||||||
if (!gedcom) {
|
throw new Error('GEDCOM file not found in zip archive.');
|
||||||
throw new Error('GEDCOM file not found in zip archive.');
|
}
|
||||||
|
} catch (error) {
|
||||||
|
revokeObjectUrls(images);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return {gedcom, images};
|
return {gedcom, images};
|
||||||
}
|
}
|
||||||
@@ -117,7 +152,12 @@ export async function loadFromUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {gedcom, images} = await loadFile(await response.blob());
|
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. */
|
/** Loads data from the given GEDCOM file contents. */
|
||||||
@@ -140,7 +180,12 @@ export async function loadGedcom(
|
|||||||
'Error loading data. Please upload your file again.',
|
'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 {
|
export interface UploadSourceSpec {
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import {useLocation, useNavigate} from 'react-router';
|
|||||||
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
|
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
|
||||||
import {loadFile} from '../datasource/load_data';
|
import {loadFile} from '../datasource/load_data';
|
||||||
import {analyticsEvent} from '../util/analytics';
|
import {analyticsEvent} from '../util/analytics';
|
||||||
|
import {isImageFile} from '../util/gedcom_util';
|
||||||
import {MenuType} from './menu_item';
|
import {MenuType} from './menu_item';
|
||||||
|
|
||||||
function isImageFileName(fileName: string) {
|
|
||||||
const lower = fileName.toLowerCase();
|
|
||||||
return lower.endsWith('.jpg') || lower.endsWith('.png');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuType: MenuType;
|
menuType: MenuType;
|
||||||
}
|
}
|
||||||
@@ -42,10 +38,10 @@ export function UploadMenu(props: Props) {
|
|||||||
|
|
||||||
// Convert uploaded images to object URLs.
|
// Convert uploaded images to object URLs.
|
||||||
filesArray
|
filesArray
|
||||||
.filter(
|
.filter((file) => file.name !== gedcomFile.name && isImageFile(file.name))
|
||||||
(file) => file.name !== gedcomFile.name && isImageFileName(file.name),
|
.forEach((file) =>
|
||||||
)
|
images.set(file.name.toLowerCase(), URL.createObjectURL(file)),
|
||||||
.forEach((file) => images.set(file.name, URL.createObjectURL(file)));
|
);
|
||||||
|
|
||||||
// Hash GEDCOM contents with uploaded image file names.
|
// Hash GEDCOM contents with uploaded image file names.
|
||||||
const imageFileNames = Array.from(images.keys()).sort().join('|');
|
const imageFileNames = Array.from(images.keys()).sort().join('|');
|
||||||
@@ -88,7 +84,7 @@ export function UploadMenu(props: Props) {
|
|||||||
<input
|
<input
|
||||||
className="hidden"
|
className="hidden"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".ged,.gdz,.gedzip,.zip,image/*"
|
accept=".ged,.gdz,.gedzip,.zip,.webp,image/*"
|
||||||
id="fileInput"
|
id="fileInput"
|
||||||
multiple
|
multiple
|
||||||
onChange={handleUpload}
|
onChange={handleUpload}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getImageFileEntry,
|
getImageFileEntry,
|
||||||
getNonImageFileEntry,
|
getNonImageFileEntry,
|
||||||
mapToSource,
|
mapToSource,
|
||||||
|
resolveFileUrl,
|
||||||
} from '../../util/gedcom_util';
|
} from '../../util/gedcom_util';
|
||||||
import {Config, Ids} from '../config/config';
|
import {Config, Ids} from '../config/config';
|
||||||
import {AdditionalFiles, FileEntry} from './additional-files';
|
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(
|
const imageEntry = dereference(
|
||||||
objectEntryReference,
|
objectEntryReference,
|
||||||
gedcom,
|
gedcom,
|
||||||
@@ -107,7 +112,7 @@ function imageDetails(objectEntryReference: GedcomEntry, gedcom: GedcomData) {
|
|||||||
return (
|
return (
|
||||||
<div className="person-image">
|
<div className="person-image">
|
||||||
<WrappedImage
|
<WrappedImage
|
||||||
url={imageFileEntry.data}
|
url={resolveFileUrl(imageFileEntry.data, images)}
|
||||||
filename={getFileName(imageFileEntry) || ''}
|
filename={getFileName(imageFileEntry) || ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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[] = [];
|
const files: FileEntry[] = [];
|
||||||
objectEntries
|
objectEntries
|
||||||
.map((objectEntry) =>
|
.map((objectEntry) =>
|
||||||
@@ -148,7 +157,7 @@ function fileDetails(objectEntries: GedcomEntry[], gedcom: GedcomData) {
|
|||||||
const fileEntry = getNonImageFileEntry(objectEntry);
|
const fileEntry = getNonImageFileEntry(objectEntry);
|
||||||
if (fileEntry) {
|
if (fileEntry) {
|
||||||
files.push({
|
files.push({
|
||||||
url: fileEntry.data,
|
url: resolveFileUrl(fileEntry.data, images),
|
||||||
filename: getFileName(fileEntry),
|
filename: getFileName(fileEntry),
|
||||||
titl: objectEntry.tree.find((entry) => entry.tag === 'TITL')?.data,
|
titl: objectEntry.tree.find((entry) => entry.tag === 'TITL')?.data,
|
||||||
});
|
});
|
||||||
@@ -249,12 +258,14 @@ function getSectionForEachMatchingEntry(
|
|||||||
detailsFunction: (
|
detailsFunction: (
|
||||||
entry: GedcomEntry,
|
entry: GedcomEntry,
|
||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
|
images?: Map<string, string>,
|
||||||
) => React.ReactNode | null,
|
) => React.ReactNode | null,
|
||||||
|
images?: Map<string, string>,
|
||||||
): React.ReactNode[] {
|
): React.ReactNode[] {
|
||||||
return flatMap(tags, (tag) =>
|
return flatMap(tags, (tag) =>
|
||||||
entries
|
entries
|
||||||
.filter((entry) => entry.tag === tag)
|
.filter((entry) => entry.tag === tag)
|
||||||
.map((entry) => detailsFunction(entry, gedcom)),
|
.map((entry) => detailsFunction(entry, gedcom, images)),
|
||||||
)
|
)
|
||||||
.filter((element) => element !== null)
|
.filter((element) => element !== null)
|
||||||
.map((element, index) => (
|
.map((element, index) => (
|
||||||
@@ -271,14 +282,16 @@ function combineAllMatchingEntriesIntoSingleSection(
|
|||||||
detailsFunction: (
|
detailsFunction: (
|
||||||
entries: GedcomEntry[],
|
entries: GedcomEntry[],
|
||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
|
images?: Map<string, string>,
|
||||||
) => React.ReactNode | null,
|
) => React.ReactNode | null,
|
||||||
|
images?: Map<string, string>,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
const entriesWithMatchingTag = flatMap(tags, (tag) =>
|
const entriesWithMatchingTag = flatMap(tags, (tag) =>
|
||||||
entries.filter((entry) => entry.tag === tag),
|
entries.filter((entry) => entry.tag === tag),
|
||||||
).filter((element) => element !== null);
|
).filter((element) => element !== null);
|
||||||
|
|
||||||
const sectionWithDetails = entriesWithMatchingTag.length
|
const sectionWithDetails = entriesWithMatchingTag.length
|
||||||
? detailsFunction(entriesWithMatchingTag, gedcom)
|
? detailsFunction(entriesWithMatchingTag, gedcom, images)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!sectionWithDetails) {
|
if (!sectionWithDetails) {
|
||||||
@@ -334,6 +347,7 @@ interface Props {
|
|||||||
gedcom: GedcomData;
|
gedcom: GedcomData;
|
||||||
indi: string;
|
indi: string;
|
||||||
config: Config;
|
config: Config;
|
||||||
|
images?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Details(props: Props) {
|
export function Details(props: Props) {
|
||||||
@@ -353,9 +367,15 @@ export function Details(props: Props) {
|
|||||||
props.gedcom,
|
props.gedcom,
|
||||||
['OBJE'],
|
['OBJE'],
|
||||||
imageDetails,
|
imageDetails,
|
||||||
|
props.images,
|
||||||
)}
|
)}
|
||||||
<ImmediateFamily gedcom={props.gedcom} indi={props.indi} />
|
<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}
|
{props.config.id === Ids.SHOW ? getSectionForId(props.indi) : null}
|
||||||
{getSectionForEachMatchingEntry(
|
{getSectionForEachMatchingEntry(
|
||||||
entries,
|
entries,
|
||||||
@@ -375,6 +395,7 @@ export function Details(props: Props) {
|
|||||||
props.gedcom,
|
props.gedcom,
|
||||||
['OBJE'],
|
['OBJE'],
|
||||||
fileDetails,
|
fileDetails,
|
||||||
|
props.images,
|
||||||
)}
|
)}
|
||||||
{combineAllMatchingEntriesIntoSingleSection(
|
{combineAllMatchingEntriesIntoSingleSection(
|
||||||
entries,
|
entries,
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function EventExtras(props: Props) {
|
|||||||
content={
|
content={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id="extras.files"
|
id="extras.files"
|
||||||
defaultMessage="Additonal files"
|
defaultMessage="Additional files"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
size="mini"
|
size="mini"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getNonImageFileEntry,
|
getNonImageFileEntry,
|
||||||
mapToSource,
|
mapToSource,
|
||||||
resolveDate,
|
resolveDate,
|
||||||
|
resolveFileUrl,
|
||||||
resolveType,
|
resolveType,
|
||||||
Source,
|
Source,
|
||||||
} from '../../util/gedcom_util';
|
} from '../../util/gedcom_util';
|
||||||
@@ -26,6 +27,7 @@ interface Props {
|
|||||||
gedcom: GedcomData;
|
gedcom: GedcomData;
|
||||||
indi: string;
|
indi: string;
|
||||||
entries: GedcomEntry[];
|
entries: GedcomEntry[];
|
||||||
|
images?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventData {
|
interface EventData {
|
||||||
@@ -164,42 +166,55 @@ function eventPlace(entry: GedcomEntry) {
|
|||||||
return place?.data ? getData(place) : undefined;
|
return place?.data ? getData(place) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventImages(entry: GedcomEntry, gedcom: GedcomData): Image[] {
|
function eventImages(
|
||||||
return entry.tree
|
entry: GedcomEntry,
|
||||||
|
gedcom: GedcomData,
|
||||||
|
images?: Map<string, string>,
|
||||||
|
): Image[] {
|
||||||
|
const result: Image[] = [];
|
||||||
|
entry.tree
|
||||||
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
||||||
.map((objectEntry) =>
|
.forEach((objectEntryReference) => {
|
||||||
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
|
const objectEntry = dereference(
|
||||||
)
|
objectEntryReference,
|
||||||
.map((objectEntry) => getImageFileEntry(objectEntry))
|
gedcom,
|
||||||
.flatMap((imageFileEntry) =>
|
(gedcom) => gedcom.other,
|
||||||
imageFileEntry
|
);
|
||||||
? [
|
const fileEntry = getImageFileEntry(objectEntry);
|
||||||
{
|
if (fileEntry) {
|
||||||
url: imageFileEntry?.data || '',
|
result.push({
|
||||||
filename: getFileName(imageFileEntry) || '',
|
url: resolveFileUrl(fileEntry.data, images),
|
||||||
},
|
filename: getFileName(fileEntry) || '',
|
||||||
]
|
});
|
||||||
: [],
|
}
|
||||||
);
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventFiles(entry: GedcomEntry, gedcom: GedcomData): Image[] {
|
function eventFiles(
|
||||||
return entry.tree
|
entry: GedcomEntry,
|
||||||
|
gedcom: GedcomData,
|
||||||
|
images?: Map<string, string>,
|
||||||
|
): FileEntry[] {
|
||||||
|
const result: FileEntry[] = [];
|
||||||
|
entry.tree
|
||||||
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
||||||
.map((objectEntry) =>
|
.forEach((objectEntryReference) => {
|
||||||
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
|
const objectEntry = dereference(
|
||||||
)
|
objectEntryReference,
|
||||||
.map((objectEntry) => getNonImageFileEntry(objectEntry))
|
gedcom,
|
||||||
.flatMap((fileEntry) =>
|
(gedcom) => gedcom.other,
|
||||||
fileEntry
|
);
|
||||||
? [
|
const fileEntry = getNonImageFileEntry(objectEntry);
|
||||||
{
|
if (fileEntry) {
|
||||||
url: fileEntry?.data || '',
|
result.push({
|
||||||
filename: getFileName(fileEntry) || '',
|
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[] {
|
function eventSources(entry: GedcomEntry, gedcom: GedcomData): Source[] {
|
||||||
@@ -235,11 +250,12 @@ function toEvent(
|
|||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
indi: string,
|
indi: string,
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
|
images?: Map<string, string>,
|
||||||
): EventData[] {
|
): EventData[] {
|
||||||
if (entry.tag === 'FAMS') {
|
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(
|
function toIndiEvent(
|
||||||
@@ -247,6 +263,7 @@ function toIndiEvent(
|
|||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
indi: string,
|
indi: string,
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
|
images?: Map<string, string>,
|
||||||
): EventData[] {
|
): EventData[] {
|
||||||
const date = resolveDate(entry) || null;
|
const date = resolveDate(entry) || null;
|
||||||
return [
|
return [
|
||||||
@@ -256,8 +273,8 @@ function toIndiEvent(
|
|||||||
type: resolveType(entry),
|
type: resolveType(entry),
|
||||||
age: getAge(entry, indi, gedcom, intl),
|
age: getAge(entry, indi, gedcom, intl),
|
||||||
place: eventPlace(entry),
|
place: eventPlace(entry),
|
||||||
images: eventImages(entry, gedcom),
|
images: eventImages(entry, gedcom, images),
|
||||||
files: eventFiles(entry, gedcom),
|
files: eventFiles(entry, gedcom, images),
|
||||||
notes: eventNotes(entry, gedcom),
|
notes: eventNotes(entry, gedcom),
|
||||||
sources: eventSources(entry, gedcom),
|
sources: eventSources(entry, gedcom),
|
||||||
indi: indi,
|
indi: indi,
|
||||||
@@ -269,6 +286,7 @@ function toFamilyEvents(
|
|||||||
entry: GedcomEntry,
|
entry: GedcomEntry,
|
||||||
gedcom: GedcomData,
|
gedcom: GedcomData,
|
||||||
indi: string,
|
indi: string,
|
||||||
|
images?: Map<string, string>,
|
||||||
): EventData[] {
|
): EventData[] {
|
||||||
const family = dereference(entry, gedcom, (gedcom) => gedcom.fams);
|
const family = dereference(entry, gedcom, (gedcom) => gedcom.fams);
|
||||||
return flatMap(FAMILY_EVENT_TAGS, (tag) =>
|
return flatMap(FAMILY_EVENT_TAGS, (tag) =>
|
||||||
@@ -281,8 +299,8 @@ function toFamilyEvents(
|
|||||||
type: resolveType(familyEvent),
|
type: resolveType(familyEvent),
|
||||||
personLink: getSpouse(indi, family, gedcom),
|
personLink: getSpouse(indi, family, gedcom),
|
||||||
place: eventPlace(familyEvent),
|
place: eventPlace(familyEvent),
|
||||||
images: eventImages(familyEvent, gedcom),
|
images: eventImages(familyEvent, gedcom, images),
|
||||||
files: eventFiles(familyEvent, gedcom),
|
files: eventFiles(familyEvent, gedcom, images),
|
||||||
notes: eventNotes(familyEvent, gedcom),
|
notes: eventNotes(familyEvent, gedcom),
|
||||||
sources: eventSources(familyEvent, gedcom),
|
sources: eventSources(familyEvent, gedcom),
|
||||||
indi: indi,
|
indi: indi,
|
||||||
@@ -320,7 +338,9 @@ export function Events(props: Props) {
|
|||||||
const events = flatMap(SORTED_EVENT_TYPE_GROUPS, (eventTypeGroup) =>
|
const events = flatMap(SORTED_EVENT_TYPE_GROUPS, (eventTypeGroup) =>
|
||||||
props.entries
|
props.entries
|
||||||
.filter((entry) => eventTypeGroup.includes(entry.tag))
|
.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)
|
.flatMap((events) => events)
|
||||||
.sort((event1, event2) => compareDates(event1.date, event2.date)),
|
.sort((event1, event2) => compareDates(event1.date, event2.date)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {SyntheticEvent, useState} from 'react';
|
import {SyntheticEvent, useState} from 'react';
|
||||||
import {FormattedMessage} from 'react-intl';
|
import {FormattedMessage} from 'react-intl';
|
||||||
import {
|
import {
|
||||||
|
Card,
|
||||||
Container,
|
Container,
|
||||||
Icon,
|
Icon,
|
||||||
Image,
|
Image,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Placeholder,
|
Placeholder,
|
||||||
} from 'semantic-ui-react';
|
} from 'semantic-ui-react';
|
||||||
|
import {isBrowserLoadable} from '../../util/gedcom_util';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -22,6 +24,33 @@ export function WrappedImage(props: Props) {
|
|||||||
const [imageFailed, setImageFailed] = useState(false);
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
const [imageSrc, setImageSrc] = useState('');
|
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) {
|
if (imageLoaded && imageSrc !== props.url) {
|
||||||
setImageLoaded(false);
|
setImageLoaded(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ export function SidePanel({
|
|||||||
defaultMessage: 'Info',
|
defaultMessage: 'Info',
|
||||||
}),
|
}),
|
||||||
render: () => (
|
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.wife": "Съпруга",
|
||||||
"family.unknown_spouse": "Неизвестен съпруг/а",
|
"family.unknown_spouse": "Неизвестен съпруг/а",
|
||||||
"family.children": "Деца",
|
"family.children": "Деца",
|
||||||
"family.child": "Дете"
|
"family.child": "Дете",
|
||||||
|
"media.not_uploaded": "Файлът не е качен"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,6 @@
|
|||||||
"family.wife": "Manželka",
|
"family.wife": "Manželka",
|
||||||
"family.unknown_spouse": "Neznámý manžel/ka",
|
"family.unknown_spouse": "Neznámý manžel/ka",
|
||||||
"family.children": "Děti",
|
"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.wife": "Ehefrau",
|
||||||
"family.unknown_spouse": "Unbekannter Ehepartner",
|
"family.unknown_spouse": "Unbekannter Ehepartner",
|
||||||
"family.children": "Kinder",
|
"family.children": "Kinder",
|
||||||
"family.child": "Kind"
|
"family.child": "Kind",
|
||||||
|
"media.not_uploaded": "Datei nicht hochgeladen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,6 @@
|
|||||||
"family.wife": "Femme",
|
"family.wife": "Femme",
|
||||||
"family.unknown_spouse": "Conjoint inconnu",
|
"family.unknown_spouse": "Conjoint inconnu",
|
||||||
"family.children": "Enfants",
|
"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.wife": "Moglie",
|
||||||
"family.unknown_spouse": "Coniuge sconosciuto",
|
"family.unknown_spouse": "Coniuge sconosciuto",
|
||||||
"family.children": "Figli",
|
"family.children": "Figli",
|
||||||
"family.child": "Figlio"
|
"family.child": "Figlio",
|
||||||
|
"media.not_uploaded": "File non caricato"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,6 @@
|
|||||||
"family.wife": "Żona",
|
"family.wife": "Żona",
|
||||||
"family.unknown_spouse": "Nieznany współmałżonek",
|
"family.unknown_spouse": "Nieznany współmałżonek",
|
||||||
"family.children": "Dzieci",
|
"family.children": "Dzieci",
|
||||||
"family.child": "Dziecko"
|
"family.child": "Dziecko",
|
||||||
|
"media.not_uploaded": "Plik nieprzesłany"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,5 +143,6 @@
|
|||||||
"family.wife": "Жена",
|
"family.wife": "Жена",
|
||||||
"family.unknown_spouse": "Неизвестный супруг(а)",
|
"family.unknown_spouse": "Неизвестный супруг(а)",
|
||||||
"family.children": "Дети",
|
"family.children": "Дети",
|
||||||
"family.child": "Ребёнок"
|
"family.child": "Ребёнок",
|
||||||
|
"media.not_uploaded": "Файл не загружен"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import {
|
|||||||
findRelationshipPath,
|
findRelationshipPath,
|
||||||
getAncestors,
|
getAncestors,
|
||||||
getDescendants,
|
getDescendants,
|
||||||
|
getFileName,
|
||||||
|
getImageFileEntry,
|
||||||
getName,
|
getName,
|
||||||
|
getNonImageFileEntry,
|
||||||
idToFamMap,
|
idToFamMap,
|
||||||
idToIndiMap,
|
idToIndiMap,
|
||||||
|
isBrowserLoadable,
|
||||||
|
isImageFile,
|
||||||
normalizeGedcom,
|
normalizeGedcom,
|
||||||
|
resolveFileUrl,
|
||||||
} from './gedcom_util';
|
} from './gedcom_util';
|
||||||
|
|
||||||
describe('normalizeGedcom()', () => {
|
describe('normalizeGedcom()', () => {
|
||||||
@@ -188,3 +194,122 @@ describe('Relationship algorithms', () => {
|
|||||||
expect(descendants).toContain('I3');
|
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 {
|
export interface TopolaData {
|
||||||
chartData: JsonGedcomData;
|
chartData: JsonGedcomData;
|
||||||
gedcom: GedcomData;
|
gedcom: GedcomData;
|
||||||
|
images?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Source {
|
export interface Source {
|
||||||
@@ -204,11 +205,12 @@ export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
|
|||||||
return sortSpouses(sortChildren(gedcom));
|
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. */
|
/** Returns true if the given file name has a known image extension. */
|
||||||
export function isImageFile(fileName: string): boolean {
|
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));
|
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.
|
* Removes images that are not HTTP links or do not have known image extensions.
|
||||||
* Does not modify the input object.
|
* 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 {
|
function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
|
||||||
if (!indi.images || indi.images.length === 0) {
|
if (!indi.images || indi.images.length === 0) {
|
||||||
return indi;
|
return indi;
|
||||||
}
|
}
|
||||||
const newImages: JsonImage[] = [];
|
const newImages: JsonImage[] = [];
|
||||||
indi.images.forEach((image) => {
|
indi.images.forEach((image) => {
|
||||||
const filePath = image.url.replaceAll('\\', '/');
|
const resolvedUrl = resolveFileUrl(image.url, images);
|
||||||
const fileName = filePath.split('/').pop() || '';
|
const normalizedUrl = image.url.replace(/\\/g, '/');
|
||||||
const fileUrl = images.get(filePath);
|
if (
|
||||||
const nameUrl = images.get(fileName);
|
resolvedUrl !== normalizedUrl ||
|
||||||
if (fileUrl) {
|
(isBrowserLoadable(resolvedUrl) && isImageFile(resolvedUrl))
|
||||||
newImages.push({url: fileUrl, title: image.title});
|
) {
|
||||||
} else if (nameUrl) {
|
newImages.push({url: resolvedUrl, title: image.title});
|
||||||
newImages.push({url: nameUrl, title: image.title});
|
|
||||||
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
|
|
||||||
newImages.push(image);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return Object.assign({}, indi, {images: newImages});
|
return Object.assign({}, indi, {images: newImages});
|
||||||
@@ -276,6 +297,7 @@ export function convertGedcom(
|
|||||||
return {
|
return {
|
||||||
chartData: filterImages(normalizeGedcom(json), images),
|
chartData: filterImages(normalizeGedcom(json), images),
|
||||||
gedcom: prepareGedcom(entries),
|
gedcom: prepareGedcom(entries),
|
||||||
|
images,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +331,17 @@ export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
|||||||
(entry) => entry.tag === 'FORM',
|
(entry) => entry.tag === 'FORM',
|
||||||
)?.data;
|
)?.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(
|
function findFileEntry(
|
||||||
@@ -317,8 +349,7 @@ function findFileEntry(
|
|||||||
predicate: (entry: GedcomEntry) => boolean,
|
predicate: (entry: GedcomEntry) => boolean,
|
||||||
): GedcomEntry | undefined {
|
): GedcomEntry | undefined {
|
||||||
return objectEntry.tree.find(
|
return objectEntry.tree.find(
|
||||||
(entry) =>
|
(entry) => entry.tag === 'FILE' && entry.data && predicate(entry),
|
||||||
entry.tag === 'FILE' && entry.data.startsWith('http') && predicate(entry),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,4 +201,40 @@ test.describe('Details panel visual validation @visual', () => {
|
|||||||
await sidebar.waitFor();
|
await sidebar.waitFor();
|
||||||
await expect(sidebar).toHaveScreenshot('details-immediate-family.png');
|
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 |
Reference in New Issue
Block a user