From 8d3ab4b84e14f0af93ca21bcecd5183f9149e2c4 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Mon, 22 Jun 2026 01:45:52 +0300 Subject: [PATCH] Update Genealogical Tree: * Add `isDeceased` and `fromExMarriage` properties, display corresponding state in the templates; * Add migration from `deathPlace` to `burialPlace`; * Add migration for relatedTo label from "link state" to proper `rdfs:label` property; * Auto-migrate `diagram.json` to `diagrams/main.json`; --- .../GenealogicalTree/FormInputs.module.css | 3 + src/tools/GenealogicalTree/FormInputs.tsx | 109 +++++++++++++ .../GenealogicalTree/GenealogicalPackage.tsx | 48 ++++-- .../GenealogicalTree/GenealogicalSchema.ttl | 51 ++++++- .../GenealogicalTree/GenealogicalTree.tsx | 143 +++++++++--------- .../GraphTemplates.module.css | 20 +++ src/tools/GenealogicalTree/GraphTemplates.tsx | 83 +++++++++- src/tools/GenealogicalTree/MainMenu.tsx | 54 ++++++- src/tools/GenealogicalTree/OwlShaclSchema.ts | 6 +- src/tools/GenealogicalTree/Vocabularies.ts | 3 + .../translations/en.translation.json | 2 + 11 files changed, 423 insertions(+), 99 deletions(-) create mode 100644 src/tools/GenealogicalTree/FormInputs.module.css create mode 100644 src/tools/GenealogicalTree/FormInputs.tsx diff --git a/src/tools/GenealogicalTree/FormInputs.module.css b/src/tools/GenealogicalTree/FormInputs.module.css new file mode 100644 index 00000000..36999564 --- /dev/null +++ b/src/tools/GenealogicalTree/FormInputs.module.css @@ -0,0 +1,3 @@ +.inputCheck { + vertical-align: center; +} diff --git a/src/tools/GenealogicalTree/FormInputs.tsx b/src/tools/GenealogicalTree/FormInputs.tsx new file mode 100644 index 00000000..d459dae8 --- /dev/null +++ b/src/tools/GenealogicalTree/FormInputs.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import * as Reactodia from '@reactodia/workspace'; +import * as Forms from '@reactodia/workspace/forms'; + +import { findGenealogicalProvider } from './GenealogicalDataProvider'; +import { genealogy, rdfs, schema, xsd } from './Vocabularies'; + +import styles from './FormInputs.module.css'; + +export function GenealogicalPropertyEditor(props: { + options: Reactodia.PropertyEditorOptions; +}) { + const {options} = props; + return ( + { + if (property === rdfs.comment) { + return ; + } else if (property === schema.gender) { + return ; + } else if (property === Reactodia.schema.thumbnailUrl) { + return ; + } else if ( + property === genealogy.fromExMarriage || + property === genealogy.isDeceased + ) { + return ; + } + return ; + }} + /> + ); +} + +function InputGender(props: Forms.InputSingleProps) { + const {factory} = props; + const {model} = Reactodia.useWorkspace(); + + let provider: Reactodia.DataProvider | undefined; + if (model.dataProvider instanceof Reactodia.CompositeDataProvider) { + provider = model.dataProvider.providers + .find(p => p.origin?.value === genealogy.SchemaOrigin)?.provider; + } + + const {data: entities} = Reactodia.useProvidedEntities(provider, [schema.Male, schema.Female]); + const language = Reactodia.useObservedProperty( + model.events, 'changeLanguage', () => model.language + ); + const variants = React.useMemo( + () => Array.from(entities.values(), (item): Forms.InputSelectVariant => ({ + value: factory.namedNode(item.id), + label: model.locale.formatEntityLabel(item, language), + })), + [entities, language, factory] + ); + + return ( + + ); +} + +function InputImage(props: Forms.InputMultiProps) { + const {values} = props; + const {model} = Reactodia.useWorkspace(); + const mainProvider = findGenealogicalProvider(model.dataProvider); + if (!mainProvider) { + throw new Error('Failed to find main provider'); + } + const {data: fileMetadata} = Reactodia.useProvidedEntities( + mainProvider, + values.filter(v => v.termType === 'NamedNode').map(v => v.value) + ); + return ( + /^image\//.test(item.type)} + /> + ); +} + +function InputCheck(props: Forms.InputMultiProps) { + const {readonly, factory, values, updateValues} = props; + const unchecked = values.every(v => ( + v.termType === 'Literal' && + v.datatype.value === xsd.boolean && + v.value === 'false' + )); + return ( + { + const nextChecked = e.currentTarget.checked; + updateValues(() => + nextChecked + ? [factory.literal('true', factory.namedNode(xsd.boolean))] + : [] + ); + }} + /> + ); +} + +function MultilineTextInput(props: Forms.InputSingleProps) { + return ; +} diff --git a/src/tools/GenealogicalTree/GenealogicalPackage.tsx b/src/tools/GenealogicalTree/GenealogicalPackage.tsx index 38e3bac8..e5eafcdf 100644 --- a/src/tools/GenealogicalTree/GenealogicalPackage.tsx +++ b/src/tools/GenealogicalTree/GenealogicalPackage.tsx @@ -8,6 +8,7 @@ import { applyRdfChanges } from './ApplyRdfChanges'; import { genealogy, rdfs, schema, xsd } from './Vocabularies'; export class GenealogicalPackage { + private static readonly DIAGRAM_IRI_PREFIX = 'urn:reactodia:genealogical-package:diagram:'; private static readonly FILE_IRI_PREFIX = 'urn:reactodia:genealogical-package:file:'; static readonly DEFAULT_NAMESPACE_BASE = 'http://reactodia.github.io/genealogy-graph/'; @@ -15,7 +16,7 @@ export class GenealogicalPackage { private readonly imageNameToUrl = new Map(); private constructor( - readonly diagram: Reactodia.SerializedDiagram | undefined, + readonly diagrams: ReadonlyMap, readonly graph: readonly Reactodia.Rdf.Quad[], private readonly prefixes: Readonly>, private readonly entries: zip.Entry[], @@ -44,7 +45,7 @@ export class GenealogicalPackage { factory.literal(GenealogicalPackage.DEFAULT_NAMESPACE_BASE), ), ]; - return new GenealogicalPackage(undefined, graph, {}, [], controller.signal); + return new GenealogicalPackage(new Map(), graph, {}, [], controller.signal); } static async loadFromBytes(bytes: Uint8Array, options: { signal: AbortSignal }): Promise { @@ -55,16 +56,30 @@ export class GenealogicalPackage { signal.addEventListener('abort', () => reader.close()); const entries = await reader.getEntries(); - const diagramEntry = entries.find((e): e is zip.FileEntry => !e.directory && e.filename === 'diagram.json'); const graphEntry = entries.find((e): e is zip.FileEntry => !e.directory && e.filename === 'graph.ttl'); - let diagram: Reactodia.SerializedDiagram | undefined; - if (diagramEntry) { - const diagramJson = await diagramEntry.getData(new zip.TextWriter()); - try { - diagram = JSON.parse(diagramJson); - } catch (err) { - throw new Error('Failed to parse serialized diagram "diagram.json"', {cause: err}); + const diagrams = new Map(); + for (const entry of entries) { + if (entry.directory) { + continue; + } + + const match = /^diagrams\/(.+).json$/.exec(entry.filename); + let diagramName = match ? match[1] : undefined; + + // Temporary compatibility with initial version + if (entry.filename === 'diagram.json') { + diagramName = 'main'; + } + + if (diagramName) { + const diagramJson = await (entry as zip.FileEntry).getData(new zip.TextWriter()); + try { + const diagram = JSON.parse(diagramJson); + diagrams.set(GenealogicalPackage.DIAGRAM_IRI_PREFIX + diagramName, diagram); + } catch (err) { + throw new Error(`Failed to parse serialized diagram "${entry.filename}"`, {cause: err}); + } } } @@ -81,7 +96,7 @@ export class GenealogicalPackage { } } - return new GenealogicalPackage(diagram, graph, prefixes, entries, signal); + return new GenealogicalPackage(diagrams, graph, prefixes, entries, signal); } async resolveFileUrl(iri: string, options: { signal?: AbortSignal }): Promise { @@ -107,10 +122,10 @@ export class GenealogicalPackage { async exportWith(params: { dataProvider: Reactodia.RdfDataProvider; authoringState: Reactodia.AuthoringState; - diagram: Reactodia.SerializedDiagram | undefined; + diagrams: ReadonlyMap; uploader?: Forms.MemoryFileUploader; }): Promise { - const {dataProvider, authoringState: baseState, diagram, uploader} = params; + const {dataProvider, authoringState: baseState, diagrams, uploader} = params; const factory = Reactodia.Rdf.DefaultDataFactory; let authoringState = baseState; @@ -191,8 +206,11 @@ export class GenealogicalPackage { const zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip")); - if (diagram) { - await zipWriter.add('diagram.json', new zip.TextReader(JSON.stringify(diagram))); + for (const [slug, diagram] of diagrams) { + await zipWriter.add( + `diagrams/${slug}.json`, + new zip.TextReader(JSON.stringify(diagram)) + ); } await zipWriter.add('graph.ttl', new zip.TextReader(graphTurtle)); diff --git a/src/tools/GenealogicalTree/GenealogicalSchema.ttl b/src/tools/GenealogicalTree/GenealogicalSchema.ttl index d9ec7503..5ee6c171 100644 --- a/src/tools/GenealogicalTree/GenealogicalSchema.ttl +++ b/src/tools/GenealogicalTree/GenealogicalSchema.ttl @@ -15,12 +15,21 @@ :Marriage a owl:Class; rdfs:label "Marriage"@en, "Брак"@ru . +:burialPlace a owl:ObjectProperty; + rdfs:label "burial place"@en, "похоронен(а) в"@ru . + +:fromExMarriage a owl:DatatypeProperty; + rdfs:label "from ex-marriage"@en, "из бывшего брака"@ru . + :hasGodparent a owl:ObjectProperty; rdfs:label "has godparent"@en, "имеет крёстного(ую)"@ru . :hasPartner a owl:ObjectProperty; rdfs:label "has marriage partner"@en, "супруг(а)"@ru . +:isDeceased a owl:DatatypeProperty; + rdfs:label "is deceased"@en, "скончался(ась)"@ru . + rdfs:comment a owl:DatatypeProperty; rdfs:label "comment"@en, "комментарий"@ru . @@ -134,7 +143,7 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:class schema:Person ; sh:nodeKind sh:IRI ; sh:minCount 2 ; - dash:reifiableBy :commonPropertyShape ; + dash:reifiableBy :hasPartnerShape ; ] . :PersonShape a sh:NodeShape ; @@ -163,14 +172,20 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:order 4 ; ] ; sh:property [ - sh:path schema:thumbnailUrl ; + sh:path :isDeceased ; sh:maxCount 1 ; + sh:datatype xsd:boolean ; sh:order 5 ; ] ; sh:property [ - sh:path rdfs:comment ; + sh:path schema:thumbnailUrl ; + sh:maxCount 1 ; sh:order 6 ; ] ; + sh:property [ + sh:path rdfs:comment ; + sh:order 7 ; + ] ; sh:property [ sh:path schema:parent ; sh:class schema:Person ; @@ -187,7 +202,7 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:path schema:relatedTo ; sh:class schema:Person ; sh:nodeKind sh:IRI ; - dash:reifiableBy :commonPropertyShape ; + dash:reifiableBy :relatedToShape ; ] ; sh:property [ sh:path schema:birthPlace ; @@ -203,6 +218,13 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:maxCount 1 ; dash:reifiableBy :commonPropertyShape ; ] ; + sh:property [ + sh:path :burialPlace ; + sh:class schema:Place ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + dash:reifiableBy :commonPropertyShape ; + ] ; sh:property [ sh:path schema:homeLocation ; sh:class schema:Place ; @@ -275,6 +297,16 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:path rdfs:comment ; ] . +:relatedToShape a sh:NodeShape ; + sh:property [ + sh:path rdfs:comment ; + ] ; + sh:property [ + sh:path rdfs:label ; + sh:uniqueLang true ; + sh:order 1 ; + ] . + :homeLocationShape a sh:NodeShape ; sh:property [ sh:path rdfs:comment ; @@ -291,3 +323,14 @@ reactodia:sourceProvider a owl:DatatypeProperty; sh:datatype xsd:date ; sh:order 2 ; ] . + +:hasPartnerShape a sh:NodeShape ; + sh:property [ + sh:path rdfs:comment ; + ] ; + sh:property [ + sh:path :fromExMarriage ; + sh:maxCount 1 ; + sh:datatype xsd:boolean ; + sh:order 1 ; + ] . diff --git a/src/tools/GenealogicalTree/GenealogicalTree.tsx b/src/tools/GenealogicalTree/GenealogicalTree.tsx index a929d647..9aa069b5 100644 --- a/src/tools/GenealogicalTree/GenealogicalTree.tsx +++ b/src/tools/GenealogicalTree/GenealogicalTree.tsx @@ -3,17 +3,21 @@ import * as Reactodia from '@reactodia/workspace'; import * as Forms from '@reactodia/workspace/forms'; import * as N3 from 'n3'; +import { GenealogicalPropertyEditor } from './FormInputs'; import { GenealogicalDataProvider, GenealogicalLocaleProvider, findGenealogicalProvider, } from './GenealogicalDataProvider'; import { GenealogicalMetadataProvider } from './GenealogicalMetadataProvider'; import { GenealogicalPackage } from './GenealogicalPackage'; import { GenealogicalValidationProvider } from './GenealogicalValidationProvider'; -import { MarriageTemplate, ParentLinkTemplate, OtherLinkTemplate } from './GraphTemplates'; +import { + PersonTemplate, MarriageTemplate, + ParentLinkTemplate, HasPartnerLinkTemplate, RelatedToLinkTemplate, OtherLinkTemplate, +} from './GraphTemplates'; import { MainMenu } from './MainMenu'; import { OpenPackageSettings } from './OpenPackageSettings'; import { sh, getSinglePropertyValue, termAsString } from './OwlShaclSchema'; -import { genealogy, rdfs, schema } from './Vocabularies'; +import { genealogy, schema } from './Vocabularies'; import enTranslation from './translations/en.translation.json'; import GenealogicalSchemaTurtle from './GenealogicalSchema.ttl?raw'; @@ -87,9 +91,10 @@ export function ToolGenealogicalTree() { const [dataSource, setDataSource] = React.useState(); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { - const {model, editor, overlay, translation: t, getCommandBus, performLayout} = context; + const {model, view, editor, overlay, translation: t, getCommandBus, performLayout} = context; const metadataProvider = editor.metadataProvider as GenealogicalMetadataProvider; const validationProvider = editor.validationProvider as GenealogicalValidationProvider; + const renameLinkProvider = view.renameLinkProvider as RenameGenealogicalLinksProvider; metadataProvider.loadSchema({signal}); editor.setAuthoringState(Reactodia.AuthoringState.empty); @@ -133,6 +138,7 @@ export function ToolGenealogicalTree() { await metadataProvider.loadSettings({mainProvider, signal}); metadataProvider.updateSettings(editor.authoringState); validationProvider.setProvider(mainProvider); + renameLinkProvider.setWorkspace(workspace); const initialSettings = metadataProvider.getSettings(); const defaultLanguage = termAsString(getSinglePropertyValue( @@ -151,12 +157,12 @@ export function ToolGenealogicalTree() { await model.importLayout({ dataProvider, locale, - diagram: sourcePackage.diagram, + diagram: sourcePackage.diagrams.get('urn:reactodia:genealogical-package:diagram:main'), validateLinks: true, signal, }); - if (!sourcePackage.diagram) { + if (sourcePackage.diagrams.size === 0) { const task = overlay.startTask(); try { await Promise.all([schema.Person, genealogy.Marriage, schema.Place].map(async (type) => { @@ -208,7 +214,9 @@ export function ToolGenealogicalTree() { const updatedPackage = await mainProvider.sourcePackage.exportWith({ dataProvider: mainProvider, authoringState: mainProvider.cleanupAuthoring(authoringState), - diagram: model.exportLayout(), + diagrams: new Map([ + ['main', model.exportLayout()] + ]), uploader: mainProvider.uploader, }); setDataSource({bytes: await updatedPackage.bytes()}); @@ -219,13 +227,19 @@ export function ToolGenealogicalTree() { } canvas={{ elementTemplateResolver: types => { - if (types.includes(genealogy.Marriage)) { + if (types.includes(schema.Person)) { + return PersonTemplate; + } else if (types.includes(genealogy.Marriage)) { return MarriageTemplate; } }, linkTemplateResolver: type => { if (type === schema.parent) { return ParentLinkTemplate; + } else if (type === schema.relatedTo) { + return RelatedToLinkTemplate; + } else if (type === genealogy.hasPartner) { + return HasPartnerLinkTemplate; } else if (type) { return OtherLinkTemplate; } @@ -239,20 +253,7 @@ export function ToolGenealogicalTree() { { code: 'zh', label: '汉语' }, ]} visualAuthoring={{ - propertyEditor: options => ( - { - if (property === rdfs.comment) { - return ; - } else if (property === schema.gender) { - return ; - } else if (property === Reactodia.schema.thumbnailUrl) { - return ; - } - return ; - }} - /> - ), + propertyEditor: options => , }}> @@ -263,62 +264,66 @@ export function ToolGenealogicalTree() { } class RenameGenealogicalLinksProvider extends Reactodia.RenameLinkToLinkStateProvider { + private workspace: Reactodia.WorkspaceContext | undefined; + + setWorkspace(context: Reactodia.WorkspaceContext | undefined) { + this.workspace = context; + } + override canRename(link: Reactodia.Link): boolean { return ( link instanceof Reactodia.AnnotationLink || link.typeId === schema.relatedTo ); } -} - -function FormInputGender(props: Forms.InputSingleProps) { - const {factory} = props; - const {model} = Reactodia.useWorkspace(); - let provider: Reactodia.DataProvider | undefined; - if (model.dataProvider instanceof Reactodia.CompositeDataProvider) { - provider = model.dataProvider.providers - .find(p => p.origin?.value === genealogy.SchemaOrigin)?.provider; + getLabel(link: Reactodia.Link): string | undefined { + if (link instanceof Reactodia.RelationLink && link.data.linkTypeId === schema.relatedTo) { + if (!this.workspace) { + return undefined; + } + const {model, editor, translation: t} = this.workspace; + const event = editor.authoringState?.links.get(link.data); + const data = event?.data ?? link.data; + const labels = data.properties[Reactodia.rdfs.label]; + if (labels) { + const literals = labels.filter(v => v.termType === 'Literal'); + if (literals.length > 0) { + return t.formatLabel(literals, '', model.language); + } + } + } else { + return super.getLabel(link); + } } - const {data: entities} = Reactodia.useProvidedEntities(provider, [schema.Male, schema.Female]); - const language = Reactodia.useObservedProperty( - model.events, 'changeLanguage', () => model.language - ); - const variants = React.useMemo( - () => Array.from(entities.values(), (item): Forms.InputSelectVariant => ({ - value: factory.namedNode(item.id), - label: model.locale.formatEntityLabel(item, language), - })), - [entities, language, factory] - ); - - return ( - - ); -} - -function FormInputImage(props: Forms.InputMultiProps) { - const {values} = props; - const {model} = Reactodia.useWorkspace(); - const mainProvider = findGenealogicalProvider(model.dataProvider); - if (!mainProvider) { - throw new Error('Failed to find main provider'); + setLabel(link: Reactodia.Link, label: string): void { + if (link instanceof Reactodia.RelationLink && link.data.linkTypeId === schema.relatedTo) { + if (!this.workspace) { + return; + } + const {model, editor, translation: t} = this.workspace; + const event = editor.authoringState?.links.get(link.data); + const data = event?.data ?? link.data; + const labels = data.properties[Reactodia.rdfs.label] ?? []; + const previous = this.getLabel(link); + const current = t.selectLabel( + labels + .filter(v => v.value === previous) + .filter(v => v.termType === 'Literal'), + model.language + ); + editor.changeRelation(data, { + ...data, + properties: { + [Reactodia.rdfs.label]: [ + ...labels.filter(v => v !== current), + model.factory.literal(label, current?.language) + ] + } + }); + } else { + return super.setLabel(link, label); + } } - const {data: fileMetadata} = Reactodia.useProvidedEntities( - mainProvider, - values.filter(v => v.termType === 'NamedNode').map(v => v.value) - ); - return ( - /^image\//.test(item.type)} - /> - ); -} - -function MultilineTextInput(props: Forms.InputSingleProps) { - return ; } diff --git a/src/tools/GenealogicalTree/GraphTemplates.module.css b/src/tools/GenealogicalTree/GraphTemplates.module.css index 8ddfeedb..1413d3b2 100644 --- a/src/tools/GenealogicalTree/GraphTemplates.module.css +++ b/src/tools/GenealogicalTree/GraphTemplates.module.css @@ -9,6 +9,10 @@ text-align: center; } +.marriageEnded { + border-style: dashed; +} + .marriageInfo { position: absolute; bottom: 0; @@ -21,3 +25,19 @@ text-overflow: ellipsis; overflow: hidden; } + +.person { + display: contents; +} + +.personDeceased :global(.reactodia-standard-element__thumbnail) { + position: relative; +} + +.personDeceased :global(.reactodia-standard-element__thumbnail)::after { + content: '🪦'; + position: absolute; + font-size: 16px; + top: -8px; + left: -8px; +} diff --git a/src/tools/GenealogicalTree/GraphTemplates.tsx b/src/tools/GenealogicalTree/GraphTemplates.tsx index eab7c30a..a8633bfb 100644 --- a/src/tools/GenealogicalTree/GraphTemplates.tsx +++ b/src/tools/GenealogicalTree/GraphTemplates.tsx @@ -1,8 +1,33 @@ +import cx from 'clsx'; import * as React from 'react'; import * as Reactodia from '@reactodia/workspace'; import styles from './GraphTemplates.module.css'; -import { schema } from './Vocabularies'; +import { getSinglePropertyValue, termAsBoolean } from './OwlShaclSchema'; +import { genealogy, schema } from './Vocabularies'; + +export const PersonTemplate: Reactodia.ElementTemplate = { + ...Reactodia.StandardTemplate, + renderElement: props => ( + props.element instanceof Reactodia.EntityElement + ? + : null + ), +}; + +function PersonEntity(props: Reactodia.TemplateProps & { target: Reactodia.EntityElement }) { + const {target} = props; + const data = Reactodia.useObservedProperty(target.events, 'changeData', () => target.data); + const deathDate = getSinglePropertyValue(data, schema.deathDate); + const deceased = ( + Boolean(deathDate) || termAsBoolean(getSinglePropertyValue(data, genealogy.isDeceased)) + ); + return ( +
+ +
+ ); +} export const MarriageTemplate: Reactodia.ElementTemplate = { shape: 'ellipse', @@ -19,9 +44,10 @@ function MarriageEntity(props: Reactodia.TemplateProps & { target: Reactodia.Ent const data = Reactodia.useObservedProperty(target.events, 'changeData', () => target.data); const language = Reactodia.useObservedProperty(model.events, 'changeLanguage', () => model.language); const label = model.locale.formatEntityLabel(data, language); + const endDate = getSinglePropertyValue(data, schema.endDate); return ( <> -
+
{ + const {link} = props; + const relation = link instanceof Reactodia.RelationLink ? link : undefined; + const fromExMarriage = Reactodia.useSyncStore( + Reactodia.useEventStore(relation?.events, 'changeData'), + () => relation + ? termAsBoolean(getSinglePropertyValue(relation.data, genealogy.fromExMarriage)) + : undefined + ); + return ( + ( + propertyIri === 'urn:reactodia:sourceProvider' || + propertyIri === genealogy.fromExMarriage + ) ? null : undefined + } + /> + ); + }, +}; + +export const RelatedToLinkTemplate: Reactodia.LinkTemplate = { + ...Reactodia.StandardLinkTemplate, + renderLink: props => ( + ( + propertyIri === 'urn:reactodia:sourceProvider' || + propertyIri === Reactodia.rdfs.label + ) ? null : undefined + } + /> + ), +}; + export const OtherLinkTemplate: Reactodia.LinkTemplate = { ...Reactodia.StandardLinkTemplate, renderLink: props => ( void; onSave: () => Promise; }) { const {onOpen, onSave} = props; - const {model, overlay, translation: t} = Reactodia.useWorkspace(); + const {model, editor, overlay, translation: t} = Reactodia.useWorkspace(); return ( <> { - const batch = model.history.startBatch('Reset pinned properties'); + const batch = model.history.startBatch( + Reactodia.TranslatedText.text('genealogical_tree.reset_pinned_properties') + ); try { for (const element of model.elements) { - if (element instanceof Reactodia.EntityElement && element.data.types.includes(schema.Person)) { + if ( + element instanceof Reactodia.EntityElement && + element.data.types.includes(schema.Person) + ) { batch.history.execute(Reactodia.setElementState( element, element.elementState @@ -69,7 +74,46 @@ export function MainMenu(props: { batch.store(); } }}> - Reset pinned properties + {t.text('genealogical_tree.reset_pinned_properties')} + + { + const batch = model.history.startBatch( + Reactodia.TranslatedText.text('genealogical_tree.migrate_data_schema') + ); + try { + for (const link of model.links) { + if (link instanceof Reactodia.RelationLink) { + if (link.data.linkTypeId === schema.relatedTo) { + const customLabel = link.linkState.get( + Reactodia.TemplateProperties.CustomLabel + ); + if (customLabel) { + editor.changeRelation(link.data, { + ...link.data, + properties: { + ...link.data.properties, + [Reactodia.rdfs.label]: [model.factory.literal(customLabel)], + } + }); + batch.history.execute(Reactodia.setLinkState( + link, + link.linkState.set(Reactodia.TemplateProperties.CustomLabel, undefined) + )); + } + } else if (link.data.linkTypeId === schema.deathPlace) { + editor.changeRelation(link.data, { + ...link.data, + linkTypeId: genealogy.burialPlace, + }); + } + } + } + } finally { + batch.store(); + } + }}> + {t.text('genealogical_tree.migrate_data_schema')} diff --git a/src/tools/GenealogicalTree/OwlShaclSchema.ts b/src/tools/GenealogicalTree/OwlShaclSchema.ts index 402ee4b7..8276fad0 100644 --- a/src/tools/GenealogicalTree/OwlShaclSchema.ts +++ b/src/tools/GenealogicalTree/OwlShaclSchema.ts @@ -232,11 +232,11 @@ async function loadPropertyData( } export function getSinglePropertyValue( - element: ElementModel, + data: { readonly properties: Record> }, propertyIri: PropertyTypeIri ): Rdf.NamedNode | Rdf.Literal | undefined { - if (Object.hasOwn(element.properties, propertyIri)) { - const values = element.properties[propertyIri]; + if (Object.hasOwn(data.properties, propertyIri)) { + const values = data.properties[propertyIri]; if (values.length === 1) { return values[0]; } diff --git a/src/tools/GenealogicalTree/Vocabularies.ts b/src/tools/GenealogicalTree/Vocabularies.ts index 4770543f..34a21895 100644 --- a/src/tools/GenealogicalTree/Vocabularies.ts +++ b/src/tools/GenealogicalTree/Vocabularies.ts @@ -25,10 +25,13 @@ export const genealogy = vocabulary('http://reactodia.github.io/genealogy#', [ 'Marriage', 'SchemaOrigin', 'PackageSettings', + 'burialPlace', 'defaultLanguage', 'defaultNamespaceBase', 'hasGodparent', 'hasPartner', + 'isDeceased', + 'fromExMarriage', ]); export const rdfs = vocabulary(Reactodia.rdfs.$namespace, [ diff --git a/src/tools/GenealogicalTree/translations/en.translation.json b/src/tools/GenealogicalTree/translations/en.translation.json index 0cf7e2d7..9526435c 100644 --- a/src/tools/GenealogicalTree/translations/en.translation.json +++ b/src/tools/GenealogicalTree/translations/en.translation.json @@ -5,6 +5,8 @@ "action_open_settings": "Open package settings", "action_save_to_file": "Apply changes and save to file", "init_failed_to_load_package": "Failed to open genealogical package", + "migrate_data_schema": "Migrate data to latest schema", + "reset_pinned_properties": "Reset pinned properties", "task_loading_package": "Loading a package from file", "task_loading_package_failed": "Failed to load specified file.", "validation.marriage_partner_count": "Marriage should have at least two partners",