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 { export interface EdgeTypeSuggestion {
typical: string[]; // Recommended edge types typical: string[]; // Recommended edge types
alternatives: string[]; // Alternative edge types (not typical, not prohibited)
prohibited: string[]; // Prohibited edge types prohibited: string[]; // Prohibited edge types
} }
@ -21,14 +22,36 @@ export function computeEdgeSuggestions(
targetType: string | null, targetType: string | null,
graphSchema: GraphSchema | null = null graphSchema: GraphSchema | null = null
): EdgeTypeSuggestion { ): EdgeTypeSuggestion {
let typical: string[] = [];
let prohibited: string[] = [];
if (graphSchema) { 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 { return {
typical: [], typical,
prohibited: [], alternatives,
prohibited,
}; };
} }

View File

@ -233,7 +233,7 @@ export async function buildSemanticMappings(
if (mappingState.existingMappings.has(item.link)) { if (mappingState.existingMappings.has(item.link)) {
result.existingMappingsKept++; result.existingMappingsKept++;
} }
} else if (decision.action === "change") { } else if (decision.action === "change" || decision.action === "setTypical") {
// Use alias if provided, otherwise use canonical type // Use alias if provided, otherwise use canonical type
// This ensures selected aliases are written to the file, not just canonical types // This ensures selected aliases are written to the file, not just canonical types
const finalEdgeType = decision.alias || decision.edgeType; const finalEdgeType = decision.alias || decision.edgeType;

View File

@ -1,5 +1,6 @@
/** /**
* Modal for prompting edge type assignment for a single link. * Modal for prompting edge type assignment for a single link.
* Enhanced with preselection similar to InlineEdgeTypeModal.
*/ */
import { Modal } from "obsidian"; import { Modal } from "obsidian";
@ -12,6 +13,7 @@ import { computeEdgeSuggestions } from "../mapping/schemaHelper";
export type LinkPromptDecision = export type LinkPromptDecision =
| { action: "keep"; edgeType: string } // edgeType is the actual type (alias or canonical) to keep | { 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: "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" }; | { action: "skip" };
export class LinkPromptModal extends Modal { export class LinkPromptModal extends Modal {
@ -21,6 +23,8 @@ export class LinkPromptModal extends Modal {
private graphSchema: GraphSchema | null; private graphSchema: GraphSchema | null;
private result: LinkPromptDecision | null = null; private result: LinkPromptDecision | null = null;
private resolve: ((result: LinkPromptDecision) => void) | null = null; private resolve: ((result: LinkPromptDecision) => void) | null = null;
private selectedEdgeType: string | null = null;
private selectedAlias: string | null = null;
constructor( constructor(
app: any, app: any,
@ -37,10 +41,26 @@ export class LinkPromptModal extends Modal {
} }
onOpen(): void { onOpen(): void {
// Always show quick chooser as main view
this.showQuickChooser();
}
private showQuickChooser(): void {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();
contentEl.addClass("link-prompt-modal"); 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 // Link info
const linkInfo = contentEl.createEl("div", { cls: "link-info" }); const linkInfo = contentEl.createEl("div", { cls: "link-info" });
linkInfo.createEl("h2", { text: `Link: [[${this.item.link}]]` }); 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}` }); linkInfo.createEl("p", { text: `Target type: ${this.item.targetType}` });
} }
// Current mapping (if exists) // Show current/selected edge type prominently
if (this.item.currentType) { const selectedTypeDisplay = this.getSelectedTypeDisplay();
const currentInfo = linkInfo.createEl("p", { cls: "current-mapping" }); if (selectedTypeDisplay) {
currentInfo.textContent = `Current edge type: ${this.item.currentType}`; const selectedInfo = contentEl.createEl("div", {
cls: "selected-edge-type",
// 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",
}); });
warning.style.color = "var(--text-error)"; selectedInfo.style.marginTop = "1em";
warning.style.fontWeight = "bold"; selectedInfo.style.padding = "0.75em";
selectedInfo.style.backgroundColor = "var(--background-modifier-hover)";
selectedInfo.style.borderRadius = "4px";
selectedInfo.style.border = "2px solid var(--interactive-accent)";
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,
});
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 // Action buttons
const buttonContainer = contentEl.createEl("div", { cls: "action-buttons" }); const buttonContainer = contentEl.createEl("div", { cls: "action-buttons" });
buttonContainer.style.display = "flex"; buttonContainer.style.display = "flex";
buttonContainer.style.flexDirection = "column"; buttonContainer.style.flexDirection = "row";
buttonContainer.style.gap = "0.5em"; buttonContainer.style.gap = "0.5em";
buttonContainer.style.marginTop = "1em"; buttonContainer.style.marginTop = "1.5em";
buttonContainer.style.justifyContent = "flex-end";
if (this.item.currentType) { // OK/Save button - confirms selection
// Keep current const okBtn = buttonContainer.createEl("button", {
const keepBtn = buttonContainer.createEl("button", { text: this.item.currentType ? "Speichern" : "OK",
text: `✅ Keep (${this.item.currentType})`,
cls: "mod-cta", 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: `Behalten (${this.item.currentType})`,
});
keepBtn.onclick = () => { keepBtn.onclick = () => {
this.result = { action: "keep", edgeType: this.item.currentType! }; this.result = { action: "keep", edgeType: this.item.currentType! };
this.close(); 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 // Skip button
const skipBtn = buttonContainer.createEl("button", { const skipBtn = buttonContainer.createEl("button", {
text: "Skip link", text: "Überspringen",
}); });
skipBtn.onclick = () => { skipBtn.onclick = () => {
this.result = { action: "skip" }; this.result = { action: "skip" };
this.close(); 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();
}
};
}
} }
onClose(): void { onClose(): void {
@ -180,4 +296,126 @@ export class LinkPromptModal extends Modal {
this.open(); 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);
};
});
}
} }