Enhance edge type suggestions and UI in LinkPromptModal
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 support for alternative edge types in the EdgeTypeSuggestion interface, improving user guidance during edge type selection.
- Updated computeEdgeSuggestions function to compute and limit alternative edge types based on typical and prohibited types.
- Enhanced LinkPromptModal to display recommended and alternative edge types, improving user experience with clearer selection options.
- Introduced new action for setting typical edge types, allowing for more intuitive edge type management.
This commit is contained in:
Lars 2026-01-17 20:30:53 +01:00
parent 2fcf333e56
commit 7e256bd2e9
3 changed files with 356 additions and 95 deletions

View File

@ -8,6 +8,7 @@ import { getHints } from "./graphSchema";
export interface EdgeTypeSuggestion {
typical: string[]; // Recommended edge types
alternatives: string[]; // Alternative edge types (not typical, not prohibited)
prohibited: string[]; // Prohibited edge types
}
@ -21,14 +22,36 @@ export function computeEdgeSuggestions(
targetType: string | null,
graphSchema: GraphSchema | null = null
): EdgeTypeSuggestion {
let typical: string[] = [];
let prohibited: string[] = [];
if (graphSchema) {
return getHints(graphSchema, sourceType, targetType);
const hints = getHints(graphSchema, sourceType, targetType);
typical = hints.typical;
prohibited = hints.prohibited;
}
// Compute alternatives: limit to a reasonable number of common edge types
// Only show alternatives if we have typical types (meaning schema is available)
// Otherwise, don't show alternatives to avoid overwhelming the user
let alternatives: string[] = [];
if (typical.length > 0) {
// Only show alternatives if we have schema-based recommendations
// Limit to first 6-8 most common edge types that aren't typical or prohibited
const allCanonicalTypes = Array.from(vocabulary.byCanonical.keys());
const filtered = allCanonicalTypes.filter(canonical => {
const isTypical = typical.includes(canonical);
const isProhibited = prohibited.includes(canonical);
return !isTypical && !isProhibited;
});
// Limit to 8 alternatives max
alternatives = filtered.slice(0, 8);
}
// No schema available, return empty
return {
typical: [],
prohibited: [],
typical,
alternatives,
prohibited,
};
}

View File

