Display sources and links to non-image files in details panel (#210)

This commit is contained in:
czifumasa 2025-08-03 23:59:44 +02:00 committed by GitHub
parent 91a8bd0115
commit 3438c29ab8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 377 additions and 118 deletions

View File

@ -0,0 +1,29 @@
import {List} from 'semantic-ui-react';
export interface FileEntry {
url: string;
filename?: string;
}
interface Props {
files?: FileEntry[];
}
export function AdditionalFiles({files}: Props) {
if (!files?.length) return null;
return (
<List>
{files.map((file, index) => (
<List.Item key={index}>
<List.Icon verticalAlign="middle" name="circle" size="tiny" />
<List.Content>
<a target="_blank" href={file.url} rel="noopener noreferrer">
{file.filename || file.url.split('/').pop() || file.url}
</a>
</List.Content>
</List.Item>
))}
</List>
);
}

View File

@ -8,9 +8,13 @@ import {
getData,
getFileName,
getImageFileEntry,
getNonImageFileEntry,
mapToSource,
} from '../util/gedcom_util';
import {AdditionalFiles} from './additional-files';
import {Events} from './events';
import {MultilineText} from './multiline-text';
import {Sources} from './sources';
import {TranslatedTag} from './translated-tag';
import {WrappedImage} from './wrapped-image';
@ -55,23 +59,95 @@ function dataDetails(entry: GedcomEntry) {
);
}
function fileDetails(objectEntry: GedcomEntry) {
const imageFileEntry = getImageFileEntry(objectEntry);
function imageDetails(objectEntryReference: GedcomEntry, gedcom: GedcomData) {
const imageEntry = dereference(
objectEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
return imageFileEntry ? (
const imageFileEntry = getImageFileEntry(imageEntry);
if (!imageFileEntry || !hasData(imageEntry)) {
return null;
}
return (
<div className="person-image">
<WrappedImage
url={imageFileEntry.data}
filename={getFileName(imageFileEntry) || ''}
/>
</div>
) : null;
);
}
function noteDetails(entry: GedcomEntry) {
function sourceDetails(
sourceReferenceEntries: GedcomEntry[],
gedcom: GedcomData,
) {
const sources = sourceReferenceEntries.map((sourceEntryReference) =>
mapToSource(sourceEntryReference, gedcom),
);
if (!sources.length) {
return null;
}
return (
<>
<div className="item-header">
<Header as="span" size="small">
<TranslatedTag tag="SOUR" />
</Header>
</div>
<Sources sources={sources} />
</>
);
}
function fileDetails(objectEntries: GedcomEntry[], gedcom: GedcomData) {
const files = objectEntries
.map((objectEntry) =>
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
)
.map((objectEntry) => getNonImageFileEntry(objectEntry))
.filter((objectEntry): objectEntry is GedcomEntry => !!objectEntry)
.map((fileEntry) => ({
url: fileEntry.data,
filename: getFileName(fileEntry),
}));
if (!files.length) {
return null;
}
return (
<>
<div className="item-header">
<Header as="span" size="small">
<TranslatedTag tag="OBJE" />
</Header>
</div>
<AdditionalFiles files={files} />
</>
);
}
function noteDetails(noteEntryReference: GedcomEntry, gedcom: GedcomData) {
const noteEntry = dereference(
noteEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
if (!noteEntry || !hasData(noteEntry)) {
return null;
}
return (
<MultilineText
lines={getData(entry).map((line, index) => (
lines={getData(noteEntry).map((line, index) => (
<i key={index}>{line}</i>
))}
/>
@ -103,15 +179,19 @@ function nameDetails(entry: GedcomEntry) {
);
}
function getDetails(
function getSectionForEachMatchingEntry(
entries: GedcomEntry[],
gedcom: GedcomData,
tags: string[],
detailsFunction: (entry: GedcomEntry) => React.ReactNode | null,
detailsFunction: (
entry: GedcomEntry,
gedcom: GedcomData,
) => React.ReactNode | null,
): React.ReactNode[] {
return flatMap(tags, (tag) =>
entries
.filter((entry) => entry.tag === tag)
.map((entry) => detailsFunction(entry)),
.map((entry) => detailsFunction(entry, gedcom)),
)
.filter((element) => element !== null)
.map((element, index) => (
@ -121,6 +201,34 @@ function getDetails(
));
}
function combineAllMatchingEntriesIntoSingleSection(
entries: GedcomEntry[],
gedcom: GedcomData,
tags: string[],
detailsFunction: (
entries: GedcomEntry[],
gedcom: GedcomData,
) => React.ReactNode | null,
): React.ReactNode {
const entriesWithMatchingTag = flatMap(tags, (tag) =>
entries.filter((entry) => entry.tag === tag),
).filter((element) => element !== null);
const sectionWithDetails = entriesWithMatchingTag.length
? detailsFunction(entriesWithMatchingTag, gedcom)
: null;
if (!sectionWithDetails) {
return null;
}
return (
<Item>
<Item.Content>{sectionWithDetails}</Item.Content>
</Item>
);
}
/**
* Returns true if there is displayable information in this entry.
* Returns false if there is no data in this entry or this is only a reference
@ -130,9 +238,10 @@ function hasData(entry: GedcomEntry) {
return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@'));
}
function getOtherDetails(entries: GedcomEntry[]) {
function getOtherSections(entries: GedcomEntry[], gedcom: GedcomData) {
return entries
.filter((entry) => !EXCLUDED_TAGS.includes(entry.tag))
.map((entry) => dereference(entry, gedcom, (gedcom) => gedcom.other))
.filter(hasData)
.map((entry) => dataDetails(entry))
.filter((element) => element !== null)
@ -150,18 +259,42 @@ interface Props {
export function Details(props: Props) {
const entries = props.gedcom.indis[props.indi].tree;
const entriesWithData = entries
.map((entry) => dereference(entry, props.gedcom, (gedcom) => gedcom.other))
.filter(hasData);
return (
<div className="details">
<Item.Group divided>
{getDetails(entries, ['NAME'], nameDetails)}
{getDetails(entriesWithData, ['OBJE'], fileDetails)}
{getSectionForEachMatchingEntry(
entries,
props.gedcom,
['NAME'],
nameDetails,
)}
{getSectionForEachMatchingEntry(
entries,
props.gedcom,
['OBJE'],
imageDetails,
)}
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
{getOtherDetails(entriesWithData)}
{getDetails(entriesWithData, ['NOTE'], noteDetails)}
{getOtherSections(entries, props.gedcom)}
{getSectionForEachMatchingEntry(
entries,
props.gedcom,
['NOTE'],
noteDetails,
)}
{combineAllMatchingEntriesIntoSingleSection(
entries,
props.gedcom,
['OBJE'],
fileDetails,
)}
{combineAllMatchingEntriesIntoSingleSection(
entries,
props.gedcom,
['SOUR'],
sourceDetails,
)}
</Item.Group>
</div>
);

View File

@ -1,7 +1,5 @@
import * as React from 'react';
import {useState} from 'react';
import {FormattedMessage, IntlShape, useIntl} from 'react-intl';
import Linkify from 'react-linkify';
import {FormattedMessage} from 'react-intl';
import {
Icon,
Item,
@ -11,9 +9,10 @@ import {
Popup,
Tab,
} from 'semantic-ui-react';
import {DateOrRange} from 'topola';
import {formatDateOrRange} from '../util/date_util';
import {Source} from '../util/gedcom_util';
import {AdditionalFiles, FileEntry} from './additional-files';
import {MultilineText} from './multiline-text';
import {Sources} from './sources';
import {WrappedImage} from './wrapped-image';
export interface Image {
@ -22,19 +21,12 @@ export interface Image {
title?: string;
}
export interface Source {
title?: string;
author?: string;
page?: string;
date?: DateOrRange;
publicationInfo?: string;
}
interface Props {
images?: Image[];
notes?: string[][];
sources?: Source[];
indi: string;
files?: FileEntry[];
}
function eventImages(images: Image[] | undefined) {
@ -69,37 +61,7 @@ function eventNotes(notes: string[][] | undefined) {
);
}
function eventSources(sources: Source[] | undefined, intl: IntlShape) {
return (
!!sources?.length && (
<List>
{sources.map((source, index) => (
<List.Item key={index}>
<List.Icon verticalAlign="middle" name="circle" size="tiny" />
<List.Content>
<List.Header>
<Linkify properties={{target: '_blank'}}>
{[source.author, source.title, source.publicationInfo]
.filter((sourceElement) => sourceElement)
.join(', ')}
</Linkify>
</List.Header>
<List.Description>
<Linkify properties={{target: '_blank'}}>{source.page}</Linkify>
{source.date
? ' [' + formatDateOrRange(source.date, intl) + ']'
: null}
</List.Description>
</List.Content>
</List.Item>
))}
</List>
)
);
}
export function EventExtras(props: Props) {
const intl = useIntl();
const [activeIndex, setActiveIndex] = useState(-1);
const [indi, setIndi] = useState('');
@ -109,7 +71,7 @@ export function EventExtras(props: Props) {
}
function handleTabOnClick(
event: React.MouseEvent<HTMLAnchorElement>,
_event: React.MouseEvent<HTMLAnchorElement>,
menuItemProps: MenuItemProps,
) {
menuItemProps.index !== undefined && activeIndex !== menuItemProps.index
@ -162,10 +124,37 @@ export function EventExtras(props: Props) {
/>
</Menu.Item>
),
render: () => <Tab.Pane>{eventSources(props.sources, intl)}</Tab.Pane>,
render: () => (
<Tab.Pane>
<Sources sources={props.sources} />
</Tab.Pane>
),
};
const panes = [imageTab, noteTab, sourceTab].flatMap((tab) =>
const filesTab = props.files?.length && {
menuItem: (
<Menu.Item fitted key="files" onClick={handleTabOnClick}>
<Popup
content={
<FormattedMessage
id="extras.files"
defaultMessage="Additonal files"
/>
}
size="mini"
position="bottom center"
trigger={<Icon circular name="file alternate outline" />}
/>
</Menu.Item>
),
render: () => (
<Tab.Pane>
<AdditionalFiles files={props.files} />
</Tab.Pane>
),
};
const panes = [imageTab, noteTab, sourceTab, filesTab].flatMap((tab) =>
tab ? [tab] : [],
);

View File

@ -14,9 +14,14 @@ import {
getFileName,
getImageFileEntry,
getName,
getNonImageFileEntry,
mapToSource,
pointerToId,
resolveDate,
Source,
} from '../util/gedcom_util';
import {EventExtras, Image, Source} from './event-extras';
import {FileEntry} from './additional-files';
import {EventExtras, Image} from './event-extras';
import {TranslatedTag} from './translated-tag';
function PersonLink(props: {person: GedcomEntry}) {
@ -53,6 +58,7 @@ interface EventData {
personLink?: GedcomEntry;
place?: string[];
images?: Image[];
files?: FileEntry[];
notes?: string[][];
sources?: Source[];
indi: string;
@ -74,7 +80,7 @@ const FAMILY_EVENT_TAGS = ['MARR', 'DIV'];
function EventHeader(props: {event: EventData}) {
const intl = useIntl();
return (
<div className="event-header">
<div className="item-header">
<Header as="span" size="small">
<TranslatedTag tag={props.event.type} />
</Header>
@ -144,48 +150,29 @@ function eventImages(entry: GedcomEntry, gedcom: GedcomData): Image[] {
);
}
function eventFiles(entry: GedcomEntry, gedcom: GedcomData): Image[] {
return 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) || '',
},
]
: [],
);
}
function eventSources(entry: GedcomEntry, gedcom: GedcomData): Source[] {
return entry.tree
.filter((subEntry) => 'SOUR' === subEntry.tag)
.map((sourceEntryReference) => {
const sourceEntry = dereference(
sourceEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
const title = sourceEntry.tree.find(
(subEntry) => 'TITL' === subEntry.tag,
);
const abbr = sourceEntry.tree.find((subEntry) => 'ABBR' === subEntry.tag);
const author = sourceEntry.tree.find(
(subEntry) => 'AUTH' === subEntry.tag,
);
const publicationInfo = sourceEntry.tree.find(
(subEntry) => 'PUBL' === subEntry.tag,
);
const page = sourceEntryReference.tree.find(
(subEntry) => 'PAGE' === subEntry.tag,
);
const sourceData = sourceEntryReference.tree.find(
(subEntry) => 'DATA' === subEntry.tag,
);
const date = sourceData ? resolveDate(sourceData) : undefined;
return {
title: title?.data || abbr?.data,
author: author?.data,
page: page?.data,
date: date ? getDate(date.data) : undefined,
publicationInfo: publicationInfo?.data,
};
});
.map((sourceEntryReference) => mapToSource(sourceEntryReference, gedcom));
}
function eventNotes(entry: GedcomEntry, gedcom: GedcomData): string[][] {
@ -221,6 +208,7 @@ function toIndiEvent(
age: getAge(entry, indi, gedcom, intl),
place: eventPlace(entry),
images: eventImages(entry, gedcom),
files: eventFiles(entry, gedcom),
notes: eventNotes(entry, gedcom),
sources: eventSources(entry, gedcom),
indi: indi,
@ -228,10 +216,6 @@ function toIndiEvent(
];
}
function resolveDate(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
}
function toFamilyEvents(
entry: GedcomEntry,
gedcom: GedcomData,
@ -248,6 +232,7 @@ function toFamilyEvents(
personLink: getSpouse(indi, family, gedcom),
place: eventPlace(familyMarriageEvent),
images: eventImages(familyMarriageEvent, gedcom),
files: eventFiles(familyMarriageEvent, gedcom),
notes: eventNotes(familyMarriageEvent, gedcom),
sources: eventSources(familyMarriageEvent, gedcom),
indi: indi,
@ -272,6 +257,7 @@ function Event(props: {event: EventData}) {
notes={props.event.notes}
sources={props.event.sources}
indi={props.event.indi}
files={props.event.files}
/>
</Item.Content>
</Item>

38
src/details/sources.tsx Normal file
View File

@ -0,0 +1,38 @@
import {useIntl} from 'react-intl';
import Linkify from 'react-linkify';
import {List} from 'semantic-ui-react';
import {formatDateOrRange} from '../util/date_util';
import {Source} from '../util/gedcom_util';
interface Props {
sources?: Source[];
}
export function Sources({sources}: Props) {
const intl = useIntl();
if (!sources?.length) return null;
return (
<List>
{sources.map((source, index) => (
<List.Item key={index}>
<List.Icon verticalAlign="middle" name="circle" size="tiny" />
<List.Content>
<List.Header>
<Linkify properties={{target: '_blank'}}>
{[source.author, source.title, source.publicationInfo]
.filter((sourceElement) => !!sourceElement)
.join(', ')}
</Linkify>
</List.Header>
<List.Description>
<Linkify properties={{target: '_blank'}}>{source.page}</Linkify>
{source.date && ` [${formatDateOrRange(source.date, intl)}]`}
</List.Description>
</List.Content>
</List.Item>
))}
</List>
);
}

View File

@ -22,6 +22,8 @@ const TAG_DESCRIPTIONS = new Map([
['OCCU', 'Occupation'],
['TITL', 'Title'],
['WWW', 'WWW'],
['OBJE', 'Additional files'],
['SOUR', 'Sources'],
['birth', 'Birth name'],
['married', 'Married name'],
['maiden', 'Maiden name'],

View File

@ -153,13 +153,13 @@ div.zoom {
padding: 0 15px;
}
.details .event-header {
.details .item-header {
justify-content: space-between;
display: flex;
word-break: break-word;
}
.details .event-header .header {
.details .item-header .header {
text-transform: uppercase;
margin: 0;
min-width: 40%;

View File

@ -62,6 +62,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Обръщение",
"gedcom.WWW": "Линк",
"gedcom.OBJE": "Допълнителни файлове",
"gedcom.SOUR": "Източници",
"gedcom._UPD": "Последно обновление",
"gedcom.birth": "Рождено име",
"gedcom.married": "Име след брак",
@ -106,5 +108,6 @@
"name.unknown_name": "Неизвестно име",
"extras.images": "Изображение",
"extras.notes": "Бележки",
"extras.sources": "Източници"
"extras.sources": "Източници",
"extras.files": "Допълнителни файлове"
}

View File

@ -63,6 +63,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Titul",
"gedcom.WWW": "Stránka WWW",
"gedcom.OBJE": "Další soubory",
"gedcom.SOUR": "Zdroje",
"gedcom.RELI": "Vyznání",
"gedcom._UPD": "Poslední aktualizace",
"gedcom.birth": "Rodné jméno",
@ -107,5 +109,6 @@
"name.unknown_name": "N.N.",
"extras.images": "Obrázky",
"extras.notes": "Poznámky",
"extras.sources": "Zdroje"
"extras.sources": "Zdroje",
"extras.files": "Další soubory"
}

View File

@ -50,6 +50,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Titel",
"gedcom.WWW": "Website",
"gedcom.OBJE": "Zusätzliche Dateien",
"gedcom.SOUR": "Quellen",
"gedcom._UPD": "Zuletzt aktualisiert",
"gedcom.birth": "Geburtsname",
"gedcom.married": "Ehenamen",

View File

@ -60,6 +60,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Titre",
"gedcom.WWW": "Site Web",
"gedcom.OBJE": "Fichiers supplémentaires",
"gedcom.SOUR": "Sources",
"gedcom._UPD": "Dernière mise à jour",
"gedcom.MARR": "Mariage",
"gedcom.DIV": "Divorce",

View File

@ -52,6 +52,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Titolo",
"gedcom.WWW": "Sito web",
"gedcom.OBJE": "File aggiuntivi",
"gedcom.SOUR": "Fonti",
"gedcom._UPD": "Ultimo aggiornamento",
"gedcom.birth": "Nome alla nascita",
"gedcom.married": "Nome da coniugato/a",

View File

@ -55,6 +55,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Tytuł",
"gedcom.WWW": "Strona WWW",
"gedcom.OBJE": "Dodatkowe pliki",
"gedcom.SOUR": "Źródła",
"gedcom._UPD": "Ostatnia aktualizacja",
"gedcom.MARR": "Małżeństwo",
"gedcom.DIV": "Rozwód",
@ -94,5 +96,6 @@
"name.unknown_name": "N.N.",
"extras.images": "Zdjęcia",
"extras.notes": "Notatki",
"extras.sources": "Źródła"
"extras.sources": "Źródła",
"extras.files": "Dodatkowe pliki"
}

View File

@ -60,6 +60,8 @@
"gedcom.RIN": "ID",
"gedcom.TITL": "Титул",
"gedcom.WWW": "Веб-сайт WWW",
"gedcom.OBJE": "Дополнительные файлы",
"gedcom.SOUR": "Источники",
"gedcom._UPD": "Последнее обновление",
"gedcom.birth": "Имя при рождении",
"gedcom.married": "Имя в браке",
@ -97,5 +99,6 @@
"name.unknown_name": "Н.И.",
"extras.images": "Картинки",
"extras.notes": "Примечание",
"extras.sources": "Источники"
"extras.sources": "Источники",
"extras.files": "Дополнительные файлы"
}

View File

@ -1,6 +1,8 @@
import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom';
import {
DateOrRange,
gedcomEntriesToJson,
getDate,
JsonFam,
JsonGedcomData,
JsonImage,
@ -25,6 +27,14 @@ export interface TopolaData {
gedcom: GedcomData;
}
export interface Source {
title?: string;
author?: string;
page?: string;
date?: DateOrRange;
publicationInfo?: string;
}
/**
* Returns the identifier extracted from a pointer string.
* E.g. '@I123@' -> 'I123'
@ -296,13 +306,67 @@ export function getFileName(fileEntry: GedcomEntry): string | undefined {
return fileTitle && fileExtension && fileTitle + '.' + fileExtension;
}
export function getImageFileEntry(
function findFileEntry(
objectEntry: GedcomEntry,
predicate: (entry: GedcomEntry) => boolean,
): GedcomEntry | undefined {
return objectEntry.tree.find(
(entry) =>
entry.tag === 'FILE' &&
entry.data.startsWith('http') &&
isImageFile(entry.data),
entry.tag === 'FILE' && entry.data.startsWith('http') && predicate(entry),
);
}
export function getNonImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
}
export function getImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
}
export function resolveDate(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
}
export function mapToSource(
sourceEntryReference: GedcomEntry,
gedcom: GedcomData,
) {
const sourceEntry = dereference(
sourceEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
const title = sourceEntry.tree.find((subEntry) => 'TITL' === subEntry.tag);
const abbr = sourceEntry.tree.find((subEntry) => 'ABBR' === subEntry.tag);
const author = sourceEntry.tree.find((subEntry) => 'AUTH' === subEntry.tag);
const publicationInfo = sourceEntry.tree.find(
(subEntry) => 'PUBL' === subEntry.tag,
);
const page = sourceEntryReference.tree.find(
(subEntry) => 'PAGE' === subEntry.tag,
);
const sourceData = sourceEntryReference.tree.find(
(subEntry) => 'DATA' === subEntry.tag,
);
const date = sourceData ? resolveDate(sourceData) : undefined;
return {
title: title?.data || abbr?.data,
author: author?.data,
page: page?.data,
date: date ? getDate(date.data) : undefined,
publicationInfo: publicationInfo?.data,
};
}