From c044d6e8db7345435c9a7132426b9a1b77f55d74 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 30 Jan 2026 19:13:50 +0100 Subject: [PATCH] Enhance InterviewWizardModal and SectionEdgesOverviewModal for improved link handling - Integrated LinkTargetPickerModal into InterviewWizardModal to allow users to select link targets dynamically, enhancing user experience during note linking. - Updated link handling logic to support full links and improved edge type resolution in SectionEdgesOverviewModal, ensuring accurate target type identification. - Refactored code to streamline the extraction of note links and their associated types, improving overall functionality and maintainability. --- src/interview/targetTypeResolver.ts | 136 +++++++++++++++++++++++++++ src/ui/InterviewWizardModal.ts | 114 ++++++++++++----------- src/ui/LinkTargetPickerModal.ts | 139 ++++++++++++++++++++++++++++ src/ui/SectionEdgesOverviewModal.ts | 64 +++++-------- 4 files changed, 361 insertions(+), 92 deletions(-) create mode 100644 src/interview/targetTypeResolver.ts create mode 100644 src/ui/LinkTargetPickerModal.ts diff --git a/src/interview/targetTypeResolver.ts b/src/interview/targetTypeResolver.ts new file mode 100644 index 0000000..efe9fed --- /dev/null +++ b/src/interview/targetTypeResolver.ts @@ -0,0 +1,136 @@ +/** + * Ermittelt Zieltyp für Note-Links: Note-Type (ganze Note) oder Sektionstyp (bei Note#Abschnitt). + * Wird in Kantenübersicht und Toolbar-Link-Auswahl genutzt. + */ + +import type { App } from "obsidian"; +import { TFile } from "obsidian"; + +/** Link ohne Alias: "Note" oder "Note#Abschnitt" */ +export function parseNoteLink(linkTarget: string): { basename: string; heading: string | null } { + const withoutAlias = linkTarget.split("|")[0]?.trim() || linkTarget; + const sharp = withoutAlias.indexOf("#"); + if (sharp === -1) { + return { basename: withoutAlias.trim(), heading: null }; + } + const basename = withoutAlias.slice(0, sharp).trim(); + const heading = withoutAlias.slice(sharp + 1).trim() || null; + return { basename, heading }; +} + +/** + * Liest aus Dateiinhalt den Sektionstyp für eine Überschrift. + * Erwartet Format: ## Überschrift (optional ^block-id), nächste Zeile > [!section] typ + */ +export function getSectionTypeForHeading(content: string, heading: string): string | null { + const lines = content.split("\n"); + const headingNorm = heading.trim().toLowerCase(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const match = line.match(/^(#{1,6})\s+(.+?)(?:\s+\^[\w-]+)?\s*$/); + if (match && match[2]) { + const lineHeading = match[2].trim(); + if (lineHeading.toLowerCase() === headingNorm) { + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine === undefined) continue; + const sectionMatch = nextLine.match(/^\s*>\s*\[!section\]\s+(.+)\s*$/); + if (sectionMatch && sectionMatch[1]) { + return sectionMatch[1].trim(); + } + } + return null; + } + } + } + return null; +} + +export interface ResolvedTargetType { + targetType: string | null; + displayLabel: string; + basename: string; + heading: string | null; +} + +/** + * Ermittelt Zieltyp für einen Note-Link (ganze Note → Note-Type, mit #Abschnitt → Sektionstyp). + * @param app Obsidian App + * @param linkTarget z.B. "Note" oder "Note#Abschnitt" (ohne Alias) + * @param sourcePath Pfad der Quelldatei für getFirstLinkpathDest + */ +export async function resolveTargetTypeForNoteLink( + app: App, + linkTarget: string, + sourcePath: string +): Promise { + const { basename, heading } = parseNoteLink(linkTarget); + const displayLabel = heading ? `${basename}#${heading}` : basename; + + let targetType: string | null = null; + try { + const targetFile = app.metadataCache.getFirstLinkpathDest(basename, sourcePath); + if (!targetFile || !(targetFile instanceof TFile)) { + return { targetType: null, displayLabel, basename, heading }; + } + + if (heading) { + const content = await app.vault.read(targetFile); + targetType = getSectionTypeForHeading(content, heading); + } + + if (targetType === null) { + const cache = app.metadataCache.getFileCache(targetFile); + if (cache?.frontmatter) { + const t = cache.frontmatter.type ?? cache.frontmatter.noteType; + targetType = t != null ? String(t) : null; + } + } + } catch { + // ignore + } + + return { targetType, displayLabel, basename, heading }; +} + +/** + * Liest aus einer Note alle Überschriften mit zugehörigem Sektionstyp (falls vorhanden). + * Für Toolbar-Auswahl: "Ganze Note" oder "## Überschrift (typ)". + */ +export interface HeadingWithType { + heading: string; + sectionType: string | null; + level: number; +} + +export async function getHeadingsWithSectionTypes( + app: App, + file: TFile +): Promise { + const content = await app.vault.read(file); + const lines = content.split("\n"); + const result: HeadingWithType[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const match = line.match(/^(#{1,6})\s+(.+?)(?:\s+\^[\w-]+)?\s*$/); + if (match && match[1] && match[2]) { + const level = match[1].length; + const heading = match[2].trim(); + let sectionType: string | null = null; + for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) { + const nextLine = lines[j]; + if (nextLine === undefined) continue; + const sectionMatch = nextLine.match(/^\s*>\s*\[!section\]\s+(.+)\s*$/); + if (sectionMatch && sectionMatch[1]) { + sectionType = sectionMatch[1].trim(); + break; + } + } + result.push({ heading, sectionType, level }); + } + } + return result; +} diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 23ebeb3..cb427d2 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -38,6 +38,7 @@ import { buildSemanticMappings, type BuildResult } from "../mapping/semanticMapp import type { MindnetSettings } from "../settings"; import { InlineEdgeTypeModal, type InlineEdgeTypeResult } from "./InlineEdgeTypeModal"; import { SectionEdgesOverviewModal, type SectionEdgesOverviewResult } from "./SectionEdgesOverviewModal"; +import { LinkTargetPickerModal } from "./LinkTargetPickerModal"; import { getSectionKeyForWizardContext } from "../interview/sectionKeyResolver"; import type { PendingEdgeAssignment } from "../interview/wizardState"; import { VocabularyLoader } from "../vocab/VocabularyLoader"; @@ -601,39 +602,43 @@ export class InterviewWizardModal extends Modal { app, this.noteIndex, async (result: EntityPickerResult) => { - // Check if inline micro edging is enabled (also for toolbar) - // Support: inline_micro, both (inline_micro + post_run) const edgingMode = this.profile.edging?.mode; - const shouldRunInlineMicro = + const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; - - let linkText = `[[${result.basename}]]`; - - if (shouldRunInlineMicro) { - // Get current step for section key resolution - const currentStep = getCurrentStep(this.state); - if (currentStep) { - console.log("[Mindnet] Starting inline micro edging from toolbar"); - const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); - if (edgeType && typeof edgeType === "string") { - // Use [[rel:type|link]] format - linkText = `[[rel:${edgeType}|${result.basename}]]`; - } + const currentStep = getCurrentStep(this.state); + + let linkTarget = result.basename; + if (shouldRunInlineMicro && currentStep) { + const file = this.app.vault.getAbstractFileByPath(result.path); + if (file && file instanceof TFile) { + const picker = new LinkTargetPickerModal( + this.app, + file, + result.basename, + this.file?.path + ); + const pick = await picker.show(); + if (pick) linkTarget = pick.linkTarget; } } - - // Insert link with rel: prefix if edge type was selected - // Extract inner part (without [[ and ]]) + + let linkText = `[[${linkTarget}]]`; + if (shouldRunInlineMicro && currentStep) { + const edgeType = await this.handleInlineMicroEdging(currentStep, linkTarget, result.path); + if (edgeType && typeof edgeType === "string") { + linkText = `[[rel:${edgeType}|${linkTarget}]]`; + } + } + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); } ).open(); }; - + blockIdModal.open(); } else { - // Keine Block-IDs vorhanden, öffne direkt Entity-Picker if (!this.noteIndex) { new Notice("Note index not available"); return; @@ -642,30 +647,35 @@ export class InterviewWizardModal extends Modal { app, this.noteIndex, async (result: EntityPickerResult) => { - // Check if inline micro edging is enabled (also for toolbar) - // Support: inline_micro, both (inline_micro + post_run) const edgingMode = this.profile.edging?.mode; - const shouldRunInlineMicro = + const shouldRunInlineMicro = (edgingMode === "inline_micro" || edgingMode === "both") && this.settings?.inlineMicroEnabled !== false; - - let linkText = `[[${result.basename}]]`; - - if (shouldRunInlineMicro) { - // Get current step for section key resolution - const currentStep = getCurrentStep(this.state); - if (currentStep) { - console.log("[Mindnet] Starting inline micro edging from toolbar"); - const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); - if (edgeType && typeof edgeType === "string") { - // Use [[rel:type|link]] format - linkText = `[[rel:${edgeType}|${result.basename}]]`; - } + const currentStep = getCurrentStep(this.state); + + let linkTarget = result.basename; + if (shouldRunInlineMicro && currentStep) { + const file = this.app.vault.getAbstractFileByPath(result.path); + if (file && file instanceof TFile) { + const picker = new LinkTargetPickerModal( + this.app, + file, + result.basename, + this.file?.path + ); + const pick = await picker.show(); + if (pick) linkTarget = pick.linkTarget; } } - - // Insert link with rel: prefix if edge type was selected - // Extract inner part (without [[ and ]]) + + let linkText = `[[${linkTarget}]]`; + if (shouldRunInlineMicro && currentStep) { + const edgeType = await this.handleInlineMicroEdging(currentStep, linkTarget, result.path); + if (edgeType && typeof edgeType === "string") { + linkText = `[[rel:${edgeType}|${linkTarget}]]`; + } + } + const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); insertWikilinkIntoTextarea(textarea, innerLink); } @@ -2593,7 +2603,8 @@ export class InterviewWizardModal extends Modal { this.state.sectionSequence, this.vocabulary, graphSchema, - this.state.collectedData // Übergebe gesammelte Daten für Note-Link-Extraktion + this.state.collectedData, + this.file?.path // Quelldatei für Link-Auflösung (Zieltyp ermitteln) ); const result = await overviewModal.show(); @@ -2906,27 +2917,26 @@ export class InterviewWizardModal extends Modal { sourceNoteId = extractFrontmatterId(sourceContent) || undefined; } - // Get target note ID and type + // Zieltyp ermitteln: Note-Type (ganze Note) oder Sektionstyp (bei Note#Abschnitt) try { + const { resolveTargetTypeForNoteLink } = await import("../interview/targetTypeResolver"); + const resolved = await resolveTargetTypeForNoteLink( + this.app, + linkBasename, + this.file?.path || "" + ); + targetType = resolved.targetType ?? undefined; const targetFile = this.app.vault.getAbstractFileByPath(linkPath); if (targetFile && targetFile instanceof TFile) { const targetContent = await this.app.vault.read(targetFile); targetNoteId = extractFrontmatterId(targetContent) || undefined; - const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/); - if (targetFrontmatter && targetFrontmatter[1]) { - const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m); - if (typeMatch && typeMatch[1]) { - targetType = typeMatch[1].trim(); - } - } } } catch (e) { - // Target note might not exist yet, that's OK - console.debug("[Mindnet] Could not read target note for inline micro:", e); + console.debug("[Mindnet] Could not resolve target type for inline micro:", e); } } - // Show inline edge type modal + // Show inline edge type modal (linkBasename kann "Note" oder "Note#Abschnitt" sein) const modal = new InlineEdgeTypeModal( this.app, linkBasename, diff --git a/src/ui/LinkTargetPickerModal.ts b/src/ui/LinkTargetPickerModal.ts new file mode 100644 index 0000000..7a6aceb --- /dev/null +++ b/src/ui/LinkTargetPickerModal.ts @@ -0,0 +1,139 @@ +/** + * Modal zur Auswahl des Link-Ziels: Ganze Note oder ein Abschnitt (Überschrift inkl. Sektionstyp). + * Wird in der Interview-Toolbar nach der Note-Auswahl angezeigt. + * Speichert Link als [[Note]] oder [[Note#Abschnitt]]. + */ + +import { App, Modal, TFile } from "obsidian"; +import { + getHeadingsWithSectionTypes, + resolveTargetTypeForNoteLink, + type HeadingWithType, +} from "../interview/targetTypeResolver"; + +export interface LinkTargetPick { + linkTarget: string; // "Note" oder "Note#Abschnitt" + targetType: string | null; +} + +export class LinkTargetPickerModal extends Modal { + private file: TFile; + private basename: string; + private sourceFilePath: string; + private resolve: ((pick: LinkTargetPick | null) => void) | null = null; + + constructor(app: App, file: TFile, basename: string, sourceFilePath?: string) { + super(app); + this.file = file; + this.basename = basename; + this.sourceFilePath = sourceFilePath || ""; + } + + async onOpen(): Promise { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("link-target-picker-modal"); + + contentEl.createEl("h2", { text: "Link-Ziel wählen" }); + contentEl.createEl("p", { + text: `Zielnote: ${this.basename}. Ganze Note oder einen Abschnitt auswählen.`, + cls: "link-target-picker-description", + }); + + let noteType: string | null = null; + try { + const resolved = await resolveTargetTypeForNoteLink( + this.app, + this.basename, + this.sourceFilePath + ); + noteType = resolved.targetType; + } catch { + // ignore + } + + const list = contentEl.createEl("div", { cls: "link-target-picker-list" }); + + // Option: Ganze Note + const wholeNoteBtn = list.createEl("button", { cls: "link-target-option" }); + wholeNoteBtn.style.display = "block"; + wholeNoteBtn.style.width = "100%"; + wholeNoteBtn.style.textAlign = "left"; + wholeNoteBtn.style.padding = "0.5em 0.75em"; + wholeNoteBtn.style.marginBottom = "0.25em"; + wholeNoteBtn.style.cursor = "pointer"; + wholeNoteBtn.style.border = "1px solid var(--background-modifier-border)"; + wholeNoteBtn.style.borderRadius = "4px"; + const wholeLabel = wholeNoteBtn.createEl("span", { text: "Ganze Note" }); + wholeLabel.style.fontWeight = "bold"; + if (noteType) { + wholeNoteBtn.createEl("span", { text: ` (Typ: ${noteType})`, cls: "target-type" }).style.color = + "var(--text-muted)"; + } + wholeNoteBtn.onclick = () => { + if (this.resolve) { + this.resolve({ linkTarget: this.basename, targetType: noteType }); + this.resolve = null; + } + this.close(); + }; + + let headings: HeadingWithType[] = []; + try { + headings = await getHeadingsWithSectionTypes(this.app, this.file); + } catch { + // ignore + } + + for (const h of headings) { + const headingBtn = list.createEl("button", { cls: "link-target-option" }); + headingBtn.style.display = "block"; + headingBtn.style.width = "100%"; + headingBtn.style.textAlign = "left"; + headingBtn.style.padding = "0.5em 0.75em"; + headingBtn.style.marginBottom = "0.25em"; + headingBtn.style.cursor = "pointer"; + headingBtn.style.border = "1px solid var(--background-modifier-border)"; + headingBtn.style.borderRadius = "4px"; + const prefix = "#".repeat(h.level) + " "; + headingBtn.createEl("span", { text: prefix + h.heading }); + if (h.sectionType) { + headingBtn + .createEl("span", { text: ` (Sektion: ${h.sectionType})`, cls: "target-type" }) + .style.color = "var(--text-muted)"; + } + const linkTarget = `${this.basename}#${h.heading}`; + headingBtn.onclick = () => { + if (this.resolve) { + this.resolve({ linkTarget, targetType: h.sectionType }); + this.resolve = null; + } + this.close(); + }; + } + + const cancelBtn = contentEl.createEl("button", { text: "Abbrechen", cls: "mod-secondary" }); + cancelBtn.style.marginTop = "1em"; + cancelBtn.onclick = () => { + if (this.resolve) { + this.resolve(null); + this.resolve = null; + } + this.close(); + }; + } + + show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } + + onClose(): void { + if (this.resolve) { + this.resolve(null); + this.resolve = null; + } + } +} diff --git a/src/ui/SectionEdgesOverviewModal.ts b/src/ui/SectionEdgesOverviewModal.ts index d4be0c8..d228f98 100644 --- a/src/ui/SectionEdgesOverviewModal.ts +++ b/src/ui/SectionEdgesOverviewModal.ts @@ -9,6 +9,7 @@ import type { EdgeVocabulary } from "../vocab/types"; import type { GraphSchema } from "../mapping/graphSchema"; import { EdgeTypeChooserModal, type EdgeTypeChoice } from "./EdgeTypeChooserModal"; import { getHints } from "../mapping/graphSchema"; +import { resolveTargetTypeForNoteLink } from "../interview/targetTypeResolver"; export interface SectionEdge { fromSection: SectionInfo; @@ -20,10 +21,10 @@ export interface SectionEdge { export interface NoteEdge { fromSection: SectionInfo | null; // null = Note-Level - toNote: string; // Note-Basename + toNote: string; // Vollständiger Link: Note oder Note#Abschnitt (für Anzeige und Kantenzuordnung) edgeType: string; // Aktueller Edge-Type (kann geändert werden) suggestedType: string | null; // Vorgeschlagener Edge-Type aus graph_schema - targetType: string | null; // Target-Note-Type (aus Frontmatter) + targetType: string | null; // Zieltyp: Note-Type (ganze Note) oder Sektionstyp (bei Note#Abschnitt) } export interface SectionEdgesOverviewResult { @@ -37,6 +38,7 @@ export class SectionEdgesOverviewModal extends Modal { private vocabulary: EdgeVocabulary | null; private graphSchema: GraphSchema | null; private collectedData: Map; // Gesammelte Daten aus dem Interview + private activeFilePath: string; // Quelldatei für Link-Auflösung (getFirstLinkpathDest) private sectionEdges: SectionEdge[] = []; private noteEdges: NoteEdge[] = []; private result: SectionEdgesOverviewResult | null = null; @@ -47,13 +49,15 @@ export class SectionEdgesOverviewModal extends Modal { sectionSequence: SectionInfo[], vocabulary: EdgeVocabulary | null, graphSchema: GraphSchema | null, - collectedData?: Map + collectedData?: Map, + activeFilePath?: string ) { super(app); this.sectionSequence = sectionSequence; this.vocabulary = vocabulary; this.graphSchema = graphSchema; this.collectedData = collectedData || new Map(); + this.activeFilePath = activeFilePath || ""; } async onOpen(): Promise { @@ -468,13 +472,12 @@ export class SectionEdgesOverviewModal extends Modal { private async buildNoteEdgeList(): Promise { this.noteEdges = []; - // Extrahiere alle Note-Links aus gesammelten Daten - const noteLinksBySection = new Map>(); // blockId -> Set + // Extrahiere alle Note-Links aus gesammelten Daten (vollständiger Link: Note oder Note#Abschnitt) + const noteLinksBySection = new Map>(); // blockId -> Set for (const [key, value] of this.collectedData.entries()) { if (!value || typeof value !== "string") continue; - // Finde zugehörige Section für diesen Step let sectionInfo: SectionInfo | null = null; for (const section of this.sectionSequence) { if (section.stepKey === key) { @@ -483,63 +486,46 @@ export class SectionEdgesOverviewModal extends Modal { } } - // Extrahiere Wikilinks aus dem Text const wikilinkRegex = /\[\[([^\]]+?)\]\]/g; let match: RegExpExecArray | null; while ((match = wikilinkRegex.exec(value)) !== null) { if (match[1]) { const linkTarget = match[1].trim(); if (!linkTarget) continue; - - // Prüfe, ob es ein Block-ID-Link ist ([[#^...]]) - if (linkTarget.startsWith("#^")) { - continue; // Block-ID-Links sind Section-Edges, keine Note-Edges - } - - // Normalisiere Link (entferne Alias und Heading) - const normalized = linkTarget.split("|")[0]?.split("#")[0]?.trim() || linkTarget; - if (!normalized) continue; + if (linkTarget.startsWith("#^")) continue; // Block-ID-Links sind Section-Edges + // Ziel-Link: Bei [[rel:type|Note#Abschnitt]] den Teil nach dem ersten | nehmen, sonst ganzer Link + const fullLink = linkTarget.includes("|") + ? linkTarget.split("|").slice(1).join("|").trim() || linkTarget.trim() + : linkTarget.split("|")[0]?.trim() || linkTarget; + if (!fullLink || fullLink.startsWith("rel:")) continue; // rel: allein ist kein gültiger Ziel-Link const blockId = sectionInfo?.blockId || null; if (!noteLinksBySection.has(blockId)) { noteLinksBySection.set(blockId, new Set()); } - noteLinksBySection.get(blockId)!.add(normalized); + noteLinksBySection.get(blockId)!.add(fullLink); } } } - // Erstelle NoteEdge-Objekte for (const [blockId, noteLinks] of noteLinksBySection.entries()) { const sectionInfo = blockId ? this.sectionSequence.find(s => s.blockId === blockId) || null : null; - // WP-26: Verwende effektiven Section-Type (mit Heading-Level-basierter Fallback-Logik) const sectionIndex = blockId ? this.sectionSequence.findIndex(s => s.blockId === blockId) : -1; const sourceType = sectionInfo && sectionIndex >= 0 ? this.getEffectiveSectionType(sectionInfo, sectionIndex) : null; - for (const noteBasename of noteLinks) { - // Ermittle Target-Note-Type - let targetType: string | null = null; - try { - const targetFile = this.app.metadataCache.getFirstLinkpathDest(noteBasename, ""); - if (targetFile) { - const cache = this.app.metadataCache.getFileCache(targetFile); - if (cache?.frontmatter) { - targetType = cache.frontmatter.type || cache.frontmatter.noteType || null; - if (targetType && typeof targetType !== "string") { - targetType = String(targetType); - } - } - } - } catch (e) { - // Ignore errors - } + for (const fullLink of noteLinks) { + const resolved = await resolveTargetTypeForNoteLink( + this.app, + fullLink, + this.activeFilePath + ); + const targetType = resolved.targetType; - // Ermittle vorgeschlagenen Edge-Type aus graph_schema let suggestedType: string | null = null; if (this.graphSchema && sourceType && targetType) { const hints = getHints(this.graphSchema, sourceType, targetType); @@ -547,15 +533,13 @@ export class SectionEdgesOverviewModal extends Modal { suggestedType = hints.typical[0] || null; } } - - // Fallback: references if (!suggestedType) { suggestedType = "references"; } this.noteEdges.push({ fromSection: sectionInfo, - toNote: noteBasename, + toNote: resolved.displayLabel, edgeType: suggestedType, suggestedType: suggestedType, targetType: targetType,