From 9cfbbb57011d26646c43f4d3d8fb914b435cd79f Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Mon, 30 Mar 2026 01:08:34 +0300 Subject: [PATCH] Add Genealogical Tree Editor tool: * Use OWL/SHACL-based metadata provider with `schema:` + `genealogy:` data namespaces; * Use `dash` spec to define relation shapes (`dash:reifiedBy`); * Serialize to a zip file (`GenealogicalPackage`) with file upload support; * Store and edit package settings as a (hidden) entity withing the data; * Use menu action to re-pin element properties (a workaround); * Add basic `ValidationProvider` to check marriage (>= 2 partners) and person (should have specified gender) entities; * Use workaround for type-based styling to style person elements based on gender; --- docusaurus.config.ts | 7 + package-lock.json | 12 + package.json | 1 + src/css/custom.css | 2 +- src/pages/tools/genealogical-tree-editor.tsx | 26 ++ src/tools/GenealogicalTree/ApplyRdfChanges.ts | 256 ++++++++++++++ .../GenealogicalDataProvider.ts | 110 ++++++ .../GenealogicalMetadataProvider.ts | 267 +++++++++++++++ .../GenealogicalTree/GenealogicalPackage.tsx | 240 +++++++++++++ .../GenealogicalTree/GenealogicalSchema.ttl | 293 ++++++++++++++++ .../GenealogicalTree.module.css | 15 + .../GenealogicalTree/GenealogicalTree.tsx | 318 ++++++++++++++++++ .../GenealogicalValidationProvider.ts | 59 ++++ .../GraphTemplates.module.css | 23 ++ src/tools/GenealogicalTree/GraphTemplates.tsx | 74 ++++ src/tools/GenealogicalTree/MainMenu.tsx | 80 +++++ .../OpenPackageSettings.module.css | 14 + .../GenealogicalTree/OpenPackageSettings.tsx | 36 ++ src/tools/GenealogicalTree/OwlShaclSchema.ts | 273 +++++++++++++++ src/tools/GenealogicalTree/Vocabularies.ts | 64 ++++ .../translations/en.translation.json | 13 + .../GenealogicalTree/translations/schema.json | 13 + src/typings.d.ts | 4 + 23 files changed, 2199 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/genealogical-tree-editor.tsx create mode 100644 src/tools/GenealogicalTree/ApplyRdfChanges.ts create mode 100644 src/tools/GenealogicalTree/GenealogicalDataProvider.ts create mode 100644 src/tools/GenealogicalTree/GenealogicalMetadataProvider.ts create mode 100644 src/tools/GenealogicalTree/GenealogicalPackage.tsx create mode 100644 src/tools/GenealogicalTree/GenealogicalSchema.ttl create mode 100644 src/tools/GenealogicalTree/GenealogicalTree.module.css create mode 100644 src/tools/GenealogicalTree/GenealogicalTree.tsx create mode 100644 src/tools/GenealogicalTree/GenealogicalValidationProvider.ts create mode 100644 src/tools/GenealogicalTree/GraphTemplates.module.css create mode 100644 src/tools/GenealogicalTree/GraphTemplates.tsx create mode 100644 src/tools/GenealogicalTree/MainMenu.tsx create mode 100644 src/tools/GenealogicalTree/OpenPackageSettings.module.css create mode 100644 src/tools/GenealogicalTree/OpenPackageSettings.tsx create mode 100644 src/tools/GenealogicalTree/OwlShaclSchema.ts create mode 100644 src/tools/GenealogicalTree/Vocabularies.ts create mode 100644 src/tools/GenealogicalTree/translations/en.translation.json create mode 100644 src/tools/GenealogicalTree/translations/schema.json create mode 100644 src/typings.d.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 659bc5ce..cb3db759 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -123,6 +123,13 @@ const config: Config = { {to: '/playground/classic-workspace', label: 'Classic Workspace'}, ] }, + { + label: 'Tools', + position: 'left', + items: [ + {to: '/tools/genealogical-tree-editor', label: 'Genealogical Tree Editor (α ver.)'}, + ] + }, { href: 'https://github.com/reactodia/reactodia-workspace', label: 'GitHub', diff --git a/package-lock.json b/package-lock.json index 242fba64..9fddfad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mdx-js/react": "^3.1.0", "@reactodia/hashmap": "^0.2.1", "@reactodia/workspace": "^0.34.1", + "@zip.js/zip.js": "^2.8.23", "clsx": "^2.1.1", "n3": "^1.17.2", "prism-react-renderer": "^2.4.1", @@ -5721,6 +5722,17 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.23.tgz", + "integrity": "sha512-RB+RLnxPJFPrGvQ9rgO+4JOcsob6lD32OcF0QE0yg24oeW9q8KnTTNlugcDaIveEcCbclobJcZP+fLQ++sH0bw==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/package.json b/package.json index f9ed4ed5..adad7aa4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@mdx-js/react": "^3.1.0", "@reactodia/hashmap": "^0.2.1", "@reactodia/workspace": "^0.34.1", + "@zip.js/zip.js": "^2.8.23", "clsx": "^2.1.1", "n3": "^1.17.2", "prism-react-renderer": "^2.4.1", diff --git a/src/css/custom.css b/src/css/custom.css index a6dca9fe..830b9aeb 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -30,7 +30,7 @@ --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } -/* Add alpha symbol after "Docs" link in the header */ +/* Add beta symbol after "Docs" link in the header */ a.navbar__item.navbar__link[href="/docs/"]::after, a.menu__link[href="/docs/"]::after { content: 'β'; diff --git a/src/pages/tools/genealogical-tree-editor.tsx b/src/pages/tools/genealogical-tree-editor.tsx new file mode 100644 index 00000000..e365353e --- /dev/null +++ b/src/pages/tools/genealogical-tree-editor.tsx @@ -0,0 +1,26 @@ +import BrowserOnly from '@docusaurus/BrowserOnly'; +import Layout from '@theme/Layout'; +import { InlineReactodia, InlineReactodiaHead } from '@site/src/components/InlineReactodia'; + +export default function Tool() { + return ( + <> + + + + {() => { + const {ToolGenealogicalTree} = require( + '@site/src/tools/GenealogicalTree/GenealogicalTree' + ) as typeof import('@site/src/tools/GenealogicalTree/GenealogicalTree'); + return ( + + + + ); + }} + + + + ); +} diff --git a/src/tools/GenealogicalTree/ApplyRdfChanges.ts b/src/tools/GenealogicalTree/ApplyRdfChanges.ts new file mode 100644 index 00000000..fd605558 --- /dev/null +++ b/src/tools/GenealogicalTree/ApplyRdfChanges.ts @@ -0,0 +1,256 @@ +import { HashSet } from '@reactodia/hashmap'; +import * as Reactodia from '@reactodia/workspace'; + +type EncodedTerm = + | Reactodia.ElementIri + | Reactodia.ElementTypeIri + | Reactodia.PropertyTypeIri + | Reactodia.LinkTypeIri; + +type DecodedTerm = Reactodia.Rdf.NamedNode | Reactodia.Rdf.BlankNode; + +// TODO: move into Reactodia +export function applyRdfChanges(params: { + initialDataset: Iterable; + authoringState: Reactodia.AuthoringState; + dataFactory: Reactodia.Rdf.DataFactory; + decodeTerm: (iri: EncodedTerm) => DecodedTerm; +}): Reactodia.MemoryDataset { + const {initialDataset, authoringState, dataFactory, decodeTerm} = params; + const dataset = Reactodia.indexedDataset( + Reactodia.IndexQuadBy.S | + Reactodia.IndexQuadBy.SP | + Reactodia.IndexQuadBy.O + ); + dataset.addAll(initialDataset); + + const toDelete = Reactodia.indexedDataset(Reactodia.IndexQuadBy.OnlyQuad); + const toInsert = Reactodia.indexedDataset(Reactodia.IndexQuadBy.OnlyQuad); + const updateDataset = () => { + for (const quad of toDelete) { + dataset.delete(quad); + } + dataset.addAll(toInsert); + toDelete.clear(); + toInsert.clear(); + }; + + const context: DatasetChangeContext = { + dataset, + toDelete, + toInsert, + updateDataset, + dataFactory, + decodeTerm, + }; + + processDeleteEvents(context, authoringState); + processAddChangeEvents(context, authoringState); + processEntityRenames(context, authoringState); + + return dataset; +} + +interface DatasetChangeContext { + readonly dataset: Reactodia.MemoryDataset; + readonly toDelete: Reactodia.MemoryDataset; + readonly toInsert: Reactodia.MemoryDataset; + readonly updateDataset: () => void; + + readonly dataFactory: Reactodia.Rdf.DataFactory; + readonly decodeTerm: (iri: EncodedTerm) => DecodedTerm; +} + +function processDeleteEvents(context: DatasetChangeContext, authoringState: Reactodia.AuthoringState): void { + const {dataset, toDelete, updateDataset, decodeTerm} = context; + + for (const change of authoringState.elements.values()) { + if (change.type === 'entityDelete') { + const iri = decodeTerm(change.data.id); + toDelete.addAll(dataset.iterateMatches(iri, null, null)); + toDelete.addAll(dataset.iterateMatches(null, null, iri)); + } + } + updateDataset(); + + for (const change of authoringState.links.values()) { + if (change.type === 'relationDelete') { + const subject = decodeTerm(change.data.sourceId); + const predicate = decodeTerm(change.data.linkTypeId); + const object = decodeTerm(change.data.targetId); + for (const quad of dataset.iterateMatches(subject, predicate, object)) { + toDelete.add(quad); + toDelete.addAll(dataset.iterateMatches(quad, null, null)); + toDelete.addAll(dataset.iterateMatches(null, null, quad)); + } + } + } + updateDataset(); +} + +function processAddChangeEvents(context: DatasetChangeContext, authoringState: Reactodia.AuthoringState): void { + const {dataset, toDelete, toInsert, updateDataset, dataFactory, decodeTerm} = context; + + const rdfType = dataFactory.namedNode(Reactodia.rdf.type); + const beforeSet = new HashSet( + Reactodia.Rdf.hashTerm, + Reactodia.Rdf.equalTerms + ); + const propertyContext: PropertyChangeContext = { + ...context, + beforeSet, + addedSet: beforeSet.clone(), + }; + + for (const change of authoringState.elements.values()) { + if (change.type === 'entityChange' || change.type === 'entityAdd') { + const before = change.type === 'entityChange' ? change.before : undefined; + const after = change.data; + const iri = decodeTerm(after.id); + + if (before) { + for (const type of before.types) { + if (!after.types.includes(type)) { + toDelete.addAll(dataset.iterateMatches(iri, rdfType, decodeTerm(type))); + } + } + } + + for (const type of after.types) { + if (!before || !before.types.includes(type)) { + toInsert.add(dataFactory.quad(iri, rdfType, decodeTerm(type))); + } + } + + processChangeProperties(propertyContext, iri, before?.properties ?? {}, after.properties); + } + } + updateDataset(); + + for (const change of authoringState.links.values()) { + if (change.type === 'relationChange' || change.type === 'relationAdd') { + const before = change.type === 'relationChange' ? change.before : undefined; + const subject = decodeTerm(change.data.sourceId); + const predicate = decodeTerm(change.data.linkTypeId); + const object = decodeTerm(change.data.targetId); + if (predicate.termType !== 'NamedNode') { + continue; + } + + const quads = before + ? Array.from(dataset.iterateMatches(subject, predicate, object)) + : []; + if (quads.length === 0) { + quads.push(dataFactory.quad(subject, predicate, object)); + } + + if (change.type === 'relationAdd') { + toInsert.addAll(quads); + } + + for (const quad of quads) { + processChangeProperties( + propertyContext, + quad, + before?.properties ?? {}, + change.data.properties + ); + } + } + } + updateDataset(); +} + +interface PropertyChangeContext extends DatasetChangeContext { + readonly beforeSet: HashSet; + readonly addedSet: HashSet; +} + +function processChangeProperties( + context: PropertyChangeContext, + subject: Reactodia.Rdf.NamedNode | Reactodia.Rdf.BlankNode | Reactodia.Rdf.Quad, + from: { readonly [id: string]: readonly (Reactodia.Rdf.NamedNode | Reactodia.Rdf.Literal)[] }, + to: { readonly [id: string]: readonly (Reactodia.Rdf.NamedNode | Reactodia.Rdf.Literal)[] }, +): void { + const {dataset, toInsert, toDelete, dataFactory, decodeTerm, beforeSet, addedSet} = context; + + for (const property of Object.keys(from)) { + if (!Object.prototype.hasOwnProperty.call(to, property)) { + const predicate = decodeTerm(property); + toDelete.addAll(dataset.iterateMatches(subject, predicate, null)); + } + } + + for (const [property, toValues] of Object.entries(to)) { + const predicate = decodeTerm(property); + if (predicate.termType !== 'NamedNode') { + continue; + } + + if (Object.prototype.hasOwnProperty.call(from, property)) { + for (const value of from[property]) { + beforeSet.add(value); + } + } + + for (const value of toValues) { + addedSet.add(value); + if (!beforeSet.has(value)) { + toInsert.add(dataFactory.quad(subject, predicate, value)); + } + } + + for (const value of beforeSet) { + if (!addedSet.has(value)) { + toDelete.addAll(dataset.iterateMatches(subject, predicate, value)); + } + } + + beforeSet.clear(); + addedSet.clear(); + } +}; + +function processEntityRenames(context: DatasetChangeContext, authoringState: Reactodia.AuthoringState): void { + const {dataset, toDelete, toInsert, updateDataset, dataFactory, decodeTerm} = context; + + for (const change of authoringState.elements.values()) { + if (change.type === 'entityChange' && change.newIri) { + const from = decodeTerm(change.data.id); + const to = decodeTerm(change.newIri); + + for (const fromQuad of dataset.iterateMatches(from, null, null)) { + toDelete.add(fromQuad); + const toQuad = dataFactory.quad(to, fromQuad.predicate, fromQuad.object, fromQuad.graph); + toInsert.add(toQuad); + renameIndirectQuads(context, fromQuad, toQuad); + } + + for (const fromQuad of dataset.iterateMatches(null, null, from)) { + toDelete.add(fromQuad); + const toQuad = dataFactory.quad(fromQuad.subject, fromQuad.predicate, to, fromQuad.graph); + toInsert.add(toQuad); + renameIndirectQuads(context, fromQuad, toQuad); + } + } + } + updateDataset(); +} + +function renameIndirectQuads( + context: DatasetChangeContext, + fromQuad: Reactodia.Rdf.Quad, + toQuad: Reactodia.Rdf.Quad +): void { + const {dataset, toDelete, toInsert, dataFactory} = context; + + for (const indirect of dataset.iterateMatches(fromQuad, null, null)) { + toDelete.add(indirect); + toInsert.add(dataFactory.quad(toQuad, indirect.predicate, indirect.object, indirect.graph)); + } + + for (const indirect of dataset.iterateMatches(null, null, fromQuad)) { + toDelete.add(indirect); + toInsert.add(dataFactory.quad(indirect.subject, indirect.predicate, fromQuad, indirect.graph)); + } +}; diff --git a/src/tools/GenealogicalTree/GenealogicalDataProvider.ts b/src/tools/GenealogicalTree/GenealogicalDataProvider.ts new file mode 100644 index 00000000..42bd0b14 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalDataProvider.ts @@ -0,0 +1,110 @@ +import * as Reactodia from '@reactodia/workspace'; +import * as Forms from '@reactodia/workspace/forms'; + +import { GenealogicalPackage } from './GenealogicalPackage'; +import { schema } from './Vocabularies'; + +export class GenealogicalDataProvider extends Reactodia.RdfDataProvider { + readonly uploader: Forms.MemoryFileUploader; + + private _dataset = Reactodia.indexedDataset( + Reactodia.IndexQuadBy.SP + ); + + constructor( + readonly sourcePackage: GenealogicalPackage, + options: { + uploader: Forms.MemoryFileUploader; + signal: AbortSignal; + } + ) { + super({ + datatypePredicates: [schema.gender, Reactodia.schema.thumbnailUrl], + }); + const {uploader} = options; + this.uploader = uploader; + this.addGraph(sourcePackage.graph); + this._dataset.addAll(sourcePackage.graph); + } + + async elements(params: { + elementIds: ReadonlyArray; + signal?: AbortSignal; + }): Promise> { + const result = await super.elements(params); + return new Map( + Array.from(result).map(([iri, entity]) => [iri, this.transformEntity(entity)]) + ); + } + + async lookup(params: Reactodia.DataProviderLookupParams): Promise { + const result = await super.lookup(params); + return result.map(item => ({ + ...item, + element: this.transformEntity(item.element), + })); + } + + private transformEntity(entity: Reactodia.ElementModel): Reactodia.ElementModel { + if (entity.types.includes(schema.Person)) { + const genderValues = Object.hasOwn(entity.properties, schema.gender) + ? entity.properties[schema.gender] : undefined; + const gender = genderValues && genderValues.length === 1 ? genderValues[0] : undefined; + if (gender && gender.termType === 'NamedNode') { + if (gender.value === schema.Male) { + const types = [...entity.types, schema.Male].sort(); + return {...entity, types}; + } else if (gender.value === schema.Female) { + const types = [...entity.types, schema.Female].sort(); + return {...entity, types}; + } + } + } + return entity; + } + + cleanupAuthoring(state: Reactodia.AuthoringState): Reactodia.AuthoringState { + return state; + } +} + +export class GenealogicalLocaleProvider extends Reactodia.DefaultDataLocaleProvider { + private readonly uploader: Forms.MemoryFileUploader; + private readonly package: GenealogicalPackage; + + constructor(options: Reactodia.DefaultDataLocaleProviderOptions & { + uploader: Forms.MemoryFileUploader; + package: GenealogicalPackage; + }) { + super(options); + this.uploader = options.uploader; + this.package = options.package; + } + + override selectEntityImageUrl(entity: Reactodia.ElementModel): string | undefined { + return entity.types.includes(Forms.FileType) + ? entity.id : super.selectEntityImageUrl(entity); + } + + async resolveAssetUrl(assetIri: string, options: { signal?: AbortSignal; }): Promise { + const {signal} = options; + const [uploadUrl, packageUrl] = await Promise.all([ + this.uploader.resolveFileUrl(assetIri, {signal}), + this.package.resolveFileUrl(assetIri, {signal}), + ]); + return uploadUrl ?? packageUrl ?? assetIri; + } +} + +export function findGenealogicalProvider(rootProvider: Reactodia.DataProvider) { + if (!(rootProvider instanceof Reactodia.CompositeDataProvider)) { + throw new Error('Cannot find composite data provider'); + } + const mainProvider = rootProvider.providers + .map(p => p.provider) + .find(p => p instanceof GenealogicalDataProvider); + if (!mainProvider) { + throw new Error('Cannot find main data provider'); + } + return mainProvider; +} diff --git a/src/tools/GenealogicalTree/GenealogicalMetadataProvider.ts b/src/tools/GenealogicalTree/GenealogicalMetadataProvider.ts new file mode 100644 index 00000000..c3463b63 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalMetadataProvider.ts @@ -0,0 +1,267 @@ +import * as Reactodia from '@reactodia/workspace'; + +import { + loadOwlShaclSchema, type OwlShaclSchema, type ShaclShape, sh, + getSinglePropertyValue, termAsString, +} from './OwlShaclSchema'; +import { genealogy, schema } from './Vocabularies'; + +export class GenealogicalMetadataProvider extends Reactodia.BaseMetadataProvider { + private readonly literalLanguages: ReadonlyArray = + ['de', 'en', 'es', 'ru', 'zh']; + + private readonly schemaProvider; + private loadedSchema: Promise = Promise.reject( + new Error('OWL-SHACL schema should be loaded first') + ); + + private readonly defaultNamespaceBase: string; + private readonly defaultSubjectTemplate = `{{hex:8}}`; + private loadedSettings = + Reactodia.EntityElement.placeholderData(genealogy.ActiveSettings); + private unsavedSettings: Reactodia.ElementModel | undefined; + + constructor(providerOptions: { + schemaProvider: Reactodia.DataProvider; + defaultNamespaceBase: string; + }) { + const {schemaProvider, defaultNamespaceBase} = providerOptions; + const {factory} = schemaProvider; + super({ + getLiteralLanguages: () => this.literalLanguages, + createEntity: async (type, {translation: t, language, signal}) => { + const shacl = await this.getSchema(); + const shapes = shacl.shapes.get(type); + const subjectTemplate = shapes?.find(shape => shape.subjectTemplate)?.subjectTemplate + ?? this.defaultSubjectTemplate; + + const typeInfo = await schemaProvider.elementTypes({classIds: [type], signal}); + const typeLabel = t.selectLabel(typeInfo.get(type)?.label ?? [], language); + + let elementState = Reactodia.TemplateState.empty; + if (type === schema.Person) { + elementState = elementState.set(Reactodia.TemplateProperties.PinnedProperties, { + [schema.birthDate]: true, + [schema.deathDate]: true, + }); + } + + const settings = this.getSettings(); + const namespaceBase = termAsString( + getSinglePropertyValue(settings, genealogy.defaultNamespaceBase) + ) ?? this.defaultNamespaceBase; + + return { + data: { + id: `${namespaceBase}${generateSubject(subjectTemplate)}`, + types: [type], + properties: { + [Reactodia.rdfs.label]: [ + typeLabel ?? factory.literal(Reactodia.Rdf.getLocalName(type) ?? 'Entity'), + ], + }, + }, + elementState, + }; + }, + canConnect: async (source, target, linkType, options) => { + const shacl = await this.getSchema(); + + const linksToTarget = new Map; + outLinks: Set; + }>(); + const getLinks = (target: Reactodia.ElementTypeIri) => { + let links = linksToTarget.get(target); + if (!links) { + links = { + inLinks: new Set(), + outLinks: new Set(), + }; + linksToTarget.set(target, links); + } + return links; + }; + + for (const [domain, shapes] of shacl.shapes) { + const fromDomain = source.types.includes(domain as Reactodia.ElementTypeIri); + for (const shape of shapes) { + for (const property of shape.properties) { + if (property.class_ && (!linkType || property.path === linkType)) { + const fromRange = (!target || target.types.includes(domain as Reactodia.ElementTypeIri)); + if (fromRange && source.types.includes(property.class_.value)) { + getLinks(domain as Reactodia.ElementTypeIri).inLinks.add(property.path); + } + if (fromDomain && (!target || target.types.includes(property.class_.value))) { + getLinks(property.class_.value).outLinks.add(property.path); + } + } + } + } + } + + const connections: Reactodia.MetadataCanConnect[] = []; + for (const [targetType, links] of linksToTarget) { + connections.push({ + targetTypes: new Set([targetType]), + inLinks: Array.from(links.inLinks), + outLinks: Array.from(links.outLinks), + }); + } + + return connections; + }, + canModifyEntity: async (entity, options) => { + const shacl = await this.getSchema(); + for (const type of entity.types) { + const shapes = shacl.shapes.get(type); + if (shapes) { + const canCreate = shapes.every(shape => shape.canCreate ?? true); + const canDelete = shapes.every(shape => shape.canDelete ?? true); + return { + canChangeIri: canCreate && canDelete, + canEdit: true, + canDelete: canDelete, + }; + } + } + return {}; + }, + canModifyRelation: async (link, source, target, options) => { + const shacl = await this.getSchema(); + for (const type of source.types) { + const shapes = shacl.shapes.get(type); + if (shapes) { + for (const shape of shapes) { + if (shape.properties.some(property => property.path === link.linkTypeId)) { + return { + canChangeType: true, + canEdit: true, + canDelete: true, + }; + } + } + } + } + return {}; + }, + getEntityShape: async (types, options) => { + const shacl = await this.getSchema(); + const properties = new Map(); + for (const type of types) { + const shapes = shacl.shapes.get(type) ?? []; + this.collectPropertyShapes(properties, shapes); + } + return {properties}; + }, + getRelationShape: async (linkType, source, target, {signal}) => { + const shacl = await this.getSchema(); + const properties = new Map(); + for (const type of source.types) { + const typeShapes = shacl.shapes.get(type) ?? []; + for (const typeShape of typeShapes) { + for (const property of typeShape.properties) { + if (property.path === linkType && property.reifiableBy) { + this.collectPropertyShapes(properties, property.reifiableBy); + } + } + } + } + return {properties}; + }, + filterConstructibleTypes: async (types, {signal}) => { + const shacl = await this.getSchema(); + return new Set(Array.from(types).filter(type => { + const shapes = shacl.shapes.get(type); + return shapes && shapes.every(shape => shape.canCreate ?? true); + })); + }, + }); + this.schemaProvider = schemaProvider; + this.defaultNamespaceBase = defaultNamespaceBase; + } + + private async getSchema(): Promise { + return this.loadedSchema; + } + + loadSchema(params: { signal: AbortSignal }) { + this.loadedSchema.catch(() => {/* Silence initial rejected or cancelled task */}); + this.loadedSchema = loadOwlShaclSchema({ + schemaProvider: this.schemaProvider, + signal: params.signal, + }); + } + + getSettings(): Reactodia.ElementModel { + return this.unsavedSettings ?? this.loadedSettings; + } + + async loadSettings(params: { + mainProvider: Reactodia.DataProvider; + signal: AbortSignal; + }): Promise { + const {mainProvider, signal} = params; + const elements = await mainProvider.elements({ + elementIds: [genealogy.ActiveSettings], + signal, + }); + const settings = elements.get(genealogy.ActiveSettings); + if (settings) { + this.loadedSettings = settings; + } + } + + updateSettings(fromState: Reactodia.AuthoringState): void { + const event = fromState.elements.get(genealogy.ActiveSettings); + this.unsavedSettings = event && event.type !== 'entityDelete' ? event.data : undefined; + } + + private collectPropertyShapes( + properties: Map, + shapes: Iterable + ): void { + for (const shape of shapes) { + for (const property of shape.properties) { + if (property.datatype || !property.class_) { + properties.set(property.path, { + valueShape: property.nodeKind?.value === sh.IRI || property.nodeKind?.value === sh.BlankNodeOrIRI + ? { + termType: 'NamedNode', + defaultValue: property.defaultValue?.termType === 'NamedNode' + ? property.defaultValue : undefined, + } + : { + termType: 'Literal', + datatype: property.datatype, + uniqueLang: property.uniqueLang, + defaultValue: property.defaultValue?.termType === 'Literal' + ? property.defaultValue : undefined, + }, + minCount: property.minCount, + maxCount: property.maxCount, + order: property.order, + }); + } + } + } + } +} + +function generateSubject(template: string): Reactodia.ElementIri { + return template.replace(/{{([^}]*)}}/g, (_, placeholder: string) => { + let hexMatch = /^hex(:[0-9]+)$/.exec(placeholder); + if (hexMatch) { + const digitCount = Number(hexMatch[1].substring(1)); + if (Number.isFinite(digitCount) && digitCount > 0 && digitCount <= 12) { + return randomHex(digitCount); + } + } + throw new Error(`Unknown subject template placeholder: ${placeholder}`); + }); +} + +function randomHex(digitCount: number): string { + return Math.floor((1 + Math.random()) * Math.pow(16, digitCount)) + .toString(16).substring(1); +} diff --git a/src/tools/GenealogicalTree/GenealogicalPackage.tsx b/src/tools/GenealogicalTree/GenealogicalPackage.tsx new file mode 100644 index 00000000..38e3bac8 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalPackage.tsx @@ -0,0 +1,240 @@ +import { HashMap } from '@reactodia/hashmap'; +import * as Reactodia from '@reactodia/workspace'; +import * as Forms from '@reactodia/workspace/forms'; +import * as zip from '@zip.js/zip.js'; +import * as N3 from 'n3'; + +import { applyRdfChanges } from './ApplyRdfChanges'; +import { genealogy, rdfs, schema, xsd } from './Vocabularies'; + +export class GenealogicalPackage { + private static readonly FILE_IRI_PREFIX = 'urn:reactodia:genealogical-package:file:'; + + static readonly DEFAULT_NAMESPACE_BASE = 'http://reactodia.github.io/genealogy-graph/'; + + private readonly imageNameToUrl = new Map(); + + private constructor( + readonly diagram: Reactodia.SerializedDiagram | undefined, + readonly graph: readonly Reactodia.Rdf.Quad[], + private readonly prefixes: Readonly>, + private readonly entries: zip.Entry[], + private readonly signal: AbortSignal, + ) { + signal.throwIfAborted(); + signal.addEventListener('abort', () => { + for (const imageUrl of this.imageNameToUrl.values()) { + URL.revokeObjectURL(imageUrl); + } + }); + } + + static createEmpty(): GenealogicalPackage { + const factory = Reactodia.Rdf.DefaultDataFactory; + const controller = new AbortController(); + const graph: Reactodia.Rdf.Quad[] = [ + factory.quad( + factory.namedNode(genealogy.ActiveSettings), + factory.namedNode(Reactodia.rdf.type), + factory.namedNode(genealogy.PackageSettings), + ), + factory.quad( + factory.namedNode(genealogy.ActiveSettings), + factory.namedNode(genealogy.defaultNamespaceBase), + factory.literal(GenealogicalPackage.DEFAULT_NAMESPACE_BASE), + ), + ]; + return new GenealogicalPackage(undefined, graph, {}, [], controller.signal); + } + + static async loadFromBytes(bytes: Uint8Array, options: { signal: AbortSignal }): Promise { + const {signal} = options; + signal.throwIfAborted(); + + const reader = new zip.ZipReader(new zip.Uint8ArrayReader(bytes)); + 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}); + } + } + + let graph: Reactodia.Rdf.Quad[] = []; + let prefixes: Record = Object.create(null); + if (graphEntry) { + const graphTurtle = await graphEntry.getData(new zip.TextWriter()); + try { + graph = new N3.Parser().parse(graphTurtle, null, (prefix, node) => { + prefixes[prefix] = node.value; + }); + } catch (err) { + throw new Error('Failed to parse serialized graph "graph.ttl"', {cause: err}); + } + } + + return new GenealogicalPackage(diagram, graph, prefixes, entries, signal); + } + + async resolveFileUrl(iri: string, options: { signal?: AbortSignal }): Promise { + this.signal.throwIfAborted(); + if (!iri.startsWith(GenealogicalPackage.FILE_IRI_PREFIX)) { + return undefined; + } + const fileName = iri.substring(GenealogicalPackage.FILE_IRI_PREFIX.length); + const cachedUrl = this.imageNameToUrl.get(fileName); + if (cachedUrl) { + return cachedUrl; + } + const entry = this.entries.find((e): e is zip.FileEntry => !e.directory && e.filename === `files/${fileName}`); + if (entry) { + const imageBlob = await entry.getData(new zip.BlobWriter(), {signal: options.signal}); + const imageUrl = URL.createObjectURL(imageBlob); + this.imageNameToUrl.set(fileName, imageUrl); + return imageUrl; + } + return undefined; + } + + async exportWith(params: { + dataProvider: Reactodia.RdfDataProvider; + authoringState: Reactodia.AuthoringState; + diagram: Reactodia.SerializedDiagram | undefined; + uploader?: Forms.MemoryFileUploader; + }): Promise { + const {dataProvider, authoringState: baseState, diagram, uploader} = params; + const factory = Reactodia.Rdf.DefaultDataFactory; + + let authoringState = baseState; + const termReplacements = new HashMap( + Reactodia.Rdf.hashTerm, + Reactodia.Rdf.equalTerms + ); + + if (uploader) { + for (const file of uploader.files()) { + authoringState = Reactodia.AuthoringState.addEntity(authoringState, file.metadata); + termReplacements.set( + factory.namedNode(file.metadata.id), + factory.namedNode(GenealogicalPackage.FILE_IRI_PREFIX + file.name) + ); + } + } + + const dataset = applyRdfChanges({ + initialDataset: this.graph, + authoringState, + dataFactory: dataProvider.factory, + decodeTerm: iri => dataProvider.decodeTerm(iri), + }); + + // Track referenced file content IRIs + const referencedFiles = new Set(); + const tryAddFileReference = (term: Reactodia.Rdf.Term) => { + if (term.termType === 'NamedNode' && term.value.startsWith(GenealogicalPackage.FILE_IRI_PREFIX)) { + const fileName = term.value.substring(GenealogicalPackage.FILE_IRI_PREFIX.length); + referencedFiles.add(fileName); + } + }; + + const quads = Array.from(dataset, q => { + const nextSubject = termReplacements.get(q.subject); + const nextObject = termReplacements.get(q.object); + tryAddFileReference(nextSubject ?? q.subject); + tryAddFileReference(nextObject ?? q.object); + if (nextSubject || nextObject) { + return factory.quad(nextSubject ?? q.subject, q.predicate, nextObject ?? q.object, q.graph); + } + return q; + }); + quads.sort(Reactodia.Rdf.compareTerms); + + let defaultNamespace = GenealogicalPackage.DEFAULT_NAMESPACE_BASE; + for (const {object: term} of dataset.iterateMatches( + factory.namedNode(genealogy.ActiveSettings), + factory.namedNode(genealogy.defaultNamespaceBase), + null + )) { + defaultNamespace = term.value; + break; + } + const graphTurtle = await serializeToTurtleString(quads, { + ...this.prefixes, + 'genealogy': genealogy.$namespace, + 'rdfs': rdfs.$namespace, + 'schema': schema.$namespace, + 'xsd': xsd.$namespace, + '': defaultNamespace, + }); + + const fileRenames = new Map(); + for (const event of authoringState.elements.values()) { + if ( + event.type === 'entityChange' && event.newIri && + event.before.id.startsWith(GenealogicalPackage.FILE_IRI_PREFIX) && + event.newIri.startsWith(GenealogicalPackage.FILE_IRI_PREFIX) + ) { + fileRenames.set( + event.before.id.substring(GenealogicalPackage.FILE_IRI_PREFIX.length), + event.newIri.substring(GenealogicalPackage.FILE_IRI_PREFIX.length) + ); + } + } + + const zipWriter = new zip.ZipWriter(new zip.BlobWriter("application/zip")); + + if (diagram) { + await zipWriter.add('diagram.json', new zip.TextReader(JSON.stringify(diagram))); + } + + await zipWriter.add('graph.ttl', new zip.TextReader(graphTurtle)); + + for (const entry of this.entries) { + if (!entry.directory && entry.filename.startsWith('files/')) { + const fileName = entry.filename.substring('files/'.length); + const newName = fileRenames.get(fileName) ?? fileName; + if (referencedFiles.has(newName)) { + zipWriter.add(`files/${newName}`, new zip.Uint8ArrayReader(new Uint8Array( + await (entry as zip.FileEntry).arrayBuffer() + ))); + } + } + } + + if (uploader) { + for (const file of uploader.files()) { + if (referencedFiles.has(file.name)) { + zipWriter.add(`files/${file.name}`, new zip.BlobReader(file.blob)); + } + } + } + + return await zipWriter.close(); + } +} + +function serializeToTurtleString( + quads: readonly Reactodia.Rdf.Quad[], + prefixes: Record +): Promise { + return new Promise((resolve, reject) => { + const turtleWriter = new N3.Writer(); + turtleWriter.addPrefixes(prefixes); + turtleWriter.addQuads(quads as N3.Quad[]); + turtleWriter.end((err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); +} diff --git a/src/tools/GenealogicalTree/GenealogicalSchema.ttl b/src/tools/GenealogicalTree/GenealogicalSchema.ttl new file mode 100644 index 00000000..d9ec7503 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalSchema.ttl @@ -0,0 +1,293 @@ +@prefix dash: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix reactodia: . +@prefix r_shapes: . +@prefix sh: . +@prefix schema: . +@prefix xsd: . +@prefix : . + +# Genealogical Ontology is based on Family History Knowledge Base (FHKB): +# https://oboacademy.github.io/obook/tutorial/fhkb/ + +:Marriage a owl:Class; + rdfs:label "Marriage"@en, "Брак"@ru . + +:hasGodparent a owl:ObjectProperty; + rdfs:label "has godparent"@en, "имеет крёстного(ую)"@ru . + +:hasPartner a owl:ObjectProperty; + rdfs:label "has marriage partner"@en, "супруг(а)"@ru . + +rdfs:comment a owl:DatatypeProperty; + rdfs:label "comment"@en, "комментарий"@ru . + +rdfs:label a owl:DatatypeProperty; + rdfs:label "label"@en, "имя/название"@ru . + +schema:Female rdfs:label "female"@en, "женщина"@ru . + +schema:Male rdfs:label "male"@en, "мужчина"@ru . + +schema:Person a owl:Class; + rdfs:label "Person"@en, "Человек"@ru . + +schema:Place a owl:Class; + rdfs:label "Place"@en, "Место"@ru . + +schema:address a owl:DatatypeProperty; + rdfs:label "address"@en, "адрес"@ru . + +schema:birthDate a owl:DatatypeProperty; + rdfs:label "birth date"@en, "дата рождения"@ru . + +schema:birthPlace a owl:ObjectProperty; + rdfs:label "birth place"@en, "место рождения"@ru . + +schema:deathDate a owl:DatatypeProperty; + rdfs:label "death date"@en, "дата смерти"@ru . + +schema:deathPlace a owl:ObjectProperty; + rdfs:label "death place"@en, "место смерти"@ru . + +schema:encodingFormat a owl:DatatypeProperty; + rdfs:label "file type"@en, "тип файла"@ru . + +schema:endDate a owl:DatatypeProperty; + rdfs:label "end date"@en, "дата окончания"@ru . + +schema:fileSize a owl:DatatypeProperty; + rdfs:label "file size"@en, "размер файла"@ru . + +schema:gender a owl:ObjectProperty; + rdfs:label "gender"@en, "пол"@ru . + +schema:homeLocation a owl:ObjectProperty; + rdfs:label "home location"@en, "место жительства"@ru . + +schema:latitude a owl:DatatypeProperty; + rdfs:label "latitude"@en, "широта"@ru . + +schema:longitude a owl:DatatypeProperty; + rdfs:label "longitude"@en, "долгота"@ru . + +schema:parent a owl:ObjectProperty; + rdfs:label "has parent"@en, "имеет родителя"@ru . + +schema:relatedTo a owl:ObjectProperty; + rdfs:label "related to"@en, "связан с"@ru . + +schema:startDate a owl:DatatypeProperty; + rdfs:label "start date"@en, "дата начала"@ru . + +schema:thumbnailUrl a owl:DatatypeProperty; + rdfs:label "main image"@en, "основное изображение"@ru . + +schema:uploadDate a owl:DatatypeProperty; + rdfs:label "upload date"@en, "дата загрузки"@ru . + +reactodia:File a owl:Class; + rdfs:label "File"@en, "Файл"@ru . + +reactodia:sourceProvider a owl:DatatypeProperty; + rdfs:label "source"@en, "источник"@ru . + +:DataOrigin rdfs:label "user data"@en, "данные пользователя"@ru . +:SchemaOrigin rdfs:label "schema"@en, "схема"@ru . + +:PackageSettings a owl:Class ; + rdfs:label "Package Settings"@en, "Настройки пакета"@ru . + +:defaultLanguage a owl:DatatypeProperty ; + rdfs:label "default language"@en, "язык по-умолчанию"@ru . + +:defaultNamespaceBase a owl:DatatypeProperty ; + rdfs:label "default namespace"@en, "пространстро имён по-умолчанию"@ru . + +:MarriageShape a sh:NodeShape ; + sh:targetClass :Marriage ; + r_shapes:subjectTemplate "marriage_{{hex:8}}" ; + sh:property [ + sh:path rdfs:label ; + sh:order 1 ; + ] ; + sh:property [ + sh:path schema:startDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 2 ; + ] ; + sh:property [ + sh:path schema:endDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 3 ; + ] ; + sh:property [ + sh:path rdfs:comment ; + sh:order 4 ; + ] ; + sh:property [ + sh:path :hasPartner ; + sh:class schema:Person ; + sh:nodeKind sh:IRI ; + sh:minCount 2 ; + dash:reifiableBy :commonPropertyShape ; + ] . + +:PersonShape a sh:NodeShape ; + sh:targetClass schema:Person ; + r_shapes:subjectTemplate "person_{{hex:8}}" ; + sh:property [ + sh:path rdfs:label ; + sh:order 1 ; + ] ; + sh:property [ + sh:path schema:gender ; + sh:maxCount 1 ; + sh:nodeKind sh:IRI ; + sh:order 2 ; + ] ; + sh:property [ + sh:path schema:birthDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 3 ; + ] ; + sh:property [ + sh:path schema:deathDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 4 ; + ] ; + sh:property [ + sh:path schema:thumbnailUrl ; + sh:maxCount 1 ; + sh:order 5 ; + ] ; + sh:property [ + sh:path rdfs:comment ; + sh:order 6 ; + ] ; + sh:property [ + sh:path schema:parent ; + sh:class schema:Person ; + sh:nodeKind sh:IRI ; + dash:reifiableBy :commonPropertyShape ; + ] ; + sh:property [ + sh:path :hasGodparent ; + sh:class schema:Person ; + sh:nodeKind sh:IRI ; + dash:reifiableBy :commonPropertyShape ; + ] ; + sh:property [ + sh:path schema:relatedTo ; + sh:class schema:Person ; + sh:nodeKind sh:IRI ; + dash:reifiableBy :commonPropertyShape ; + ] ; + sh:property [ + sh:path schema:birthPlace ; + sh:class schema:Place ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + dash:reifiableBy :commonPropertyShape ; + ] ; + sh:property [ + sh:path schema:deathPlace ; + sh:class schema:Place ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + dash:reifiableBy :commonPropertyShape ; + ] ; + sh:property [ + sh:path schema:homeLocation ; + sh:class schema:Place ; + sh:nodeKind sh:IRI ; + dash:reifiableBy :homeLocationShape ; + ] . + +:PlaceShape a sh:NodeShape ; + sh:targetClass schema:Place ; + r_shapes:subjectTemplate "place_{{hex:8}}" ; + sh:property [ + sh:path rdfs:label ; + sh:order 1 ; + ] ; + sh:property [ + sh:path schema:address ; + sh:datatype xsd:string ; + sh:order 2 ; + ] ; + sh:property [ + sh:path schema:latitude ; + sh:datatype xsd:decimal ; + sh:maxCount 1 ; + sh:order 3 ; + ] ; + sh:property [ + sh:path schema:longitude ; + sh:datatype xsd:decimal ; + sh:maxCount 1 ; + sh:order 4 ; + ] ; + sh:property [ + sh:path schema:thumbnailUrl ; + sh:order 5 ; + ] ; + sh:property [ + sh:path rdfs:comment ; + sh:order 6 ; + ] . + +:FileShape a sh:NodeShape ; + sh:targetClass reactodia:File ; + r_shapes:canCreate false ; + sh:property [ + sh:path rdfs:label ; + sh:order 1 ; + ] . + +:PackageSettingsShape a sh:NodeShape ; + sh:targetClass :PackageSettings ; + r_shapes:canCreate false ; + r_shapes:canDelete false ; + sh:property [ + sh:path :defaultNamespaceBase ; + sh:datatype xsd:string ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:order 1 ; + ] ; + sh:property [ + sh:path :defaultLanguage ; + sh:datatype xsd:string ; + sh:maxCount 1 ; + sh:order 2 ; + sh:defaultValue "en" ; + ] . + +:commonPropertyShape a sh:NodeShape ; + sh:property [ + sh:path rdfs:comment ; + ] . + +:homeLocationShape a sh:NodeShape ; + sh:property [ + sh:path rdfs:comment ; + ] ; + sh:property [ + sh:path schema:startDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 1 ; + ] ; + sh:property [ + sh:path schema:endDate ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:order 2 ; + ] . diff --git a/src/tools/GenealogicalTree/GenealogicalTree.module.css b/src/tools/GenealogicalTree/GenealogicalTree.module.css new file mode 100644 index 00000000..e614d077 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalTree.module.css @@ -0,0 +1,15 @@ +.workspace { + /* Reactodia workspace */ +} + +.workspace :global(.reactodia-standard-element)[class] { + min-width: 210px; +} + +.workspace :global(.reactodia-standard-element__label)[class] { + white-space: unset; +} + +.workspace :global(.reactodia-standard-element__pinned-props) :global(.reactodia-standard-element__properties) { + overflow-y: visible; +} diff --git a/src/tools/GenealogicalTree/GenealogicalTree.tsx b/src/tools/GenealogicalTree/GenealogicalTree.tsx new file mode 100644 index 00000000..158d0280 --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalTree.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import * as Reactodia from '@reactodia/workspace'; +import * as Forms from '@reactodia/workspace/forms'; +import * as N3 from 'n3'; + +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 { MainMenu } from './MainMenu'; +import { OpenPackageSettings } from './OpenPackageSettings'; +import { sh, getSinglePropertyValue, termAsString } from './OwlShaclSchema'; +import { genealogy, rdfs, schema } from './Vocabularies'; + +import enTranslation from './translations/en.translation.json'; +import GenealogicalSchemaTurtle from './GenealogicalSchema.ttl?raw'; +import styles from './GenealogicalTree.module.css'; + +const PERSON_ICON: string = require('!!url-loader!@vscode/codicons/src/icons/person.svg').default; + +const Layouts = Reactodia.defineLayoutWorker(() => new Worker( + new URL('@reactodia/workspace/layout.worker', import.meta.url) +)); + +interface DataSource { + readonly bytes: Uint8Array; +} + +export function ToolGenealogicalTree() { + const {defaultLayout} = Reactodia.useWorker(Layouts); + + const [dataSource, setDataSource] = React.useState(); + + const [schemaProvider] = React.useState(() => { + const provider = new (class extends Reactodia.RdfDataProvider { + override async knownElementTypes(params: { signal?: AbortSignal; }): Promise { + const graph = await super.knownElementTypes(params); + return { + ...graph, + elementTypes: graph.elementTypes.filter(data => !( + data.id.startsWith(Reactodia.owl.$namespace) || + data.id.startsWith(Reactodia.rdfs.$namespace) || + data.id.startsWith(sh.$namespace) + )), + }; + } + })(); + try { + provider.addGraph(new N3.Parser().parse(GenealogicalSchemaTurtle)); + } catch (err) { + throw new Error('Failed to parse OWL-SHACL schema for the editor', {cause: err}); + } + return provider; + }); + const [metadataProvider] = React.useState(() => new GenealogicalMetadataProvider({ + schemaProvider, + defaultNamespaceBase: GenealogicalPackage.DEFAULT_NAMESPACE_BASE, + })); + const [validationProvider] = React.useState(() => new GenealogicalValidationProvider()); + const [renameLinkProvider] = React.useState(() => new RenameGenealogicalLinksProvider()); + + const {onMount, getContext} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { + const {model, editor, overlay, translation: t, getCommandBus, performLayout} = context; + + metadataProvider.loadSchema({signal}); + editor.setAuthoringState(Reactodia.AuthoringState.empty); + editor.setAuthoringMode(true); + + const listener = new Reactodia.EventObserver(); + listener.listen(editor.events, 'changeAuthoringState', () => { + metadataProvider.updateSettings(editor.authoringState); + }); + signal.addEventListener('abort', () => listener.stopListening()); + + let sourcePackage: GenealogicalPackage; + if (dataSource) { + try { + sourcePackage = await GenealogicalPackage.loadFromBytes(dataSource.bytes, {signal}); + } catch (err) { + throw new Error(t.text('genealogical_tree.init_failed_to_load_package'), {cause: err}); + } + } else { + sourcePackage = GenealogicalPackage.createEmpty(); + } + + const uploader = new Forms.MemoryFileUploader({ + factory: Reactodia.Rdf.DefaultDataFactory, + disposeSignal: signal, + }); + const mainProvider = new GenealogicalDataProvider(sourcePackage, {uploader, signal}); + const dataProvider = new Reactodia.CompositeDataProvider({ + providers: [ + { + provider: schemaProvider, + origin: schemaProvider.factory.namedNode(genealogy.SchemaOrigin), + }, + { + provider: mainProvider, + origin: mainProvider.factory.namedNode(genealogy.DataOrigin), + }, + ], + }); + + await metadataProvider.loadSettings({mainProvider, signal}); + metadataProvider.updateSettings(editor.authoringState); + validationProvider.setProvider(mainProvider); + + const initialSettings = metadataProvider.getSettings(); + const defaultLanguage = termAsString(getSinglePropertyValue( + initialSettings, genealogy.defaultLanguage + )); + if (defaultLanguage) { + model.setLanguage(defaultLanguage); + } + + const locale = new GenealogicalLocaleProvider({ + model, + translation: t, + uploader: mainProvider.uploader, + package: sourcePackage, + }); + await model.importLayout({ + dataProvider, + locale, + diagram: sourcePackage.diagram, + validateLinks: true, + signal, + }); + + if (!sourcePackage.diagram) { + const task = overlay.startTask(); + try { + await Promise.all([schema.Person, genealogy.Marriage, schema.Place].map(async (type) => { + const items = await mainProvider.lookup({elementTypeId: type}); + for (const {element} of items) { + model.createElement(element.id); + } + })); + await model.requestData(); + await performLayout({signal}); + } catch (error) { + task.setError(error); + } finally { + task.end(); + } + + if (model.elements.length === 0) { + getCommandBus(Reactodia.UnifiedSearchTopic) + .trigger('focus', { sectionKey: 'elementTypes' }); + } + } + + const allEntities = new Set(); + for (const element of model.elements) { + for (const entity of Reactodia.iterateEntitiesOf(element)) { + allEntities.add(entity.id); + } + } + editor.revalidateEntities(allEntities); + }, [dataSource]); + + return ( + { + if (types.includes(schema.Male)) { + return {color: '#01baef', icon: PERSON_ICON, iconMonochrome: true}; + } else if (types.includes(schema.Female)) { + return {color: '#f48da7ff', icon: PERSON_ICON, iconMonochrome: true}; + } else if (types.includes(schema.Person)) { + return {color: '#b5d2cb', icon: PERSON_ICON, iconMonochrome: true}; + } + }} + translations={[ + { + ...enTranslation, + 'visual_authoring': { + 'property.load_error.text': '⚠ Failed to load variants', + }, + } + ]}> + + setDataSource({bytes})} + onSave={async () => { + const {model, editor} = getContext(); + const mainProvider = findGenealogicalProvider(model.dataProvider); + + // Capture authoring state before finalizing the diagram + const authoringState = editor.authoringState; + editor.applyAuthoringChanges(); + + const updatedPackage = await mainProvider.sourcePackage.exportWith({ + dataProvider: mainProvider, + authoringState: mainProvider.cleanupAuthoring(authoringState), + diagram: model.exportLayout(), + uploader: mainProvider.uploader, + }); + setDataSource({bytes: await updatedPackage.bytes()}); + return updatedPackage; + }} + /> + + } + canvas={{ + elementTemplateResolver: types => { + if (types.includes(genealogy.Marriage)) { + return MarriageTemplate; + } + }, + linkTemplateResolver: type => { + if (type === schema.parent) { + return ParentLinkTemplate; + } else if (type) { + return OtherLinkTemplate; + } + }, + }} + languages={[ + { code: 'de', label: 'Deutsch' }, + { code: 'en', label: 'english' }, + { code: 'es', label: 'español' }, + { code: 'ru', label: 'русский' }, + { 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 ; + }} + /> + ), + }}> + + + + + + ); +} + +class RenameGenealogicalLinksProvider extends Reactodia.RenameLinkToLinkStateProvider { + 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; + } + + 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'); + } + 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/GenealogicalValidationProvider.ts b/src/tools/GenealogicalTree/GenealogicalValidationProvider.ts new file mode 100644 index 00000000..b27efd5c --- /dev/null +++ b/src/tools/GenealogicalTree/GenealogicalValidationProvider.ts @@ -0,0 +1,59 @@ +import * as Reactodia from '@reactodia/workspace'; + +import { getSinglePropertyValue } from './OwlShaclSchema'; +import { genealogy, schema } from './Vocabularies'; + +export class GenealogicalValidationProvider implements Reactodia.ValidationProvider { + private dataProvider = new Reactodia.EmptyDataProvider(); + + async validate(e: Reactodia.ValidationEvent): Promise { + const {target, state, translation: t, signal} = e; + const validations: (Reactodia.ValidatedElement | Reactodia.ValidatedLink)[] = []; + if (target.types.includes(genealogy.Marriage)) { + const items = await this.dataProvider.lookup({refElementId: target.id, linkDirection: 'out', signal}); + const partners = new Set(); + for (const item of items) { + if (item.outLinks.has(genealogy.hasPartner)) { + partners.add(item.element.id); + } + } + for (const {type, data} of state.links.values()) { + if (data.sourceId === target.id && data.linkTypeId === genealogy.hasPartner) { + if (type === 'relationAdd') { + partners.add(data.targetId); + } else if (type === 'relationDelete') { + partners.delete(data.targetId); + } + } + } + if (partners.size < 2) { + validations.push({ + type: 'element', + target: target.id, + severity: 'warning', + message: t.text('genealogical_tree.validation.marriage_partner_count'), + }); + } + } else if (target.types.includes(schema.Person)) { + const event = state.elements.get(target.id); + if (!event || event.type !== 'entityDelete') { + const data = event?.data ?? target; + const gender = getSinglePropertyValue(data, schema.gender); + if (!gender) { + validations.push({ + type: 'element', + target: target.id, + severity: 'warning', + propertyType: schema.gender, + message: t.text('genealogical_tree.validation.missing_property_value'), + }); + } + } + } + return {items: validations}; + } + + setProvider(dataProvider: Reactodia.DataProvider): void { + this.dataProvider = dataProvider; + } +} diff --git a/src/tools/GenealogicalTree/GraphTemplates.module.css b/src/tools/GenealogicalTree/GraphTemplates.module.css new file mode 100644 index 00000000..8ddfeedb --- /dev/null +++ b/src/tools/GenealogicalTree/GraphTemplates.module.css @@ -0,0 +1,23 @@ +.marriage { + width: 40px; + height: 40px; + border: 4px solid var(--reactodia-color-primary-light); + border-radius: 50%; + display: grid; + grid-template-rows: 1fr 1fr 1fr; + align-items: center; + text-align: center; +} + +.marriageInfo { + position: absolute; + bottom: 0; + left: 50%; + transform: translate(-50%,100%); + color: cornflowerblue; + cursor: move; + white-space: nowrap; + max-width: 160px; + text-overflow: ellipsis; + overflow: hidden; +} diff --git a/src/tools/GenealogicalTree/GraphTemplates.tsx b/src/tools/GenealogicalTree/GraphTemplates.tsx new file mode 100644 index 00000000..eab7c30a --- /dev/null +++ b/src/tools/GenealogicalTree/GraphTemplates.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import * as Reactodia from '@reactodia/workspace'; + +import styles from './GraphTemplates.module.css'; +import { schema } from './Vocabularies'; + +export const MarriageTemplate: Reactodia.ElementTemplate = { + shape: 'ellipse', + renderElement: props => ( + props.element instanceof Reactodia.EntityElement + ? + : null + ), +}; + +function MarriageEntity(props: Reactodia.TemplateProps & { target: Reactodia.EntityElement }) { + const {target} = props; + const {model} = Reactodia.useWorkspace(); + 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); + return ( + <> +
+ +
+ {label} +
+
+ + ); +} + +export const ParentLinkTemplate: Reactodia.LinkTemplate = { + ...Reactodia.StandardLinkTemplate, + markerTarget: { + ...Reactodia.LinkMarkerArrowhead, + stroke: 'context-stroke', + }, + renderLink: props => ( + propertyIri === 'urn:reactodia:sourceProvider' ? null : undefined + } + /> + ), +}; + +export const OtherLinkTemplate: Reactodia.LinkTemplate = { + ...Reactodia.StandardLinkTemplate, + renderLink: props => ( + propertyIri === 'urn:reactodia:sourceProvider' ? null : undefined + } + /> + ), +}; diff --git a/src/tools/GenealogicalTree/MainMenu.tsx b/src/tools/GenealogicalTree/MainMenu.tsx new file mode 100644 index 00000000..3dc0a841 --- /dev/null +++ b/src/tools/GenealogicalTree/MainMenu.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import * as Reactodia from '@reactodia/workspace'; + +import { schema } from './Vocabularies'; + +export function MainMenu(props: { + onOpen: (bytes: Uint8Array) => void; + onSave: () => Promise; +}) { + const {onOpen, onSave} = props; + const {model, overlay, translation: t} = Reactodia.useWorkspace(); + return ( + <> + { + const task = overlay.startTask({ + title: t.text('genealogical_tree.task_loading_package'), + }); + try { + const bytes = await file.bytes(); + onOpen(bytes); + } catch (err) { + task.setError(new Error( + t.text('genealogical_tree.task_loading_package_failed'), + { cause: err } + )); + } finally { + task.end(); + } + }}> + {t.text('genealogical_tree.action_open_from_file')} + + { + const jsonString = await onSave(); + const blob = new Blob([jsonString], {type: 'application/json'}); + const blobUrl = URL.createObjectURL(blob); + const timestamp = new Date().toISOString().replaceAll(/[Z\s:-]/g, ''); + try { + const downloadLink = document.createElement('a'); + downloadLink.href = blobUrl; + downloadLink.download = `genealogy_tree_${timestamp}.zip`; + downloadLink.click(); + } finally { + URL.revokeObjectURL(blobUrl); + } + }}> + {t.text('genealogical_tree.action_save_to_file')} + + { + const batch = model.history.startBatch('Reset pinned properties'); + try { + for (const element of model.elements) { + if (element instanceof Reactodia.EntityElement && element.data.types.includes(schema.Person)) { + batch.history.execute(Reactodia.setElementState( + element, + element.elementState + .set(Reactodia.TemplateProperties.PinnedProperties, { + ...element.elementState.get(Reactodia.TemplateProperties.PinnedProperties), + [schema.birthDate]: true, + [schema.deathDate]: true, + }) + )); + } + } + } finally { + batch.store(); + } + }}> + Reset pinned properties + + + + + + + ); +} diff --git a/src/tools/GenealogicalTree/OpenPackageSettings.module.css b/src/tools/GenealogicalTree/OpenPackageSettings.module.css new file mode 100644 index 00000000..aa48d440 --- /dev/null +++ b/src/tools/GenealogicalTree/OpenPackageSettings.module.css @@ -0,0 +1,14 @@ +button.action { + display: flex; +} + +.indicator { + display: block; + padding-top: -5px; + margin-top: -8px; + margin-left: -5px; + font-size: 20px; + line-height: 14px; + color: var(--reactodia-color-info); + margin-right: -5px; +} diff --git a/src/tools/GenealogicalTree/OpenPackageSettings.tsx b/src/tools/GenealogicalTree/OpenPackageSettings.tsx new file mode 100644 index 00000000..7c4ae33c --- /dev/null +++ b/src/tools/GenealogicalTree/OpenPackageSettings.tsx @@ -0,0 +1,36 @@ +import * as Reactodia from '@reactodia/workspace'; + +import SettingsGearIcon from '@vscode/codicons/src/icons/settings-gear.svg'; + +import { GenealogicalMetadataProvider } from './GenealogicalMetadataProvider'; +import { genealogy } from './Vocabularies'; + +import styles from './OpenPackageSettings.module.css'; + +export function OpenPackageSettings() { + const {editor, getCommandBus} = Reactodia.useWorkspace(); + const t = Reactodia.useTranslation(); + + const settingsChange = Reactodia.useObservedProperty( + editor.events, + 'changeAuthoringState', + () => editor.authoringState.elements.get(genealogy.ActiveSettings) + ); + + return ( + { + const provider = editor.metadataProvider; + if (provider instanceof GenealogicalMetadataProvider) { + getCommandBus(Reactodia.VisualAuthoringTopic) + .trigger('editEntity', {target: provider.getSettings()}); + } + }} + > + + {settingsChange ?
: null} +
+ ); +} diff --git a/src/tools/GenealogicalTree/OwlShaclSchema.ts b/src/tools/GenealogicalTree/OwlShaclSchema.ts new file mode 100644 index 00000000..402ee4b7 --- /dev/null +++ b/src/tools/GenealogicalTree/OwlShaclSchema.ts @@ -0,0 +1,273 @@ +import { + Rdf, DataProvider, ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri, ElementModel, +} from '@reactodia/workspace'; + +import { vocabulary, xsd } from './Vocabularies'; + +export const sh = vocabulary('http://www.w3.org/ns/shacl#', [ + 'BlankNode', + 'BlankNodeOrIRI', + 'BlankNodeOrLiteral', + 'IRI', + 'IRIOrLiteral', + 'Literal', + 'NodeShape', + 'class', + 'datatype', + 'defaultValue', + 'maxCount', + 'minCount', + 'nodeKind', + 'order', + 'path', + 'property', + 'targetClass', + 'uniqueLang', +]); + +export const dash = vocabulary('http://datashapes.org/dash#', [ + 'reifiableBy', +]); + +export const r_shapes = vocabulary('urn:reactodia:shapes:', [ + 'canCreate', + 'canDelete', + 'subjectTemplate', +]); + +export interface OwlShaclSchema { + readonly shapes: ReadonlyMap; +} + +export interface ShaclShape { + readonly properties: readonly ShaclProperty[]; + readonly subjectTemplate?: string | undefined; + readonly canCreate?: boolean; + readonly canDelete?: boolean; +} + +export interface ShaclProperty { + readonly path: LinkTypeIri & PropertyTypeIri; + readonly nodeKind?: Rdf.NamedNode | undefined; + readonly class_?: Rdf.NamedNode | undefined; + readonly datatype?: Rdf.NamedNode | undefined; + readonly defaultValue?: Rdf.NamedNode | Rdf.Literal | undefined; + readonly uniqueLang?: boolean | undefined; + readonly minCount?: number | undefined; + readonly maxCount?: number | undefined; + readonly order?: number | undefined; + readonly reifiableBy?: readonly ShaclShape[]; +} + +export async function loadOwlShaclSchema(params: { + schemaProvider: DataProvider; + signal: AbortSignal; +}): Promise { + const {schemaProvider, signal} = params; + const {elementTypes} = await schemaProvider.knownElementTypes({signal}); + + const targetToShapeIri = new Map>(); + for (const type of elementTypes) { + targetToShapeIri.set(type.id, new Set()); + } + + const allShapeIris = new Set(); + const addShape = (targetIri: ElementTypeIri, shapeIri: ElementIri) => { + targetToShapeIri.get(targetIri)?.add(shapeIri); + allShapeIris.add(shapeIri); + }; + + await Promise.all([ + loadImplicitClassShapes(elementTypes.map(type => type.id), schemaProvider, addShape, signal), + ...elementTypes.map(type => loadTargetShapes(type.id, schemaProvider, addShape, signal)), + ]); + + const shapeElements = await schemaProvider.elements({elementIds: Array.from(allShapeIris)}); + + const shapes = new Map(); + await Promise.all(Array.from(targetToShapeIri, async ([targetIri, shapeIris]) => { + if (shapeIris.size > 0) { + const targetShapes = await Promise.all(Array.from( + shapeIris, + shapeIri => loadShapeData( + shapeElements.get(shapeIri) ?? { + id: shapeIri, + types: [], + properties: {}, + }, + schemaProvider, + signal + ) + )); + shapes.set(targetIri, targetShapes); + } + })); + + return {shapes}; +} + +async function loadImplicitClassShapes( + classIris: readonly ElementTypeIri[], + schemaProvider: DataProvider, + addShape: (classIri: ElementTypeIri, shapeIri: ElementIri) => void, + signal: AbortSignal | undefined +): Promise { + const classElements = await schemaProvider.elements({ + elementIds: classIris as ElementIri[], + signal, + }); + for (const classIri of classIris) { + const classElement = classElements.get(classIri as ElementIri); + if (classElement && classElement.types.includes(sh.NodeShape)) { + addShape(classIri, classElement.id); + } + } +} + +async function loadTargetShapes( + targetIri: ElementTypeIri, + schemaProvider: DataProvider, + addShape: (targetIri: ElementTypeIri, shapeIri: ElementIri) => void, + signal: AbortSignal | undefined +): Promise { + const connected = await schemaProvider.lookup({ + refElementId: targetIri as ElementIri, + refElementLinkId: sh.targetClass, + linkDirection: 'in', + signal, + }); + for (const {element} of connected) { + addShape(targetIri, element.id); + } +} + +async function loadShapeData( + shapeElement: ElementModel, + schemaProvider: DataProvider, + signal: AbortSignal | undefined +): Promise { + const connectedProperties = await schemaProvider.lookup({ + refElementId: shapeElement.id, + refElementLinkId: sh.property, + linkDirection: 'out', + signal, + }); + const propertyIds = connectedProperties.map(item => item.element.id); + const propertyElements = await schemaProvider.elements({ + elementIds: propertyIds, + signal, + }); + const properties: ShaclProperty[] = []; + await Promise.all(Array.from(propertyElements.values(), async propertyElement => { + const property = await loadPropertyData(propertyElement, schemaProvider, signal); + if (property) { + properties.push(property); + } + })); + return { + properties, + subjectTemplate: termAsString(getSinglePropertyValue(shapeElement, r_shapes.subjectTemplate)), + canCreate: termAsBoolean(getSinglePropertyValue(shapeElement, r_shapes.canCreate)), + canDelete: termAsBoolean(getSinglePropertyValue(shapeElement, r_shapes.canDelete)), + }; +} + +async function loadPropertyData( + propertyElement: ElementModel, + schemaProvider: DataProvider, + signal: AbortSignal | undefined +): Promise { + let path: Rdf.NamedNode | undefined; + let nodeKind: Rdf.NamedNode | undefined; + let class_: Rdf.NamedNode | undefined; + let datatype: Rdf.NamedNode | undefined; + let defaultValue: Rdf.NamedNode | Rdf.Literal | undefined; + const reifiableBy: ElementModel[] = []; + + const connected = await schemaProvider.lookup({ + refElementId: propertyElement.id, + linkDirection: 'out', + signal, + }); + for (const {element, outLinks} of connected) { + const node = schemaProvider.factory.namedNode(element.id); + if (outLinks.has(sh.path)) { + path = node; + } else if (outLinks.has(sh.nodeKind)) { + nodeKind = node; + } else if (outLinks.has(sh.datatype)) { + datatype = node; + } else if (outLinks.has(sh.defaultValue)) { + defaultValue = node; + } else if (outLinks.has(sh.class)) { + class_ = node; + } else if (outLinks.has(dash.reifiableBy)) { + reifiableBy.push(element); + } + } + + if (!path) { + return undefined; + } + + let reifiableByShapes: ShaclShape[] | undefined; + if (reifiableBy.length > 0) { + reifiableByShapes = await Promise.all(reifiableBy.map(shapeElement => + loadShapeData(shapeElement, schemaProvider, signal) + )); + } + + return { + path: path.value, + nodeKind, + class_, + datatype, + defaultValue: defaultValue ?? getSinglePropertyValue(propertyElement, sh.defaultValue), + uniqueLang: termAsBoolean(getSinglePropertyValue(propertyElement, sh.uniqueLang)), + minCount: termAsNumber(getSinglePropertyValue(propertyElement, sh.minCount)), + maxCount: termAsNumber(getSinglePropertyValue(propertyElement, sh.maxCount)), + order: termAsNumber(getSinglePropertyValue(propertyElement, sh.order)), + reifiableBy: reifiableByShapes, + }; +} + +export function getSinglePropertyValue( + element: ElementModel, + propertyIri: PropertyTypeIri +): Rdf.NamedNode | Rdf.Literal | undefined { + if (Object.hasOwn(element.properties, propertyIri)) { + const values = element.properties[propertyIri]; + if (values.length === 1) { + return values[0]; + } + } + return undefined; +} + +export function termAsNumber(term: Rdf.NamedNode | Rdf.Literal | undefined): number | undefined { + if (term && term.termType === 'Literal') { + const value = Number(term.value); + if (Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +export function termAsString(term: Rdf.NamedNode | Rdf.Literal | undefined): string | undefined { + if (term && term.termType === 'Literal' && term.datatype.value === xsd.string) { + return term.value; + } + return undefined; +} + +export function termAsBoolean(term: Rdf.NamedNode | Rdf.Literal | undefined): boolean | undefined { + if (term && term.termType === 'Literal' && term.datatype.value === xsd.boolean) { + return ( + term.value === 'true' ? true : + term.value === 'false' ? false : + undefined + ); + } + return undefined; +} diff --git a/src/tools/GenealogicalTree/Vocabularies.ts b/src/tools/GenealogicalTree/Vocabularies.ts new file mode 100644 index 00000000..4770543f --- /dev/null +++ b/src/tools/GenealogicalTree/Vocabularies.ts @@ -0,0 +1,64 @@ +import * as Reactodia from '@reactodia/workspace'; + +type VocabularyKeyType = + K extends Capitalize + ? Reactodia.ElementIri & Reactodia.ElementTypeIri + : Reactodia.LinkTypeIri & Reactodia.PropertyTypeIri; + +type Vocabulary = { $namespace: string } & { + readonly [K in Keys[number]]: VocabularyKeyType; +}; + +export function vocabulary(prefix: string, keys: Keys): Vocabulary { + const result: { [key: string]: string } = Object.create(null); + for (const key of keys) { + result[key] = prefix + key; + } + result.$namespace = prefix; + return result as Vocabulary; +} + +// Based on https://github.com/blokhin/genealogical-trees/blob/master/data/header.ttl +export const genealogy = vocabulary('http://reactodia.github.io/genealogy#', [ + 'ActiveSettings', + 'DataOrigin', + 'Marriage', + 'SchemaOrigin', + 'PackageSettings', + 'defaultLanguage', + 'defaultNamespaceBase', + 'hasGodparent', + 'hasPartner', +]); + +export const rdfs = vocabulary(Reactodia.rdfs.$namespace, [ + 'comment', +]); + +export const schema = vocabulary(Reactodia.schema.$namespace, [ + 'Female', + 'Male', + 'Person', + 'Place', + 'address', + 'birthDate', + 'birthPlace', + 'deathDate', + 'deathPlace', + 'endDate', + 'gender', + 'homeLocation', + 'latitude', + 'longitude', + 'parent', + 'relatedTo', + 'startDate', +]); + +export const xsd = vocabulary(Reactodia.xsd.$namespace, [ + 'boolean', + 'date', + 'dateTime', + 'decimal', + 'string', +]); diff --git a/src/tools/GenealogicalTree/translations/en.translation.json b/src/tools/GenealogicalTree/translations/en.translation.json new file mode 100644 index 00000000..0cf7e2d7 --- /dev/null +++ b/src/tools/GenealogicalTree/translations/en.translation.json @@ -0,0 +1,13 @@ +{ + "$schema": "./schema.json", + "genealogical_tree": { + "action_open_from_file": "Open package from file", + "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", + "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", + "validation.missing_property_value": "missing property value" + } +} diff --git a/src/tools/GenealogicalTree/translations/schema.json b/src/tools/GenealogicalTree/translations/schema.json new file mode 100644 index 00000000..c1356090 --- /dev/null +++ b/src/tools/GenealogicalTree/translations/schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Value": { "type": ["string", "null"] } + }, + "unevaluatedProperties": false, + "properties": { + "$schema": { "type": "string" }, + "genealogical_tree": { + "additionalProperties": { "$ref": "#/$defs/Value" } + } + } +} diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 00000000..f3a8698f --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,4 @@ +declare module '*?raw' { + const content: string; + export = content; +}