Enhance edge type handling and categorization
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Added optional description and category fields to edge type entries, improving metadata for edge types.
- Updated the `getAllEdgeTypes` and `groupEdgeTypesByCategory` functions to utilize new fields for better organization and display.
- Enhanced UI components to show descriptions as tooltips and categorize edge types in the EdgeTypeChooserModal and InlineEdgeTypeModal.
- Improved parsing logic in `parseEdgeVocabulary` to extract descriptions and categories from the vocabulary table, ensuring richer edge type data.
- Adjusted the LinkPromptModal to clarify edge type actions and maintain alias information during selection.
This commit is contained in:
Lars 2026-01-17 13:59:26 +01:00
parent 7ea36fbed4
commit 2fcf333e56
7 changed files with 353 additions and 68 deletions

View File

@ -39,11 +39,15 @@ export function getAllEdgeTypes(vocabulary: EdgeVocabulary): Array<{
canonical: string;
aliases: string[];
displayName: string; // First alias or canonical
description?: string;
category?: string;
}> {
const result: Array<{
canonical: string;
aliases: string[];
displayName: string;
description?: string;
category?: string;
}> = [];
for (const [canonical, entry] of vocabulary.byCanonical.entries()) {
@ -53,6 +57,8 @@ export function getAllEdgeTypes(vocabulary: EdgeVocabulary): Array<{
canonical,
aliases: entry.aliases,
displayName,
description: entry.description,
category: entry.category,
});
}
@ -64,14 +70,42 @@ export function getAllEdgeTypes(vocabulary: EdgeVocabulary): Array<{
/**
* Group edge types by category (if available).
* MVP: Returns single "All" category.
* Uses category from EdgeTypeEntry if available.
* If categories are found, groups by category. Otherwise returns single "All" group.
*/
export function groupEdgeTypesByCategory(
edgeTypes: Array<{ canonical: string; aliases: string[]; displayName: string }>
): Map<string, Array<{ canonical: string; aliases: string[]; displayName: string }>> {
// TODO: Implement category grouping from schema
// For MVP, return single "All" category
const grouped = new Map<string, Array<{ canonical: string; aliases: string[]; displayName: string }>>();
edgeTypes: Array<{ canonical: string; aliases: string[]; displayName: string; description?: string; category?: string }>
): Map<string, Array<{ canonical: string; aliases: string[]; displayName: string; description?: string; category?: string }>> {
const grouped = new Map<string, Array<{ canonical: string; aliases: string[]; displayName: string; description?: string; category?: string }>>();
// Check if any edge types have categories
const hasCategories = edgeTypes.some(e => e.category && e.category.trim() !== "");
if (hasCategories) {
// Group by category
for (const edgeType of edgeTypes) {
const category = edgeType.category && edgeType.category.trim() !== ""
? edgeType.category.trim()
: "Allgemein"; // Default category if not specified
if (!grouped.has(category)) {
grouped.set(category, []);
}
grouped.get(category)!.push(edgeType);
}
// Sort categories alphabetically
const sortedCategories = Array.from(grouped.keys()).sort();
const sortedGrouped = new Map<string, Array<{ canonical: string; aliases: string[]; displayName: string; description?: string; category?: string }>>();
for (const category of sortedCategories) {
const types = grouped.get(category);
if (types) {
sortedGrouped.set(category, types);
}
}
return sortedGrouped;
} else {
// No categories found, use "All"
grouped.set("All", edgeTypes);
return grouped;
}
}

View File

@ -227,13 +227,18 @@ export async function buildSemanticMappings(
// Apply decision
if (decision.action === "keep") {
// Use the current type as-is (preserves alias if it was an alias)
// item.currentType is already the actual type from existingMappings (could be alias or canonical)
mappingsToUse.set(item.link, decision.edgeType);
if (mappingState.existingMappings.has(item.link)) {
result.existingMappingsKept++;
}
} else if (decision.action === "change") {
// Use canonical type (alias is for display only)
mappingsToUse.set(item.link, decision.edgeType);
// Use alias if provided, otherwise use canonical type
// This ensures selected aliases are written to the file, not just canonical types
const finalEdgeType = decision.alias || decision.edgeType;
mappingsToUse.set(item.link, finalEdgeType);
console.log(`[Mindnet] Setting edge type for ${item.link}: ${finalEdgeType} (canonical: ${decision.edgeType}, alias: ${decision.alias || "none"})`);
if (mappingState.existingMappings.has(item.link)) {
// Changed existing
} else {
@ -248,9 +253,10 @@ export async function buildSemanticMappings(
}
} else {
// Silent modes: none/defaultType/advisor
// Always include existing mappings
// Always include existing mappings (preserve aliases as they are)
for (const [link, edgeType] of mappingState.existingMappings.entries()) {
if (section.links.includes(link)) {
// Use existing type as-is (could be alias or canonical)
mappingsToUse.set(link, edgeType);
result.existingMappingsKept++;
}

View File

@ -52,6 +52,13 @@ export class EdgeTypeChooserModal extends Modal {
const allEdgeTypes = getAllEdgeTypes(this.vocabulary);
const grouped = groupEdgeTypesByCategory(allEdgeTypes);
// Debug: Log grouping info
console.log("[Mindnet] EdgeTypeChooserModal:", {
totalEdgeTypes: allEdgeTypes.length,
categories: Array.from(grouped.keys()),
categorySizes: Array.from(grouped.entries()).map(([cat, types]) => ({ category: cat, count: types.length })),
});
// Recommended section (if any)
if (suggestions.typical.length > 0) {
contentEl.createEl("h3", { text: "⭐ Recommended (schema)" });
@ -61,11 +68,18 @@ export class EdgeTypeChooserModal extends Modal {
const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue;
const displayName = entry.aliases.length > 0 ? entry.aliases[0] : canonical;
// Display: canonical type (primary), aliases shown after selection
const btn = recommendedContainer.createEl("button", {
text: `${displayName}`,
text: `${canonical}`,
cls: "mod-cta",
});
// Add description as tooltip/hover text
if (entry.description) {
btn.title = entry.description;
btn.setAttribute("data-description", entry.description);
}
btn.onclick = () => {
this.selectEdgeType(canonical, entry.aliases);
};
@ -73,16 +87,24 @@ export class EdgeTypeChooserModal extends Modal {
}
// All categories
if (grouped.size > 1 || (grouped.size === 1 && !grouped.has("All"))) {
contentEl.createEl("h3", { text: "📂 All categories" });
}
for (const [category, types] of grouped.entries()) {
if (category !== "All") {
contentEl.createEl("h4", { text: category });
// Show category heading if not "All" or if there are multiple categories
if (category !== "All" || grouped.size > 1) {
const categoryHeading = contentEl.createEl("h4", { text: category });
// Add description if available (from first entry in category)
const firstType = types[0];
if (firstType && firstType.description) {
categoryHeading.title = firstType.description;
}
}
const container = contentEl.createEl("div", { cls: "edge-type-list" });
for (const { canonical, aliases, displayName } of types) {
for (const { canonical, aliases, displayName, description } of types) {
const isProhibited = suggestions.prohibited.includes(canonical);
const isTypical = suggestions.typical.includes(canonical);
@ -90,14 +112,21 @@ export class EdgeTypeChooserModal extends Modal {
if (isTypical) prefix = "⭐";
if (isProhibited) prefix = "🚫";
// Display: canonical type (primary), aliases shown after selection
const btn = container.createEl("button", {
text: `${prefix} ${displayName}`,
text: `${prefix} ${canonical}`,
});
if (isProhibited) {
btn.addClass("prohibited");
}
// Add description as tooltip/hover text
if (description) {
btn.title = description;
btn.setAttribute("data-description", description);
}
btn.onclick = () => {
this.selectEdgeType(canonical, aliases);
};
@ -122,12 +151,8 @@ export class EdgeTypeChooserModal extends Modal {
// No aliases, use canonical directly
this.result = { edgeType: canonical };
this.close();
} else if (aliases.length === 1) {
// Single alias, use it
this.result = { edgeType: canonical, alias: aliases[0] };
this.close();
} else {
// Multiple aliases, show alias chooser
// Always show alias chooser if aliases exist (even for single alias)
this.selectedCanonical = canonical;
this.showAliasChooser(aliases);
}
@ -138,14 +163,26 @@ export class EdgeTypeChooserModal extends Modal {
contentEl.empty();
contentEl.createEl("h2", { text: "Choose alias" });
contentEl.createEl("p", { text: `Multiple aliases available for ${this.selectedCanonical}` });
contentEl.createEl("p", { text: `Aliases available for ${this.selectedCanonical}` });
const container = contentEl.createEl("div", { cls: "alias-list" });
// Option: Use canonical (no alias)
const canonicalBtn = container.createEl("button", {
text: `${this.selectedCanonical} (canonical)`,
cls: "mod-cta",
});
canonicalBtn.onclick = () => {
if (this.selectedCanonical) {
this.result = { edgeType: this.selectedCanonical };
this.close();
}
};
// All aliases
for (const alias of aliases) {
const btn = container.createEl("button", {
text: alias,
cls: "mod-cta",
});
btn.onclick = () => {
if (this.selectedCanonical) {

View File

@ -68,6 +68,20 @@ export class InlineEdgeTypeModal extends Modal {
});
linkInfo.textContent = `Link: [[${this.linkBasename}]]`;
// Show selected type if one was chosen (e.g., from chooser)
if (this.selectedEdgeType) {
const selectedInfo = contentEl.createEl("div", {
cls: "inline-edge-type-modal__selected-info",
});
const displayType = this.selectedAlias || this.selectedEdgeType;
selectedInfo.textContent = `Ausgewählt: ${displayType}`;
selectedInfo.style.marginTop = "0.5em";
selectedInfo.style.padding = "0.5em";
selectedInfo.style.backgroundColor = "var(--background-modifier-hover)";
selectedInfo.style.borderRadius = "4px";
selectedInfo.style.fontWeight = "bold";
}
// Get recommendations
const recommendations = this.getRecommendations();
const typical = recommendations.typical;
@ -80,10 +94,19 @@ export class InlineEdgeTypeModal extends Modal {
return;
}
// Auto-select first typical if available
// Auto-select first typical if available (use canonical, alias selection comes later)
// Only auto-select if no type has been explicitly selected yet
if (typical.length > 0 && typical[0] && !this.selectedEdgeType) {
this.selectedEdgeType = typical[0];
this.result = { chosenRawType: typical[0], cancelled: false };
const firstTypical = typical[0];
this.selectedEdgeType = firstTypical;
// Use canonical as default, alias will be selected in alias chooser if needed
this.result = { chosenRawType: firstTypical, cancelled: false };
}
// If a type was already selected (e.g., from chooser), ensure it's reflected in result
if (this.selectedEdgeType && !this.result) {
const rawType = this.selectedAlias || this.selectedEdgeType;
this.result = { chosenRawType: rawType, cancelled: false };
}
// Recommended chips (typical) - all are directly selectable, first one is preselected
@ -100,21 +123,45 @@ export class InlineEdgeTypeModal extends Modal {
});
for (let i = 0; i < typical.length; i++) {
const edgeType = typical[i];
if (!edgeType) continue;
const canonical = typical[i];
if (!canonical) continue;
// Get entry for description
let description: string | undefined = undefined;
let aliases: string[] = [];
if (this.vocabulary) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (entry) {
description = entry.description;
aliases = entry.aliases;
}
}
// Display: canonical type (primary)
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
text: canonical,
});
// First typical is preselected
if (i === 0) {
// Add description as tooltip/hover text
if (description) {
chip.title = description;
chip.setAttribute("data-description", description);
}
// Mark as selected if this is the currently selected type
const isSelected = this.selectedEdgeType === canonical;
if (isSelected || (i === 0 && !this.selectedEdgeType)) {
chip.addClass("mod-cta");
}
chip.onclick = () => {
this.selectEdgeType(edgeType);
// If aliases exist, show alias chooser, otherwise use canonical
if (aliases.length > 0) {
this.showAliasChooser(canonical, aliases);
} else {
this.selectEdgeType(canonical);
}
};
}
}
@ -133,12 +180,38 @@ export class InlineEdgeTypeModal extends Modal {
cls: "inline-edge-type-modal__chips",
});
for (const edgeType of alternatives) {
for (const canonical of alternatives) {
// Get entry for description
let description: string | undefined = undefined;
let aliases: string[] = [];
if (this.vocabulary) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (entry) {
description = entry.description;
aliases = entry.aliases;
}
}
// Display: canonical type (primary)
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
text: canonical,
});
chip.onclick = () => this.selectEdgeType(edgeType);
// Add description as tooltip/hover text
if (description) {
chip.title = description;
chip.setAttribute("data-description", description);
}
chip.onclick = () => {
// If aliases exist, show alias chooser, otherwise use canonical
if (aliases.length > 0) {
this.showAliasChooser(canonical, aliases);
} else {
this.selectEdgeType(canonical);
}
};
}
}
@ -155,11 +228,28 @@ export class InlineEdgeTypeModal extends Modal {
cls: "inline-edge-type-modal__chips",
});
for (const edgeType of prohibited) {
for (const canonical of prohibited) {
// Get entry for description
let description: string | undefined = undefined;
if (this.vocabulary) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (entry) {
description = entry.description;
}
}
// Display: canonical type (primary)
const chip = chipsContainer.createEl("button", {
cls: "inline-edge-type-modal__chip",
text: edgeType,
text: canonical,
});
// Add description as tooltip/hover text (even for disabled chips)
if (description) {
chip.title = description;
chip.setAttribute("data-description", description);
}
chip.disabled = true;
chip.addClass("mod-muted");
}
@ -183,10 +273,10 @@ export class InlineEdgeTypeModal extends Modal {
);
const choice: EdgeTypeChoice | null = await chooser.show();
if (choice) {
// Store the choice and close modal
// Store the choice and re-render to show selection
this.selectEdgeType(choice.edgeType, choice.alias);
// Close this modal immediately after selection
this.close();
// Re-render the modal to show the selected type
this.onOpen();
}
};
}
@ -201,8 +291,18 @@ export class InlineEdgeTypeModal extends Modal {
cls: "mod-cta",
});
okBtn.onclick = () => {
// If no type selected yet, use preselected
// If no type selected yet, use preselected canonical
if (!this.result && this.selectedEdgeType) {
// Check if aliases exist - if so, show alias chooser first
if (this.vocabulary) {
const entry = this.vocabulary.byCanonical.get(this.selectedEdgeType);
if (entry && entry.aliases.length > 0) {
// Show alias chooser
this.showAliasChooser(this.selectedEdgeType, entry.aliases);
return; // Don't close yet, wait for alias selection
}
}
// No aliases, use canonical directly
this.result = { chosenRawType: this.selectedEdgeType, cancelled: false };
} else if (!this.result) {
// No selection possible, treat as skip
@ -249,27 +349,67 @@ export class InlineEdgeTypeModal extends Modal {
}
private selectEdgeType(edgeType: string, alias?: string | null): void {
// Remove previous selection
const chips = this.contentEl.querySelectorAll(".inline-edge-type-modal__chip");
const chipsArray = Array.from(chips);
for (const chip of chipsArray) {
if (chip instanceof HTMLElement) {
chip.removeClass("mod-cta");
}
}
// Mark selected
const selectedChip = chipsArray.find(
(chip) => chip.textContent === edgeType || chip.textContent?.includes(edgeType)
);
if (selectedChip && selectedChip instanceof HTMLElement) {
selectedChip.addClass("mod-cta");
}
// Store result
// Store result - use alias if provided, otherwise use edgeType (canonical)
this.selectedEdgeType = edgeType;
this.selectedAlias = alias || null;
this.result = { chosenRawType: edgeType, cancelled: false };
// Use alias if available, otherwise use canonical
const rawType = alias || edgeType;
this.result = { chosenRawType: rawType, cancelled: false };
// Note: Visual highlighting will be done in onOpen() when re-rendering
}
private showAliasChooser(canonical: string, aliases: string[]): void {
const { contentEl } = this;
// Store original content structure for restoration
const originalSections = Array.from(contentEl.children);
// Clear and show alias chooser
contentEl.empty();
contentEl.createEl("h2", { text: "Choose alias" });
contentEl.createEl("p", { text: `Aliases available for ${canonical}` });
const container = contentEl.createEl("div", { cls: "alias-list" });
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "0.5em";
container.style.marginTop = "1em";
// Option: Use canonical (no alias)
const canonicalBtn = container.createEl("button", {
text: `${canonical} (canonical)`,
cls: "mod-cta",
});
canonicalBtn.style.width = "100%";
canonicalBtn.style.padding = "0.75em";
canonicalBtn.onclick = () => {
this.selectEdgeType(canonical);
// Restore original content by re-rendering
this.onOpen();
};
// All aliases
for (const alias of aliases) {
const btn = container.createEl("button", {
text: alias,
});
btn.style.width = "100%";
btn.style.padding = "0.75em";
btn.onclick = () => {
this.selectEdgeType(canonical, alias);
// Restore original content by re-rendering
this.onOpen();
};
}
const backBtn = container.createEl("button", { text: "← Back" });
backBtn.style.width = "100%";
backBtn.style.marginTop = "1em";
backBtn.onclick = () => {
// Restore original content by re-rendering
this.onOpen();
};
}
private async openFullChooser(): Promise<void> {
@ -290,8 +430,10 @@ export class InlineEdgeTypeModal extends Modal {
const choice: EdgeTypeChoice | null = await chooser.show();
if (choice) {
// Store the choice and re-render to show selection
this.selectEdgeType(choice.edgeType, choice.alias);
this.close();
// Re-render the modal to show the selected type
this.onOpen();
} else {
// User cancelled chooser, treat as skip
this.result = { chosenRawType: null, cancelled: false };

View File

@ -10,8 +10,8 @@ import { EdgeTypeChooserModal, type EdgeTypeChoice } from "./EdgeTypeChooserModa
import { computeEdgeSuggestions } from "../mapping/schemaHelper";
export type LinkPromptDecision =
| { action: "keep"; edgeType: string }
| { action: "change"; edgeType: string; alias?: string }
| { action: "keep"; edgeType: string } // edgeType is the actual type (alias or canonical) to keep
| { action: "change"; edgeType: string; alias?: string } // edgeType is canonical, alias is the selected alias (if any)
| { action: "skip" };
export class LinkPromptModal extends Modal {

View File

@ -19,12 +19,20 @@ const BACKTICK_RE = /`([^`]+)`/g;
*/
export function parseEdgeVocabulary(md: string): EdgeVocabulary {
const lines = md.split(/\r?\n/);
const byCanonical = new Map<string, { canonical: string; inverse?: string; aliases: string[] }>();
const byCanonical = new Map<string, { canonical: string; inverse?: string; aliases: string[]; description?: string; category?: string }>();
const aliasToCanonical = new Map<string, string>();
let skippedRows = 0;
let currentCategory: string | null = null; // Track current H3 category
for (const line of lines) {
// Detect H3 headings (###) as category separators
const h3Match = line.match(/^###\s+(.+)$/);
if (h3Match && h3Match[1]) {
currentCategory = h3Match[1].trim();
continue;
}
// Skip header separator rows (e.g., "| :--- | :--- |")
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
continue;
@ -35,6 +43,12 @@ export function parseEdgeVocabulary(md: string): EdgeVocabulary {
continue;
}
// Skip header rows (contains "Canonical", "System-Typ", "Beschreibung", "Kategorie", etc.)
// Check for common header keywords
if (/canonical|system-typ|beschreibung|kategorie|category|description|inverser|aliasse/i.test(line)) {
continue;
}
// Extract all backticked tokens
const tokens: string[] = [];
let match: RegExpExecArray | null;
@ -54,6 +68,49 @@ export function parseEdgeVocabulary(md: string): EdgeVocabulary {
continue;
}
// Parse table cells (split by |, skip first and last empty cells)
const cells = line.split("|").map(c => c.trim()).filter(c => c);
// Extract description and category from cells
// Expected order: Canonical | Inverse | Aliases | Description | Category (optional)
let description: string | undefined = undefined;
let category: string | undefined = undefined;
// Try to extract from cells after aliases (index 3+)
// Description is usually the first text cell after aliases
// Category might be in brackets, short, or in a separate column
for (let i = 3; i < cells.length; i++) {
const cell = cells[i];
if (!cell || !cell.trim()) continue;
const trimmed = cell.trim();
// Check if this looks like a category:
// - Short text (< 40 chars)
// - Might be in brackets [Category]
// - Might be all caps
// - Might match category pattern
const looksLikeCategory =
trimmed.length < 40 && (
/^\[.+\]$/.test(trimmed) || // [Category]
trimmed === trimmed.toUpperCase() || // ALL CAPS
/^[A-ZÄÖÜ][a-zäöüß]+(\s+[A-ZÄÖÜ][a-zäöüß]+)*$/.test(trimmed) // Title Case
);
if (looksLikeCategory && !category) {
// Remove brackets if present
category = trimmed.replace(/^\[|\]$/g, "");
} else if (!description && trimmed.length > 0) {
// First substantial cell is likely description
// Remove markdown formatting but keep content
description = trimmed
.replace(/\*\*/g, "") // Remove bold
.replace(/\*/g, "") // Remove italic
.replace(/`/g, "") // Remove code
.trim();
}
}
// Check if aliases cell contains "(Kein Alias)"
const hasNoAliases = /\(Kein Alias\)/i.test(line);
@ -76,11 +133,16 @@ export function parseEdgeVocabulary(md: string): EdgeVocabulary {
}
}
// Store canonical entry
// Store canonical entry with description and category
// Use currentCategory from H3 heading if available, otherwise use extracted category
const finalCategory = currentCategory || category;
byCanonical.set(canonical, {
canonical,
inverse,
aliases,
description,
category: finalCategory,
});
// Build alias-to-canonical mapping (case-insensitive keys)
@ -95,7 +157,9 @@ export function parseEdgeVocabulary(md: string): EdgeVocabulary {
}
if (skippedRows > 0) {
console.warn(`parseEdgeVocabulary: Skipped ${skippedRows} rows with insufficient tokens`);
// Only warn if there are actually problematic rows (not just header/separator rows)
// Header and separator rows are expected and should not trigger warnings
console.debug(`parseEdgeVocabulary: Skipped ${skippedRows} data rows with insufficient tokens (this is normal if the file contains empty or malformed table rows)`);
}
return {

View File

@ -4,6 +4,8 @@ export interface EdgeTypeEntry {
canonical: CanonicalEdgeType;
inverse?: CanonicalEdgeType;
aliases: string[];
description?: string; // Description from vocabulary table
category?: string; // Category/group from vocabulary table
}
export interface EdgeVocabulary {