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