diff --git a/apps/obsidian/src/components/DiscourseContextView.tsx b/apps/obsidian/src/components/DiscourseContextView.tsx
index 9dcdd6368..1ffc0df24 100644
--- a/apps/obsidian/src/components/DiscourseContextView.tsx
+++ b/apps/obsidian/src/components/DiscourseContextView.tsx
@@ -2,14 +2,17 @@ import { ItemView, TFile, WorkspaceLeaf } from "obsidian";
import { createRoot, Root } from "react-dom/client";
import DiscourseGraphPlugin from "~/index";
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
+import { RelationshipSection } from "~/components/RelationshipSection";
import { VIEW_TYPE_DISCOURSE_CONTEXT } from "~/types";
+import { PluginProvider, usePlugin } from "~/components/PluginContext";
-interface DiscourseContextProps {
+type DiscourseContextProps = {
activeFile: TFile | null;
- plugin: DiscourseGraphPlugin;
-}
+};
+
+const DiscourseContext = ({ activeFile }: DiscourseContextProps) => {
+ const plugin = usePlugin();
-const DiscourseContext = ({ activeFile, plugin }: DiscourseContextProps) => {
const extractContentFromTitle = (
format: string | undefined,
title: string,
@@ -47,24 +50,40 @@ const DiscourseContext = ({ activeFile, plugin }: DiscourseContextProps) => {
return
Unknown node type: {frontmatter.nodeTypeId}
;
}
return (
-
-
- {nodeType.name || "Unnamed Node Type"}
+ <>
+
+
+ {nodeType.name || "Unnamed Node Type"}
+
+
+ {nodeType.format && (
+
+ Content:
+ {extractContentFromTitle(nodeType.format, activeFile.basename)}
+
+ )}
- {nodeType.format && (
-
- Content:
- {extractContentFromTitle(nodeType.format, activeFile.basename)}
-
- )}
-
+
+
+ Relationships
+
+
+
+ >
);
};
@@ -127,7 +146,9 @@ export class DiscourseContextView extends ItemView {
updateView(): void {
if (this.root) {
this.root.render(
-
,
+
+
+ ,
);
}
}
diff --git a/apps/obsidian/src/components/DropdownSelect.tsx b/apps/obsidian/src/components/DropdownSelect.tsx
new file mode 100644
index 000000000..f8d0de40f
--- /dev/null
+++ b/apps/obsidian/src/components/DropdownSelect.tsx
@@ -0,0 +1,85 @@
+import { DropdownComponent } from "obsidian";
+import { useEffect, useRef } from "react";
+
+type DropdownSelectProps
= {
+ options: T[];
+ onSelect: (item: T | null) => void;
+ placeholder?: string;
+ getItemText: (item: T) => string;
+};
+
+const DropdownSelect = ({
+ options,
+ onSelect,
+ placeholder = "Select...",
+ getItemText,
+}: DropdownSelectProps) => {
+ const containerRef = useRef(null);
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ if (!dropdownRef.current) {
+ dropdownRef.current = new DropdownComponent(containerRef.current);
+ }
+
+ const dropdown = dropdownRef.current;
+ const currentValue = dropdown.getValue();
+
+ dropdown.selectEl.empty();
+
+ dropdown.addOption("", placeholder);
+
+ options.forEach((option) => {
+ const text = getItemText(option);
+ dropdown.addOption(text, text);
+ });
+
+ if (
+ currentValue &&
+ options.some((opt) => getItemText(opt) === currentValue)
+ ) {
+ dropdown.setValue(currentValue);
+ }
+
+ const onChangeHandler = (value: string) => {
+ const selectedOption =
+ options.find((opt) => getItemText(opt) === value) || null;
+ dropdown.setValue(value);
+ onSelect(selectedOption);
+ };
+
+ dropdown.onChange(onChangeHandler);
+
+ if (options && options.length === 1 && !currentValue) {
+ dropdown.setValue(getItemText(options[0] as T));
+ }
+
+ return () => {
+ dropdown.onChange(() => {});
+ };
+ }, [options, onSelect, getItemText, placeholder]);
+
+ useEffect(() => {
+ return () => {
+ if (dropdownRef.current) {
+ dropdownRef.current.selectEl.empty();
+ dropdownRef.current = null;
+ }
+ };
+ }, []);
+
+ return (
+
+ );
+};
+
+export default DropdownSelect;
diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx
new file mode 100644
index 000000000..fce59ee3e
--- /dev/null
+++ b/apps/obsidian/src/components/RelationshipSection.tsx
@@ -0,0 +1,397 @@
+import { TFile, Notice } from "obsidian";
+import { useState, useRef, useEffect, useCallback, useMemo } from "react";
+import { QueryEngine } from "~/services/QueryEngine";
+import SearchBar from "./SearchBar";
+import { DiscourseNode } from "~/types";
+import DropdownSelect from "./DropdownSelect";
+import { usePlugin } from "./PluginContext";
+
+type RelationTypeOption = {
+ id: string;
+ label: string;
+ isSource: boolean;
+};
+
+type RelationshipSectionProps = {
+ activeFile: TFile;
+};
+
+const AddRelationship = ({ activeFile }: RelationshipSectionProps) => {
+ const plugin = usePlugin();
+
+ const [selectedRelationType, setSelectedRelationType] = useState("");
+ const [selectedNode, setSelectedNode] = useState(null);
+ const [isAddingRelation, setIsAddingRelation] = useState(false);
+ const [searchError, setSearchError] = useState(null);
+ const [compatibleNodeTypes, setCompatibleNodeTypes] = useState<
+ DiscourseNode[]
+ >([]);
+
+ const queryEngineRef = useRef(null);
+
+ const activeNodeTypeId = (() => {
+ const fileCache = plugin.app.metadataCache.getFileCache(activeFile);
+ return fileCache?.frontmatter?.nodeTypeId;
+ })();
+
+ useEffect(() => {
+ if (!queryEngineRef.current) {
+ queryEngineRef.current = new QueryEngine(plugin.app);
+ }
+ }, [plugin.app]);
+
+ useEffect(() => {
+ if (!selectedRelationType || !activeNodeTypeId) {
+ setCompatibleNodeTypes([]);
+ return;
+ }
+
+ const relations = plugin.settings.discourseRelations.filter(
+ (relation) =>
+ relation.relationshipTypeId === selectedRelationType &&
+ (relation.sourceId === activeNodeTypeId ||
+ relation.destinationId === activeNodeTypeId),
+ );
+
+ const compatibleNodeTypeIds = relations.map((relation) =>
+ relation.sourceId === activeNodeTypeId
+ ? relation.destinationId
+ : relation.sourceId,
+ );
+
+ const compatibleNodeTypes = compatibleNodeTypeIds
+ .map((id) => {
+ const nodeType = plugin.settings.nodeTypes.find(
+ (type) => type.id === id,
+ );
+ return nodeType;
+ })
+ .filter(Boolean) as DiscourseNode[];
+
+ setCompatibleNodeTypes(compatibleNodeTypes);
+ }, [selectedRelationType, activeNodeTypeId, plugin.settings]);
+
+ const getAvailableRelationTypes = useCallback(() => {
+ if (!activeNodeTypeId) return [];
+
+ const options: RelationTypeOption[] = [];
+
+ const relevantRelations = plugin.settings.discourseRelations.filter(
+ (relation) =>
+ relation.sourceId === activeNodeTypeId ||
+ relation.destinationId === activeNodeTypeId,
+ );
+
+ relevantRelations.forEach((relation) => {
+ const relationType = plugin.settings.relationTypes.find(
+ (type) => type.id === relation.relationshipTypeId,
+ );
+
+ if (!relationType) return;
+
+ const isSource = relation.sourceId === activeNodeTypeId;
+
+ const existingOption = options.find(
+ (opt) => opt.id === relationType.id && opt.isSource === isSource,
+ );
+
+ if (!existingOption) {
+ options.push({
+ id: relationType.id,
+ label: isSource ? relationType.label : relationType.complement,
+ isSource,
+ });
+ }
+ });
+
+ return options;
+ }, [activeNodeTypeId, plugin.settings]);
+
+ const availableRelationTypes = useMemo(
+ () => getAvailableRelationTypes(),
+ [getAvailableRelationTypes],
+ );
+
+ // Auto-select the relation type if there's only one option
+ useEffect(() => {
+ if (
+ availableRelationTypes.length === 1 &&
+ !selectedRelationType &&
+ availableRelationTypes[0]
+ ) {
+ setSelectedRelationType(availableRelationTypes[0].id);
+ }
+ }, [availableRelationTypes, selectedRelationType]);
+
+ const searchNodes = useCallback(
+ async (query: string): Promise => {
+ if (!queryEngineRef.current) {
+ setSearchError("Search engine not initialized");
+ return [];
+ }
+
+ setSearchError(null);
+ try {
+ if (!activeNodeTypeId) {
+ setSearchError("Active file does not have a node type");
+ return [];
+ }
+
+ if (!selectedRelationType) {
+ setSearchError("Please select a relationship type first");
+ return [];
+ }
+
+ if (compatibleNodeTypes.length === 0) {
+ setSearchError(
+ "No compatible node types available for the selected relation type",
+ );
+ return [];
+ }
+
+ const nodeTypeIdsToSearch = compatibleNodeTypes.map((type) => type.id);
+
+ const results =
+ await queryEngineRef.current?.searchCompatibleNodeByTitle(
+ query,
+ nodeTypeIdsToSearch,
+ activeFile,
+ );
+
+ if (results.length === 0 && query.length >= 2) {
+ setSearchError(
+ "No matching nodes found. Try a different search term.",
+ );
+ }
+
+ return results;
+ } catch (error) {
+ setSearchError(
+ error instanceof Error ? error.message : "Unknown search error",
+ );
+ return [];
+ }
+ },
+ [activeFile, activeNodeTypeId, compatibleNodeTypes, selectedRelationType],
+ );
+
+ const renderNodeItem = (file: TFile, el: HTMLElement) => {
+ const suggestionEl = el.createEl("div", {
+ cls: "file-suggestion",
+ attr: { style: "display: flex; align-items: center;" },
+ });
+
+ suggestionEl.createEl("div", {
+ text: "📄",
+ attr: { style: "margin-right: 8px;" },
+ });
+
+ suggestionEl.createEl("div", { text: file.basename });
+ };
+
+ const addRelationship = useCallback(async () => {
+ if (!selectedRelationType || !selectedNode) return;
+
+ const relationType = plugin.settings.relationTypes.find(
+ (r) => r.id === selectedRelationType,
+ );
+ if (!relationType) return;
+
+ try {
+ const appendLinkToFrontmatter = async (file: TFile, link: string) => {
+ await plugin.app.fileManager.processFrontMatter(file, (fm) => {
+ const existingLinks = Array.isArray(fm[relationType.id])
+ ? fm[relationType.id]
+ : [];
+ fm[relationType.id] = [...existingLinks, link];
+ });
+ };
+
+ await appendLinkToFrontmatter(
+ activeFile,
+ `"[[${selectedNode.name}]]"`.replace(/^['"]+|['"]+$/g, ""),
+ );
+ await appendLinkToFrontmatter(
+ selectedNode,
+ `"[[${activeFile.name}]]"`.replace(/^['"]+|['"]+$/g, ""),
+ );
+
+ new Notice(
+ `Successfully added ${relationType.label} with ${selectedNode.name}`,
+ );
+
+ resetState();
+ } catch (error) {
+ console.error("Failed to add relationship:", error);
+ new Notice(
+ `Failed to add relationship: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ }
+ }, [
+ activeFile,
+ plugin.app.fileManager,
+ plugin.settings.relationTypes,
+ selectedNode,
+ selectedRelationType,
+ ]);
+
+ const resetState = () => {
+ setIsAddingRelation(false);
+ setSelectedRelationType("");
+ setSelectedNode(null);
+ setSearchError(null);
+ };
+
+ if (!isAddingRelation) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ options={availableRelationTypes}
+ onSelect={(option) => option && setSelectedRelationType(option.id)}
+ placeholder="Select relation type"
+ getItemText={(option) => option.label}
+ />
+
+
+ {compatibleNodeTypes.length > 0 && (
+
+
+ 💡
+
+ You can link with:{" "}
+ {compatibleNodeTypes.map((type) => (
+
+ {type.name}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ asyncSearch={searchNodes}
+ onSelect={setSelectedNode}
+ placeholder={
+ selectedRelationType
+ ? "Search nodes (type at least 2 characters)..."
+ : "Select a relationship type first"
+ }
+ getItemText={(node) => node.basename}
+ renderItem={renderNodeItem}
+ disabled={!selectedRelationType}
+ />
+ {searchError && (
+
+ Search error: {searchError}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export const RelationshipSection = ({
+ activeFile,
+}: RelationshipSectionProps) => {
+ return (
+
+ );
+};
diff --git a/apps/obsidian/src/components/SearchBar.tsx b/apps/obsidian/src/components/SearchBar.tsx
new file mode 100644
index 000000000..258b86787
--- /dev/null
+++ b/apps/obsidian/src/components/SearchBar.tsx
@@ -0,0 +1,163 @@
+import { AbstractInputSuggest, App } from "obsidian";
+import { useEffect, useRef, useState, useCallback } from "react";
+import { usePlugin } from "./PluginContext";
+
+class GenericSuggest extends AbstractInputSuggest {
+ private getItemTextFn: (item: T) => string;
+ private renderItemFn: (item: T, el: HTMLElement) => void;
+ private onSelectCallback: (item: T) => void;
+ private asyncSearchFn: (query: string) => Promise;
+ private minQueryLength: number;
+ private debounceTimeout: number | null = null;
+
+ constructor(
+ app: App,
+ private textInputEl: HTMLInputElement,
+ onSelectCallback: (item: T) => void,
+ config: {
+ getItemText: (item: T) => string;
+ renderItem?: (item: T, el: HTMLElement) => void;
+ asyncSearch: (query: string) => Promise;
+ minQueryLength?: number;
+ },
+ ) {
+ super(app, textInputEl);
+ this.onSelectCallback = onSelectCallback;
+ this.getItemTextFn = config.getItemText;
+ this.renderItemFn = config.renderItem || this.defaultRenderItem.bind(this);
+ this.asyncSearchFn = config.asyncSearch;
+ this.minQueryLength = config.minQueryLength || 0;
+ }
+
+ async getSuggestions(inputStr: string): Promise {
+ const query = inputStr.trim();
+ if (query.length < this.minQueryLength) {
+ return [];
+ }
+
+ return new Promise((resolve) => {
+ if (this.debounceTimeout) {
+ clearTimeout(this.debounceTimeout);
+ }
+
+ this.debounceTimeout = window.setTimeout(async () => {
+ try {
+ const results = await this.asyncSearchFn(query);
+ resolve(results);
+ } catch (error) {
+ console.error(`[GenericSuggest] Error in async search:`, error);
+ resolve([]);
+ }
+ }, 250);
+ });
+ }
+
+ private defaultRenderItem(item: T, el: HTMLElement): void {
+ el.setText(this.getItemTextFn(item));
+ }
+
+ renderSuggestion(item: T, el: HTMLElement): void {
+ this.renderItemFn(item, el);
+ }
+
+ selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void {
+ this.textInputEl.value = this.getItemTextFn(item);
+ this.onSelectCallback(item);
+ this.close();
+ }
+}
+
+const SearchBar = ({
+ onSelect,
+ placeholder,
+ getItemText,
+ renderItem,
+ asyncSearch,
+ disabled = false,
+}: {
+ onSelect: (item: T | null) => void;
+ placeholder?: string;
+ getItemText: (item: T) => string;
+ renderItem?: (item: T, el: HTMLElement) => void;
+ asyncSearch: (query: string) => Promise;
+ disabled?: boolean;
+}) => {
+ const inputRef = useRef(null);
+ const [selected, setSelected] = useState(null);
+ const plugin = usePlugin();
+ const app = plugin.app;
+
+ useEffect(() => {
+ if (inputRef.current && app) {
+ const suggest = new GenericSuggest(
+ app,
+ inputRef.current,
+ (item) => {
+ setSelected(item);
+ onSelect(item);
+ },
+ {
+ getItemText,
+ renderItem,
+ asyncSearch,
+ },
+ );
+ return () => suggest.close();
+ }
+ }, [onSelect, app, getItemText, renderItem, asyncSearch]);
+
+ const clearSelection = useCallback(() => {
+ if (inputRef.current) {
+ inputRef.current.value = "";
+ setSelected(null);
+ onSelect(null);
+ }
+ }, [onSelect]);
+
+ return (
+
+
+ {selected && !disabled && (
+
+ )}
+
+ );
+};
+
+export default SearchBar;
diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts
index d7546666b..9931375ec 100644
--- a/apps/obsidian/src/index.ts
+++ b/apps/obsidian/src/index.ts
@@ -67,4 +67,8 @@ export default class DiscourseGraphPlugin extends Plugin {
async saveSettings() {
await this.saveData(this.settings);
}
+
+ async onunload() {
+ this.app.workspace.detachLeavesOfType(VIEW_TYPE_DISCOURSE_CONTEXT);
+ }
}
diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts
new file mode 100644
index 000000000..bb70afe77
--- /dev/null
+++ b/apps/obsidian/src/services/QueryEngine.ts
@@ -0,0 +1,118 @@
+import { TFile, App } from "obsidian";
+
+// This is a workaround to get the datacore API.
+// TODO: Remove once we can use datacore npm package
+type AppWithPlugins = App & {
+ plugins: {
+ plugins: {
+ [key: string]: {
+ api: any;
+ };
+ };
+ };
+};
+
+export class QueryEngine {
+ private app: App;
+ private dc: any;
+ private readonly MIN_QUERY_LENGTH = 2;
+
+ constructor(app: App) {
+ const appWithPlugins = app as AppWithPlugins;
+ this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api;
+ this.app = app;
+ }
+
+ async searchCompatibleNodeByTitle(
+ query: string,
+ compatibleNodeTypeIds: string[],
+ activeFile: TFile,
+ ): Promise {
+ if (!query || query.length < this.MIN_QUERY_LENGTH) {
+ return [];
+ }
+ if (!this.dc) {
+ console.warn(
+ "Datacore API not available. Search functionality is not available.",
+ );
+ return [];
+ }
+
+ try {
+ const dcQuery = `@page and exists(nodeTypeId) and ${compatibleNodeTypeIds
+ .map((id) => `nodeTypeId = "${id}"`)
+ .join(" or ")}`;
+
+ const potentialNodes = this.dc.query(dcQuery);
+ const searchResults = potentialNodes.filter((p: any) => {
+ return this.fuzzySearch(p.$name, query);
+ });
+ const finalResults = searchResults
+ .map((dcFile: any) => {
+ if (dcFile && dcFile.$path) {
+ const realFile = this.app.vault.getAbstractFileByPath(dcFile.$path);
+ if (realFile && realFile instanceof TFile) {
+ return realFile;
+ }
+ }
+ return dcFile as TFile;
+ })
+ .filter((file: TFile) => file.path !== activeFile.path);
+
+ return finalResults;
+ } catch (error) {
+ console.error("Error in searchNodeByTitle:", error);
+ return [];
+ }
+ }
+
+ /**
+ * Enhanced fuzzy search implementation
+ * Returns true if the search term is found within the target string
+ * with tolerance for typos and partial matches
+ */
+ fuzzySearch(target: string, search: string): boolean {
+ if (!search || !target) return false;
+
+ const targetLower = target.toLowerCase();
+ const searchLower = search.toLowerCase();
+
+ if (targetLower.includes(searchLower)) {
+ return true;
+ }
+
+ if (searchLower.length > targetLower.length) {
+ return false;
+ }
+
+ if (targetLower.startsWith(searchLower)) {
+ return true;
+ }
+
+ let searchIndex = 0;
+ let consecutiveMatches = 0;
+ const MIN_CONSECUTIVE = Math.min(2, searchLower.length);
+
+ for (
+ let i = 0;
+ i < targetLower.length && searchIndex < searchLower.length;
+ i++
+ ) {
+ if (targetLower[i] === searchLower[searchIndex]) {
+ searchIndex++;
+ consecutiveMatches++;
+
+ if (
+ consecutiveMatches >= MIN_CONSECUTIVE &&
+ searchIndex >= searchLower.length * 0.7
+ ) {
+ return true;
+ }
+ } else {
+ consecutiveMatches = 0;
+ }
+ }
+
+ return searchIndex === searchLower.length;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 5ccfdb625..3894d64d6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5303,11 +5303,6 @@
"version": "1.1.13",
"license": "BSD-3-Clause"
},
- "apps/roam/node_modules/immediate": {
- "version": "3.0.6",
- "license": "MIT",
- "peer": true
- },
"apps/roam/node_modules/insect": {
"version": "5.7.0",
"license": "MIT",
@@ -13978,6 +13973,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "peer": true
+ },
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@@ -16401,9 +16402,8 @@
}
},
"node_modules/obsidian": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.7.2.tgz",
- "integrity": "sha512-k9hN9brdknJC+afKr5FQzDRuEFGDKbDjfCazJwpgibwCAoZNYHYV8p/s3mM8I6AsnKrPKNXf8xGuMZ4enWelZQ==",
+ "version": "1.8.7",
+ "resolved": "git+ssh://git@github.com/obsidianmd/obsidian-api.git#9ab497e64afeac291a2546d2faeded6a7bed626a",
"dev": true,
"license": "MIT",
"dependencies": {