diff --git a/src/mapping/schemaHelper.ts b/src/mapping/schemaHelper.ts index d399444..61d152e 100644 --- a/src/mapping/schemaHelper.ts +++ b/src/mapping/schemaHelper.ts @@ -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, }; } diff --git a/src/mapping/semanticMappingBuilder.ts b/src/mapping/semanticMappingBuilder.ts index ff9bf26..47aa73e 100644 --- a/src/mapping/semanticMappingBuilder.ts +++ b/src/mapping/semanticMappingBuilder.ts @@ -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; diff --git a/src/ui/LinkPromptModal.ts b/src/ui/LinkPromptModal.ts index 87cb9f5..91af0e2 100644 --- a/src/ui/LinkPromptModal.ts +++ b/src/ui/LinkPromptModal.ts @@ -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 { + 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 { + 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 { + 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); + }; + }); + } }