@ -233,7 +233,7 @@ export async function buildSemanticMappings(
if (mappingState.existingMappings.has(item.link)) {
result.existingMappingsKept++;
}
} else if (decision.action === "change") {
} else if (decision.action === "change" || decision.action === "setTypical") {
// 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;

View File

@ -1,5 +1,6 @@
/**
* Modal for prompting edge type assignment for a single link.
* Enhanced with preselection similar to InlineEdgeTypeModal.
*/
import { Modal } from "obsidian";
@ -12,6 +13,7 @@ import { computeEdgeSuggestions } from "../mapping/schemaHelper";
export type LinkPromptDecision =
| { 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: "setTypical"; edgeType: string; alias?: string } // Set to first typical type (canonical, alias optional)
| { action: "skip" };
export class LinkPromptModal extends Modal {
@ -21,6 +23,8 @@ export class LinkPromptModal extends Modal {
private graphSchema: GraphSchema | null;
private result: LinkPromptDecision | null = null;
private resolve: ((result: LinkPromptDecision) => void) | null = null;
private selectedEdgeType: string | null = null;
private selectedAlias: string | null = null;
constructor(
app: any,
@ -37,10 +41,26 @@ export class LinkPromptModal extends Modal {
}
onOpen(): void {
// Always show quick chooser as main view
this.showQuickChooser();
}
private showQuickChooser(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("link-prompt-modal");
// Get recommendations
const suggestions = computeEdgeSuggestions(
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const typical = suggestions.typical;
const alternatives = suggestions.alternatives;
const prohibited = suggestions.prohibited;
// Link info
const linkInfo = contentEl.createEl("div", { cls: "link-info" });
linkInfo.createEl("h2", { text: `Link: [[${this.item.link}]]` });
@ -49,114 +69,210 @@ export class LinkPromptModal extends Modal {
linkInfo.createEl("p", { text: `Target type: ${this.item.targetType}` });
}
// Current mapping (if exists)
if (this.item.currentType) {
const currentInfo = linkInfo.createEl("p", { cls: "current-mapping" });
currentInfo.textContent = `Current edge type: ${this.item.currentType}`;
// Show current/selected edge type prominently
const selectedTypeDisplay = this.getSelectedTypeDisplay();
if (selectedTypeDisplay) {
const selectedInfo = contentEl.createEl("div", {
cls: "selected-edge-type",
});
selectedInfo.style.marginTop = "1em";
selectedInfo.style.padding = "0.75em";
selectedInfo.style.backgroundColor = "var(--background-modifier-hover)";
selectedInfo.style.borderRadius = "4px";
selectedInfo.style.border = "2px solid var(--interactive-accent)";
// Check if current type is prohibited
if (this.graphSchema) {
const suggestions = computeEdgeSuggestions(
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
if (suggestions.prohibited.includes(this.item.currentType)) {
const warning = linkInfo.createEl("span", {
text: " ⚠️ Prohibited",
cls: "prohibited-warning",
const selectedLabel = selectedInfo.createEl("div", {
text: "Ausgewählte Beziehung:",
cls: "selected-label",
});
selectedLabel.style.fontSize = "0.9em";
selectedLabel.style.color = "var(--text-muted)";
selectedLabel.style.marginBottom = "0.25em";
const selectedValue = selectedInfo.createEl("div", {
text: selectedTypeDisplay,
cls: "selected-value",
});
selectedValue.style.fontSize = "1.1em";
selectedValue.style.fontWeight = "bold";
selectedValue.style.color = "var(--text-normal)";
} else if (this.item.currentType) {
// Show existing type if no new selection
const currentInfo = contentEl.createEl("div", {
cls: "current-edge-type",
});
currentInfo.style.marginTop = "1em";
currentInfo.style.padding = "0.75em";
currentInfo.style.backgroundColor = "var(--background-modifier-hover)";
currentInfo.style.borderRadius = "4px";
const currentLabel = currentInfo.createEl("div", {
text: "Aktuelle Beziehung:",
cls: "current-label",
});
currentLabel.style.fontSize = "0.9em";
currentLabel.style.color = "var(--text-muted)";
currentLabel.style.marginBottom = "0.25em";
const currentValue = currentInfo.createEl("div", {
text: this.item.currentType,
cls: "current-value",
});
currentValue.style.fontSize = "1.1em";
currentValue.style.fontWeight = "bold";
// Check if current type is typical, alternative, or prohibited
const currentTypeLower = this.item.currentType.toLowerCase();
const currentCanonical = this.vocabulary.aliasToCanonical.get(currentTypeLower) || this.item.currentType;
const isTypical = typical.includes(currentCanonical);
const isProhibited = prohibited.includes(currentCanonical);
if (isTypical) {
currentValue.style.color = "var(--text-success)";
currentValue.textContent = `${this.item.currentType}`;
} else if (isProhibited) {
currentValue.style.color = "var(--text-error)";
currentValue.textContent = `${this.item.currentType} ⚠️`;
} else {
currentValue.style.color = "var(--text-warning)";
currentValue.textContent = `${this.item.currentType} ⚠️`;
}
}
// Quick-Chooser: Recommended and Alternatives
if (typical.length > 0 || alternatives.length > 0) {
// Recommended chips (typical)
if (typical.length > 0) {
const typicalContainer = contentEl.createEl("div", {
cls: "link-prompt-modal__section",
});
typicalContainer.createEl("div", {
cls: "link-prompt-modal__section-label",
text: "⭐ Recommended:",
});
const chipsContainer = typicalContainer.createEl("div", {
cls: "link-prompt-modal__chips",
});
chipsContainer.style.display = "flex";
chipsContainer.style.flexWrap = "wrap";
chipsContainer.style.gap = "0.5em";
chipsContainer.style.marginTop = "0.5em";
for (const canonical of typical) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue;
const chip = chipsContainer.createEl("button", {
cls: "link-prompt-modal__chip",
text: canonical,
});
warning.style.color = "var(--text-error)";
warning.style.fontWeight = "bold";
if (entry.description) {
chip.title = entry.description;
}
// Mark as selected if this is the currently selected type
const isSelected = this.isSelectedType(canonical);
if (isSelected) {
chip.addClass("mod-cta");
}
chip.onclick = async () => {
await this.handleChipSelection(canonical, entry.aliases);
};
}
}
// Alternatives chips
if (alternatives.length > 0) {
const altContainer = contentEl.createEl("div", {
cls: "link-prompt-modal__section",
});
altContainer.createEl("div", {
cls: "link-prompt-modal__section-label",
text: "Alternatives:",
});
const chipsContainer = altContainer.createEl("div", {
cls: "link-prompt-modal__chips",
});
chipsContainer.style.display = "flex";
chipsContainer.style.flexWrap = "wrap";
chipsContainer.style.gap = "0.5em";
chipsContainer.style.marginTop = "0.5em";
for (const canonical of alternatives) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (!entry) continue;
const chip = chipsContainer.createEl("button", {
cls: "link-prompt-modal__chip",
text: canonical,
});
if (entry.description) {
chip.title = entry.description;
}
// Mark as selected if this is the currently selected type
const isSelected = this.isSelectedType(canonical);
if (isSelected) {
chip.addClass("mod-cta");
}
chip.onclick = async () => {
await this.handleChipSelection(canonical, entry.aliases);
};
}
}
}
// Link to full Edge-Chooser
const chooserLink = contentEl.createEl("button", {
text: "Alle Kanten anzeigen...",
cls: "link-prompt-modal__chooser-link",
});
chooserLink.style.marginTop = "1em";
chooserLink.style.width = "100%";
chooserLink.onclick = async () => {
await this.showFullChooser();
};
// Action buttons
const buttonContainer = contentEl.createEl("div", { cls: "action-buttons" });
buttonContainer.style.display = "flex";
buttonContainer.style.flexDirection = "column";
buttonContainer.style.flexDirection = "row";
buttonContainer.style.gap = "0.5em";
buttonContainer.style.marginTop = "1em";
buttonContainer.style.marginTop = "1.5em";
buttonContainer.style.justifyContent = "flex-end";
if (this.item.currentType) {
// Keep current
// OK/Save button - confirms selection
const okBtn = buttonContainer.createEl("button", {
text: this.item.currentType ? "Speichern" : "OK",
cls: "mod-cta",
});
okBtn.onclick = () => {
this.confirmSelection();
};
// Keep current button (only if current type exists and no new selection)
if (this.item.currentType && !this.selectedEdgeType) {
const keepBtn = buttonContainer.createEl("button", {
text: `✅ Keep (${this.item.currentType})`,
cls: "mod-cta",
text: `Behalten (${this.item.currentType})`,
});
keepBtn.onclick = () => {
this.result = { action: "keep", edgeType: this.item.currentType! };
this.close();
};
// Change type
const changeBtn = buttonContainer.createEl("button", {
text: "Change type...",
});
changeBtn.onclick = async () => {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const choice = await chooser.show();
if (choice) {
this.result = {
action: "change",
edgeType: choice.edgeType,
alias: choice.alias,
};
this.close();
}
};
// Skip
const skipBtn = buttonContainer.createEl("button", {
text: "Skip link",
});
skipBtn.onclick = () => {
this.result = { action: "skip" };
this.close();
};
} else {
// No current mapping
// Skip
const skipBtn = buttonContainer.createEl("button", {
text: "⏩ Skip link",
});
skipBtn.onclick = () => {
this.result = { action: "skip" };
this.close();
};
// Choose type
const chooseBtn = buttonContainer.createEl("button", {
text: "Choose type...",
cls: "mod-cta",
});
chooseBtn.onclick = async () => {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const choice = await chooser.show();
if (choice) {
this.result = {
action: "change",
edgeType: choice.edgeType,
alias: choice.alias,
};
this.close();
}
};
}
// Skip button
const skipBtn = buttonContainer.createEl("button", {
text: "Überspringen",
});
skipBtn.onclick = () => {
this.result = { action: "skip" };
this.close();
};
}
onClose(): void {
@ -180,4 +296,126 @@ export class LinkPromptModal extends Modal {
this.open();
});
}
private selectEdgeType(edgeType: string, alias?: string | null): void {
this.selectedEdgeType = edgeType;
this.selectedAlias = alias || null;
}
private isSelectedType(canonical: string): boolean {
if (!this.selectedEdgeType) return false;
// Check if selected canonical matches
if (this.selectedEdgeType === canonical) return true;
// Check if selected alias belongs to this canonical
if (this.selectedAlias) {
const entry = this.vocabulary.byCanonical.get(canonical);
if (entry && entry.aliases.includes(this.selectedAlias)) return true;
}
return false;
}
private getSelectedTypeDisplay(): string | null {
if (this.selectedEdgeType) {
// Show alias if selected, otherwise canonical
return this.selectedAlias || this.selectedEdgeType;
}
return null;
}
private async handleChipSelection(canonical: string, aliases: string[]): Promise<void> {
if (aliases.length > 0) {
// Show alias chooser
const alias = await this.showAliasChooser(canonical, aliases);
if (alias !== null && alias !== undefined) {
this.selectEdgeType(canonical, alias);
// Return to quick chooser to show selection
this.showQuickChooser();
}
} else {
// No aliases, select canonical directly
this.selectEdgeType(canonical);
// Return to quick chooser to show selection
this.showQuickChooser();
}
}
private async showFullChooser(): Promise<void> {
const chooser = new EdgeTypeChooserModal(
this.app,
this.vocabulary,
this.sourceType,
this.item.targetType,
this.graphSchema
);
const choice = await chooser.show();
if (choice) {
// Store selection and return to quick chooser
this.selectEdgeType(choice.edgeType, choice.alias);
this.showQuickChooser();
}
}
private confirmSelection(): void {
if (this.selectedEdgeType) {
// New selection made
this.result = {
action: "change",
edgeType: this.selectedEdgeType,
alias: this.selectedAlias || undefined,
};
this.close();
} else if (this.item.currentType) {
// No new selection, keep current
this.result = { action: "keep", edgeType: this.item.currentType };
this.close();
} else {
// No selection and no current type, skip
this.result = { action: "skip" };
this.close();
}
}
private async showAliasChooser(canonical: string, aliases: string[]): Promise<string | null> {
return new Promise((resolve) => {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Alias auswählen" });
contentEl.createEl("p", { text: `Verfügbare Aliase für ${canonical}:` });
const container = contentEl.createEl("div");
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "0.5em";
container.style.marginTop = "1em";
// Canonical option
const canonicalBtn = container.createEl("button", {
text: `${canonical} (canonical)`,
cls: "mod-cta",
});
canonicalBtn.style.width = "100%";
canonicalBtn.style.padding = "0.75em";
canonicalBtn.onclick = () => {
resolve(canonical);
};
// Alias options
for (const alias of aliases) {
const btn = container.createEl("button", { text: alias });
btn.style.width = "100%";
btn.style.padding = "0.75em";
btn.onclick = () => {
resolve(alias);
};
}
const backBtn = container.createEl("button", { text: "← Zurück" });
backBtn.style.width = "100%";
backBtn.style.marginTop = "1em";
backBtn.onclick = () => {
resolve(null);
};
});
}
}