Implement inverse edge existence check and enhance edge creation logic
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

- Added `checkInverseEdgeExists` function to verify if an inverse edge already exists between two nodes, preventing duplicate edges from being created.
- Updated `createInverseEdge` to utilize the new check, ensuring that inverse edges are only created when necessary.
- Enhanced logging for better traceability during edge creation and existence checks, improving overall debugging capabilities.
- Introduced a new function `getInverseEdgeType` to retrieve the inverse edge type from the vocabulary, supporting the new functionality.
This commit is contained in:
Lars 2026-02-06 13:02:33 +01:00
parent 99c77ef616
commit 7627a05af4
3 changed files with 323 additions and 27 deletions

View File

@ -427,6 +427,20 @@ function normalizePathForComparison(path: string): string {
* Uses resolved paths and headingsMatch for heading comparison (block-id variants). * Uses resolved paths and headingsMatch for heading comparison (block-id variants).
* Paths are normalized so different slash styles (e.g. Windows) still match. * Paths are normalized so different slash styles (e.g. Windows) still match.
*/ */
/**
* Get inverse edge type for a canonical type from edge vocabulary.
*/
function getInverseEdgeType(
canonical: string | undefined,
edgeVocabulary: EdgeVocabulary | null
): string | null {
if (!canonical || !edgeVocabulary) {
return null;
}
const entry = edgeVocabulary.byCanonical.get(canonical);
return entry?.inverse ?? null;
}
function findEdgeBetween( function findEdgeBetween(
fromKey: string, fromKey: string,
toKey: string, toKey: string,
@ -446,6 +460,7 @@ function findEdgeBetween(
// This prevents early return when a different edge type matches first // This prevents early return when a different edge type matches first
const hasAllowedRoles = allowedEdgeRoles && allowedEdgeRoles.length > 0; const hasAllowedRoles = allowedEdgeRoles && allowedEdgeRoles.length > 0;
// First, try to find edge in forward direction (from→to)
for (const edge of allEdges) { for (const edge of allEdges) {
const sourceFile = "sectionHeading" in edge.source ? edge.source.file : edge.source.file; const sourceFile = "sectionHeading" in edge.source ? edge.source.file : edge.source.file;
const resolvedSourceFile = sourceFile.includes("/") || sourceFile.endsWith(".md") const resolvedSourceFile = sourceFile.includes("/") || sourceFile.endsWith(".md")
@ -481,9 +496,6 @@ function findEdgeBetween(
const canonical = canonicalEdgeType(edge.rawEdgeType); const canonical = canonicalEdgeType(edge.rawEdgeType);
const edgeRole = getEdgeRole(edge.rawEdgeType, canonical, chainRoles); const edgeRole = getEdgeRole(edge.rawEdgeType, canonical, chainRoles);
// Debug logging for guides edges (only via logger, controlled by log level)
// Removed console.log - use getTemplateMatchingLogger().debug() if needed
// CRITICAL FIX: If allowedEdgeRoles is specified, only return if this edge's role matches // CRITICAL FIX: If allowedEdgeRoles is specified, only return if this edge's role matches
// This prevents early return when a different edge type matches first. // This prevents early return when a different edge type matches first.
// This fixes the issue where edges later in the list (e.g., guides at index 12) were // This fixes the issue where edges later in the list (e.g., guides at index 12) were
@ -505,6 +517,92 @@ function findEdgeBetween(
} }
} }
// PROBLEM 2 FIX: If no edge found in forward direction, check reverse direction (to→from)
// This handles cases where only a backlink exists (inverse edge)
// A chain should be considered complete if the semantic relationship exists in either direction
for (const edge of allEdges) {
const sourceFile = "sectionHeading" in edge.source ? edge.source.file : edge.source.file;
const resolvedSourceFile = sourceFile.includes("/") || sourceFile.endsWith(".md")
? sourceFile
: edgeTargetResolutionMap.get(sourceFile) || sourceFile;
let resolvedTargetFile = edgeTargetResolutionMap.get(edge.target.file);
if (!resolvedTargetFile) {
if (!edge.target.file.includes("/") && !edge.target.file.endsWith(".md")) {
const sourceBasename = resolvedSourceFile.split("/").pop()?.replace(/\.md$/, "") || "";
if (sourceBasename === edge.target.file || resolvedSourceFile.endsWith(edge.target.file)) {
resolvedTargetFile = resolvedSourceFile;
}
}
if (!resolvedTargetFile) {
resolvedTargetFile = edge.target.file;
}
}
const resolvedSourceNorm = normalizePathForComparison(resolvedSourceFile);
const resolvedTargetNorm = normalizePathForComparison(resolvedTargetFile);
const sourceHeading = "sectionHeading" in edge.source ? edge.source.sectionHeading : null;
const targetHeading = edge.target.heading;
// Check reverse direction: edge goes from to→from (inverse of what we're looking for)
if (
resolvedSourceNorm === toFileNorm &&
headingsMatch(sourceHeading, toHeading) &&
resolvedTargetNorm === fromFileNorm &&
headingsMatch(targetHeading, fromHeading)
) {
// This is a reverse edge - check if it's the inverse of an allowed edge type
const canonical = canonicalEdgeType(edge.rawEdgeType);
// If we have allowedEdgeRoles, we need to check if this inverse edge's role matches
// For inverse edges, we need to find the forward canonical type and check its role
if (hasAllowedRoles && allowedEdgeRoles.length > 0) {
// For each allowed role, check if this inverse edge's canonical type is the inverse
// of a canonical type that has that role
let matchedRole: string | null = null;
if (chainRoles && canonical) {
// Check if this canonical type (which is the inverse) corresponds to a forward type
// that has one of the allowed roles
for (const [roleName, role] of Object.entries(chainRoles.roles)) {
if (allowedEdgeRoles.includes(roleName)) {
// Check if any edge type in this role has an inverse that matches our edge
for (const forwardType of role.edge_types) {
const forwardCanonical = canonicalEdgeType(forwardType);
if (forwardCanonical) {
const inverseOfForward = getInverseEdgeType(forwardCanonical, edgeVocabulary);
if (inverseOfForward === canonical) {
// This inverse edge corresponds to a forward edge with the allowed role!
matchedRole = roleName;
break;
}
}
}
if (matchedRole) break;
}
}
}
if (matchedRole) {
// Return the inverse edge, but indicate it's in reverse direction
// The role is the forward role (e.g., "guides" for a "guided_by" edge)
return { edgeRole: matchedRole, rawEdgeType: edge.rawEdgeType };
}
// Continue searching
continue;
}
// No allowed roles restriction: if we found a reverse edge, it means the relationship exists
// Return it with the inferred role (if possible)
const edgeRole = canonical && chainRoles ? getEdgeRole(edge.rawEdgeType, canonical, chainRoles) : null;
// Try to infer role from the inverse edge type
const inferredRole = edgeRole || (chainRoles ? inferRoleFromRawType(edge.rawEdgeType, chainRoles) : null);
// For inverse edges, we might need to map the role back to the forward role
// But for now, return the edge as-is - the relationship exists in reverse direction
return { edgeRole: inferredRole, rawEdgeType: edge.rawEdgeType };
}
}
return null; return null;
} }

View File

@ -23,6 +23,8 @@ export class ChainWorkbenchModal extends Modal {
private selectedMatch: WorkbenchMatch | null = null; private selectedMatch: WorkbenchMatch | null = null;
private filterStatus: string | null = null; private filterStatus: string | null = null;
private searchQuery: string = ""; private searchQuery: string = "";
private treeContainer: HTMLElement | null = null;
private clickHandlerBound: ((e: MouseEvent) => void) | null = null;
constructor( constructor(
app: App, app: App,
@ -86,13 +88,17 @@ export class ChainWorkbenchModal extends Modal {
const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); const mainContainer = contentEl.createDiv({ cls: "workbench-main" });
// Left: Tree View // Left: Tree View
const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" }); this.treeContainer = mainContainer.createDiv({ cls: "workbench-tree" });
treeContainer.createEl("h3", { text: "Templates & Chains" }); this.treeContainer.createEl("h3", { text: "Templates & Chains" });
// Right: Details View // Right: Details View
const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" });
detailsContainer.createEl("h3", { text: "Chain Details" }); detailsContainer.createEl("h3", { text: "Chain Details" });
// Register click handler ONCE (event delegation)
this.clickHandlerBound = this.handleTreeClick.bind(this);
this.treeContainer.addEventListener("click", this.clickHandlerBound);
this.render(); this.render();
} }
@ -140,15 +146,15 @@ export class ChainWorkbenchModal extends Modal {
} }
private renderTreeView(matches: WorkbenchMatch[]): void { private renderTreeView(matches: WorkbenchMatch[]): void {
const treeContainer = this.contentEl.querySelector(".workbench-tree"); if (!this.treeContainer) return;
if (!treeContainer) return;
treeContainer.empty(); // Clear content but keep the container and event handler
treeContainer.createEl("h3", { text: "Templates & Chains" }); this.treeContainer.empty();
this.treeContainer.createEl("h3", { text: "Templates & Chains" });
// Show message if no matches // Show message if no matches
if (matches.length === 0) { if (matches.length === 0) {
const emptyMessage = treeContainer.createDiv({ cls: "workbench-empty-message" }); const emptyMessage = this.treeContainer.createDiv({ cls: "workbench-empty-message" });
emptyMessage.createEl("p", { text: "Keine Chains gefunden." }); emptyMessage.createEl("p", { text: "Keine Chains gefunden." });
if (this.filterStatus || this.searchQuery) { if (this.filterStatus || this.searchQuery) {
emptyMessage.createEl("p", { emptyMessage.createEl("p", {
@ -169,11 +175,16 @@ export class ChainWorkbenchModal extends Modal {
} }
// Create template tree // Create template tree
const templateTree = treeContainer.createDiv({ cls: "template-tree" }); const templateTree = this.treeContainer.createDiv({ cls: "template-tree" });
// Store match references for event delegation
const matchMap = new Map<HTMLElement, WorkbenchMatch>();
const templateMatchCounts = new Map<HTMLElement, number>();
for (const [templateName, templateMatches] of matchesByTemplate.entries()) { for (const [templateName, templateMatches] of matchesByTemplate.entries()) {
const templateItem = templateTree.createDiv({ cls: "template-tree-item" }); const templateItem = templateTree.createDiv({ cls: "template-tree-item" });
// Templates start collapsed by default
const templateHeader = templateItem.createDiv({ cls: "template-tree-header" }); const templateHeader = templateItem.createDiv({ cls: "template-tree-header" });
templateHeader.createSpan({ cls: "template-tree-toggle", text: "▶" }); templateHeader.createSpan({ cls: "template-tree-toggle", text: "▶" });
templateHeader.createSpan({ cls: "template-tree-name", text: templateName }); templateHeader.createSpan({ cls: "template-tree-name", text: templateName });
@ -181,6 +192,10 @@ export class ChainWorkbenchModal extends Modal {
const chainsContainer = templateItem.createDiv({ cls: "template-tree-chains" }); const chainsContainer = templateItem.createDiv({ cls: "template-tree-chains" });
// Add data attribute to identify template header and store match count
templateHeader.setAttribute("data-template-name", templateName);
templateMatchCounts.set(templateHeader, templateMatches.length);
// Add chains for this template // Add chains for this template
for (const match of templateMatches) { for (const match of templateMatches) {
const chainItem = chainsContainer.createDiv({ cls: "chain-item" }); const chainItem = chainsContainer.createDiv({ cls: "chain-item" });
@ -188,6 +203,9 @@ export class ChainWorkbenchModal extends Modal {
chainItem.addClass("selected"); chainItem.addClass("selected");
} }
// Store match reference for event delegation
matchMap.set(chainItem, match);
const chainHeader = chainItem.createDiv({ cls: "chain-item-header" }); const chainHeader = chainItem.createDiv({ cls: "chain-item-header" });
chainHeader.createSpan({ chainHeader.createSpan({
cls: "chain-status-icon", cls: "chain-status-icon",
@ -215,21 +233,51 @@ export class ChainWorkbenchModal extends Modal {
text: `Notes: ${Array.from(notes).slice(0, 3).join(", ")}${notes.size > 3 ? "..." : ""}` text: `Notes: ${Array.from(notes).slice(0, 3).join(", ")}${notes.size > 3 ? "..." : ""}`
}); });
} }
}
}
chainItem.addEventListener("click", (e) => { // Store matchMap and counts on the container for event handler access
(this.treeContainer as any).__matchMap = matchMap;
(this.treeContainer as any).__templateMatchCounts = templateMatchCounts;
}
private handleTreeClick(e: MouseEvent): void {
if (!this.treeContainer) return;
const target = e.target as HTMLElement;
// Check if click is on template header or its children
const templateHeader = target.closest(".template-tree-header") as HTMLElement | null;
if (templateHeader) {
e.stopPropagation(); e.stopPropagation();
this.selectedMatch = match; const templateItem = templateHeader.closest(".template-tree-item") as HTMLElement | null;
this.render(); if (templateItem) {
}); const wasExpanded = templateItem.classList.contains("expanded");
templateHeader.addEventListener("click", () => {
templateItem.classList.toggle("expanded"); templateItem.classList.toggle("expanded");
const isExpanded = templateItem.classList.contains("expanded");
const toggle = templateHeader.querySelector(".template-tree-toggle"); const toggle = templateHeader.querySelector(".template-tree-toggle");
if (toggle) { if (toggle) {
toggle.textContent = templateItem.classList.contains("expanded") ? "▼" : "▶"; toggle.textContent = isExpanded ? "▼" : "▶";
} }
}); const matchCounts = (this.treeContainer as any).__templateMatchCounts as Map<HTMLElement, number> | undefined;
const matchCount = matchCounts?.get(templateHeader) ?? 0;
console.log(`[Chain Workbench] Template ${templateHeader.getAttribute("data-template-name")} toggled, expanded: ${isExpanded} (was: ${wasExpanded}), matches: ${matchCount}`);
} }
return;
}
// Check if click is on chain item
const chainItem = target.closest(".chain-item") as HTMLElement | null;
if (chainItem) {
e.stopPropagation();
const matchMap = (this.treeContainer as any).__matchMap as Map<HTMLElement, WorkbenchMatch> | undefined;
const match = matchMap?.get(chainItem);
if (match) {
this.selectedMatch = match;
this.render();
console.log(`[Chain Workbench] Chain item selected: ${match.templateName}`);
}
return;
} }
} }
@ -1231,13 +1279,17 @@ export class ChainWorkbenchModal extends Modal {
const mainContainer = contentEl.createDiv({ cls: "workbench-main" }); const mainContainer = contentEl.createDiv({ cls: "workbench-main" });
// Left: Tree View // Left: Tree View
const treeContainer = mainContainer.createDiv({ cls: "workbench-tree" }); this.treeContainer = mainContainer.createDiv({ cls: "workbench-tree" });
treeContainer.createEl("h3", { text: "Templates & Chains" }); this.treeContainer.createEl("h3", { text: "Templates & Chains" });
// Right: Details View // Right: Details View
const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" }); const detailsContainer = mainContainer.createDiv({ cls: "workbench-details" });
detailsContainer.createEl("h3", { text: "Chain Details" }); detailsContainer.createEl("h3", { text: "Chain Details" });
// Register click handler ONCE (event delegation)
this.clickHandlerBound = this.handleTreeClick.bind(this);
this.treeContainer.addEventListener("click", this.clickHandlerBound);
// Now render the content // Now render the content
console.log("[Chain Workbench] About to call render() - model.matches.length:", this.model.matches.length); console.log("[Chain Workbench] About to call render() - model.matches.length:", this.model.matches.length);
this.render(); this.render();
@ -1249,6 +1301,12 @@ export class ChainWorkbenchModal extends Modal {
} }
onClose(): void { onClose(): void {
// Clean up event handler
if (this.treeContainer && this.clickHandlerBound) {
this.treeContainer.removeEventListener("click", this.clickHandlerBound);
this.clickHandlerBound = null;
}
this.treeContainer = null;
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();
} }

View File

@ -275,6 +275,129 @@ export async function insertEdgeForward(
} }
} }
/**
* Check if an inverse edge already exists between two nodes.
* Returns true if an edge exists in reverse direction (tofrom) with the inverse edge type.
* PROBLEM 1 FIX: Prevents duplicate inverse edges from being created.
*
* IMPORTANT: This function normalizes edge types (aliases canonical) before comparison,
* so it works correctly even if the existing edge uses an alias.
*/
async function checkInverseEdgeExists(
app: App,
fromNodeRef: { file: string; heading: string | null },
toNodeRef: { file: string; heading: string | null },
inverseEdgeType: string,
vocabulary: Vocabulary
): Promise<boolean> {
try {
// Build index for target file (where inverse edge would be)
const { buildNoteIndex } = await import("../analysis/graphIndex");
// Try to find target file
let targetFile: TFile | null = null;
const possiblePaths = [
toNodeRef.file,
toNodeRef.file + ".md",
toNodeRef.file.replace(/\.md$/, ""),
toNodeRef.file.replace(/\.md$/, "") + ".md",
];
for (const path of possiblePaths) {
const found = app.vault.getAbstractFileByPath(path);
if (found && found instanceof TFile) {
targetFile = found;
break;
}
}
// Also try resolving as wikilink
if (!targetFile) {
const basename = toNodeRef.file.replace(/\.md$/, "").split("/").pop() || toNodeRef.file;
const resolved = app.metadataCache.getFirstLinkpathDest(basename, fromNodeRef.file);
if (resolved) {
targetFile = resolved;
}
}
if (!targetFile) {
return false; // Target file doesn't exist, can't have inverse edge
}
const { edges } = await buildNoteIndex(app, targetFile);
// Normalize the expected inverse edge type to canonical (handles aliases)
const expectedInverseCanonical = vocabulary.getCanonical(inverseEdgeType);
if (!expectedInverseCanonical) {
// If inverse type can't be normalized, fall back to direct comparison
console.warn("[checkInverseEdgeExists] Could not normalize inverse edge type:", inverseEdgeType);
}
// Build source basename for matching
const sourceBasename = fromNodeRef.file.replace(/\.md$/, "").split("/").pop() || fromNodeRef.file;
// Check if any edge in target file points back to source with inverse type
for (const edge of edges) {
// Normalize the edge's raw type to canonical (handles aliases)
const edgeCanonical = vocabulary.getCanonical(edge.rawEdgeType);
// Compare canonical types if both can be normalized, otherwise fall back to direct comparison
const typeMatches = expectedInverseCanonical && edgeCanonical
? edgeCanonical === expectedInverseCanonical
: edge.rawEdgeType.toLowerCase() === inverseEdgeType.toLowerCase();
if (!typeMatches) {
continue;
}
// Check if edge target matches source node
const targetBasename = edge.target.file.replace(/\.md$/, "").split("/").pop() || edge.target.file;
// Match: edge source is in target file, edge target matches source node
const sourceFile = "sectionHeading" in edge.source ? edge.source.file : edge.source.file;
const sourceFileBasename = sourceFile.replace(/\.md$/, "").split("/").pop() || sourceFile;
// Check file match
if (sourceFileBasename !== sourceBasename) {
continue;
}
// Check heading match (if applicable)
const sourceHeading = "sectionHeading" in edge.source ? edge.source.sectionHeading : null;
if (fromNodeRef.heading) {
// Source has heading - edge target must match
if (edge.target.heading !== fromNodeRef.heading) {
continue;
}
} else {
// Source has no heading - edge target should also have no heading (or be note-level)
if (edge.target.heading !== null) {
continue;
}
}
// Also check source heading if edge is section-level
if ("sectionHeading" in edge.source && sourceHeading) {
// Edge is from a section in target file - this is correct for inverse edges
// The source heading doesn't need to match fromNodeRef.heading for inverse edges
}
console.log("[checkInverseEdgeExists] Found existing inverse edge:", {
edgeType: edge.rawEdgeType,
sourceFile: sourceFileBasename,
targetFile: targetBasename,
targetHeading: edge.target.heading
});
return true; // Inverse edge already exists!
}
return false;
} catch (error) {
console.error("[checkInverseEdgeExists] Error checking for inverse edge:", error);
return false; // On error, assume no inverse edge exists
}
}
/** /**
* Automatically create inverse edge in target note/section. * Automatically create inverse edge in target note/section.
*/ */
@ -312,6 +435,23 @@ async function createInverseEdge(
console.log("[createInverseEdge] Forward:", forwardEdgeType, "-> Canonical:", canonical, "-> Inverse canonical:", inverseCanonical, "-> Using:", inverseEdgeType); console.log("[createInverseEdge] Forward:", forwardEdgeType, "-> Canonical:", canonical, "-> Inverse canonical:", inverseCanonical, "-> Using:", inverseEdgeType);
// PROBLEM 1 FIX: Check if inverse edge already exists before creating
// IMPORTANT: Pass vocabulary to normalize aliases to canonical types
const inverseExists = await checkInverseEdgeExists(
app,
todo.fromNodeRef,
todo.toNodeRef,
inverseEdgeType,
vocabulary
);
if (inverseExists) {
console.log("[createInverseEdge] Inverse edge already exists, skipping creation");
return; // Don't create duplicate
}
console.log("[createInverseEdge] No inverse edge found, proceeding with creation");
// Find target file // Find target file
const targetFileRef = todo.toNodeRef.file; const targetFileRef = todo.toNodeRef.file;
let targetFile: TFile | null = null; let targetFile: TFile | null = null;