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": {