diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx
new file mode 100644
index 000000000..f4b977423
--- /dev/null
+++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx
@@ -0,0 +1,65 @@
+import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel";
+import type {
+ SchemaSelectionSource,
+ SchemaSelectionState,
+} from "~/components/useSchemaSelection";
+
+type SchemaSelectionModalBodyProps = {
+ title: string;
+ description: string;
+ source: SchemaSelectionSource;
+ selection: SchemaSelectionState;
+ onDependencyViolation?: (message: string) => void;
+ footerSecondaryLabel: string;
+ onFooterSecondaryClick: () => void;
+ footerPrimaryLabel: string;
+ onFooterPrimaryClick: () => void;
+ isFooterPrimaryDisabled?: boolean;
+ isFooterSecondaryDisabled?: boolean;
+};
+
+export const SchemaSelectionModalBody = ({
+ title,
+ description,
+ source,
+ selection,
+ onDependencyViolation,
+ footerSecondaryLabel,
+ onFooterSecondaryClick,
+ footerPrimaryLabel,
+ onFooterPrimaryClick,
+ isFooterPrimaryDisabled = false,
+ isFooterSecondaryDisabled = false,
+}: SchemaSelectionModalBodyProps) => {
+ return (
+
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx
new file mode 100644
index 000000000..23848e1da
--- /dev/null
+++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx
@@ -0,0 +1,296 @@
+import type {
+ SchemaSelectionSource,
+ SchemaSelectionState,
+} from "~/components/useSchemaSelection";
+
+type SchemaSelectionPanelProps = {
+ source: SchemaSelectionSource;
+ selection: SchemaSelectionState;
+ onDependencyViolation?: (message: string) => void;
+};
+
+export const SchemaSelectionPanel = ({
+ source,
+ selection,
+ onDependencyViolation,
+}: SchemaSelectionPanelProps) => {
+ const {
+ selectedNodeTypeIds,
+ selectedRelationTypeIds,
+ selectedRelationIds,
+ selectedTemplateNames,
+ requiredNodeTypeIds,
+ requiredRelationTypeIds,
+ selectAllNodeTypes,
+ deselectOptionalNodeTypes,
+ toggleNodeType,
+ selectAllRelationTypes,
+ deselectOptionalRelationTypes,
+ toggleRelationType,
+ selectAllRelationTriples,
+ deselectAllRelationTriples,
+ toggleRelationTriple,
+ selectAllTemplates,
+ deselectAllTemplates,
+ toggleTemplate,
+ } = selection;
+
+ const nodeTypeById = new Map(
+ source.nodeTypes.map((nodeType) => [nodeType.id, nodeType]),
+ );
+ const relationTypeById = new Map(
+ source.relationTypes.map((relationType) => [relationType.id, relationType]),
+ );
+ const sortedNodeTypes = [...source.nodeTypes].sort((a, b) =>
+ a.name.localeCompare(b.name),
+ );
+ const templateToNodeTypeNames = new Map();
+ for (const nodeType of sortedNodeTypes) {
+ if (!nodeType.template) continue;
+ const names = templateToNodeTypeNames.get(nodeType.template) ?? [];
+ names.push(nodeType.name);
+ templateToNodeTypeNames.set(nodeType.template, names);
+ }
+ const referencedTemplateNames = new Set(templateToNodeTypeNames.keys());
+
+ return (
+ <>
+
+
Selection summary
+
+ {selectedNodeTypeIds.size} node type(s)
+ {selectedRelationTypeIds.size} relation type(s)
+ {selectedRelationIds.size} relation triple(s)
+ {selectedTemplateNames.size} template(s)
+
+
+
+
+
+
+
Node types
+
+
+
+
+
+
+ {source.nodeTypes.map((nodeType) => {
+ const isRequired = requiredNodeTypeIds.has(nodeType.id);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
Relation types
+
+
+
+
+
+
+ {source.relationTypes.map((relationType) => {
+ const isRequired = requiredRelationTypeIds.has(relationType.id);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
Relation triples
+
+
+
+
+
+
+ {source.relationTriples.map((relation) => {
+ const sourceName =
+ nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId;
+ const destinationName =
+ nodeTypeById.get(relation.destinationId)?.name ??
+ relation.destinationId;
+ const relationTypeLabel =
+ relationTypeById.get(relation.relationshipTypeId)?.label ??
+ relation.relationshipTypeId;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
Templates
+
+
+
+
+
+ {source.templateNames.length === 0 ? (
+ No template files found.
+ ) : (
+
+ {source.templateNames.map((templateName) => (
+
+ ))}
+
+ )}
+
+
+ >
+ );
+};
diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts
new file mode 100644
index 000000000..ebeb59710
--- /dev/null
+++ b/apps/obsidian/src/components/useSchemaSelection.ts
@@ -0,0 +1,238 @@
+import { useEffect, useMemo, useState } from "react";
+import type {
+ DiscourseNode,
+ DiscourseRelation,
+ DiscourseRelationType,
+ SchemaSelection,
+} from "~/types";
+
+export type SchemaSelectionSource = {
+ nodeTypes: Pick[];
+ relationTypes: Pick[];
+ relationTriples: Pick<
+ DiscourseRelation,
+ "id" | "sourceId" | "destinationId" | "relationshipTypeId"
+ >[];
+ templateNames: string[];
+};
+
+type SelectionToggleResult = {
+ ok: boolean;
+ reason?: string;
+};
+
+export type SchemaSelectionState = {
+ selectedNodeTypeIds: Set;
+ selectedRelationTypeIds: Set;
+ selectedRelationIds: Set;
+ selectedTemplateNames: Set;
+ requiredNodeTypeIds: Set;
+ requiredRelationTypeIds: Set;
+ selectAllNodeTypes: () => void;
+ deselectOptionalNodeTypes: () => void;
+ toggleNodeType: (
+ nodeTypeId: string,
+ shouldSelect: boolean,
+ ) => SelectionToggleResult;
+ selectAllRelationTypes: () => void;
+ deselectOptionalRelationTypes: () => void;
+ toggleRelationType: (
+ relationTypeId: string,
+ shouldSelect: boolean,
+ ) => SelectionToggleResult;
+ selectAllRelationTriples: () => void;
+ deselectAllRelationTriples: () => void;
+ toggleRelationTriple: (relationId: string, shouldSelect: boolean) => void;
+ selectAllTemplates: () => void;
+ deselectAllTemplates: () => void;
+ toggleTemplate: (templateName: string, shouldSelect: boolean) => void;
+ asSelectionPayload: () => SchemaSelection;
+};
+
+const updateSet = (
+ previousSet: Set,
+ id: string,
+ shouldSelect: boolean,
+): Set => {
+ const nextSet = new Set(previousSet);
+ if (shouldSelect) {
+ nextSet.add(id);
+ } else {
+ nextSet.delete(id);
+ }
+ return nextSet;
+};
+
+export const getReferencedTemplateNames = (
+ nodeTypes: SchemaSelectionSource["nodeTypes"],
+): Set => {
+ return new Set(
+ nodeTypes
+ .map((nodeType) => nodeType.template)
+ .filter((template): template is string => !!template),
+ );
+};
+
+export const useSchemaSelection = ({
+ source,
+ initialTemplateNames,
+ resetKey,
+}: {
+ source: SchemaSelectionSource;
+ /**
+ * Template names to pre-select on mount and on reset. Defaults to all
+ * templates in source when not provided.
+ */
+ initialTemplateNames?: string[];
+ resetKey: string;
+}): SchemaSelectionState => {
+ const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>(
+ () => new Set(source.nodeTypes.map((nodeType) => nodeType.id)),
+ );
+ const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState<
+ Set
+ >(() => new Set(source.relationTypes.map((relationType) => relationType.id)));
+ const [selectedRelationIds, setSelectedRelationIds] = useState>(
+ () => new Set(source.relationTriples.map((relation) => relation.id)),
+ );
+ const [selectedTemplateNames, setSelectedTemplateNames] = useState<
+ Set
+ >(() => new Set(initialTemplateNames ?? source.templateNames));
+
+ // resetKey is the only trigger; source and initialTemplateNames are read
+ // from the current render's closure when resetKey changes.
+ useEffect(() => {
+ setSelectedNodeTypeIds(
+ new Set(source.nodeTypes.map((nodeType) => nodeType.id)),
+ );
+ setSelectedRelationTypeIds(
+ new Set(source.relationTypes.map((relationType) => relationType.id)),
+ );
+ setSelectedRelationIds(
+ new Set(source.relationTriples.map((relation) => relation.id)),
+ );
+ setSelectedTemplateNames(
+ new Set(initialTemplateNames ?? source.templateNames),
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [resetKey]);
+
+ const requiredRelationTypeIds = useMemo(() => {
+ const requiredIds = new Set();
+ for (const relation of source.relationTriples) {
+ if (selectedRelationIds.has(relation.id)) {
+ requiredIds.add(relation.relationshipTypeId);
+ }
+ }
+ return requiredIds;
+ }, [source.relationTriples, selectedRelationIds]);
+
+ const requiredNodeTypeIds = useMemo(() => {
+ const requiredIds = new Set();
+ for (const relation of source.relationTriples) {
+ if (!selectedRelationIds.has(relation.id)) {
+ continue;
+ }
+ requiredIds.add(relation.sourceId);
+ requiredIds.add(relation.destinationId);
+ }
+ return requiredIds;
+ }, [source.relationTriples, selectedRelationIds]);
+
+ useEffect(() => {
+ setSelectedRelationTypeIds((previousSet) => {
+ const nextSet = new Set(previousSet);
+ let didChange = false;
+ for (const relationTypeId of requiredRelationTypeIds) {
+ if (!nextSet.has(relationTypeId)) {
+ nextSet.add(relationTypeId);
+ didChange = true;
+ }
+ }
+ return didChange ? nextSet : previousSet;
+ });
+ }, [requiredRelationTypeIds]);
+
+ useEffect(() => {
+ setSelectedNodeTypeIds((previousSet) => {
+ const nextSet = new Set(previousSet);
+ let didChange = false;
+ for (const nodeTypeId of requiredNodeTypeIds) {
+ if (!nextSet.has(nodeTypeId)) {
+ nextSet.add(nodeTypeId);
+ didChange = true;
+ }
+ }
+ return didChange ? nextSet : previousSet;
+ });
+ }, [requiredNodeTypeIds]);
+
+ return {
+ selectedNodeTypeIds,
+ selectedRelationTypeIds,
+ selectedRelationIds,
+ selectedTemplateNames,
+ requiredNodeTypeIds,
+ requiredRelationTypeIds,
+ selectAllNodeTypes: () =>
+ setSelectedNodeTypeIds(
+ new Set(source.nodeTypes.map((nodeType) => nodeType.id)),
+ ),
+ deselectOptionalNodeTypes: () =>
+ setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])),
+ toggleNodeType: (nodeTypeId, shouldSelect) => {
+ if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) {
+ return {
+ ok: false,
+ reason:
+ "This node type is required by a selected relation triple. Remove the triple first.",
+ };
+ }
+ setSelectedNodeTypeIds((previousSet) =>
+ updateSet(previousSet, nodeTypeId, shouldSelect),
+ );
+ return { ok: true };
+ },
+ selectAllRelationTypes: () =>
+ setSelectedRelationTypeIds(
+ new Set(source.relationTypes.map((relationType) => relationType.id)),
+ ),
+ deselectOptionalRelationTypes: () =>
+ setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])),
+ toggleRelationType: (relationTypeId, shouldSelect) => {
+ if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) {
+ return {
+ ok: false,
+ reason:
+ "This relation type is required by a selected relation triple. Remove the triple first.",
+ };
+ }
+ setSelectedRelationTypeIds((previousSet) =>
+ updateSet(previousSet, relationTypeId, shouldSelect),
+ );
+ return { ok: true };
+ },
+ selectAllRelationTriples: () =>
+ setSelectedRelationIds(
+ new Set(source.relationTriples.map((relation) => relation.id)),
+ ),
+ deselectAllRelationTriples: () => setSelectedRelationIds(new Set()),
+ toggleRelationTriple: (relationId, shouldSelect) =>
+ setSelectedRelationIds((previousSet) =>
+ updateSet(previousSet, relationId, shouldSelect),
+ ),
+ selectAllTemplates: () =>
+ setSelectedTemplateNames(new Set(source.templateNames)),
+ deselectAllTemplates: () => setSelectedTemplateNames(new Set()),
+ toggleTemplate: (templateName, shouldSelect) =>
+ setSelectedTemplateNames((previousSet) =>
+ updateSet(previousSet, templateName, shouldSelect),
+ ),
+ asSelectionPayload: () => ({
+ nodeTypeIds: [...selectedNodeTypeIds],
+ relationTypeIds: [...selectedRelationTypeIds],
+ discourseRelationIds: [...selectedRelationIds],
+ templateNames: [...selectedTemplateNames],
+ }),
+ };
+};