Enhance InterviewWizardModal and SectionEdgesOverviewModal for improved link handling
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

- 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.
This commit is contained in:
Lars 2026-01-30 19:13:50 +01:00
parent 054cfcf82d
commit c044d6e8db
4 changed files with 361 additions and 92 deletions

View File

@ -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<ResolvedTargetType> {
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<HeadingWithType[]> {
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;
}

View File

@ -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,

View File

@ -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<void> {
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<LinkTargetPick | null> {
return new Promise((resolve) => {
this.resolve = resolve;
this.open();
});
}
onClose(): void {
if (this.resolve) {
this.resolve(null);
this.resolve = null;
}
}
}

View File

@ -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<string, unknown>; // 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<string, unknown>
collectedData?: Map<string, unknown>,
activeFilePath?: string
) {
super(app);
this.sectionSequence = sectionSequence;
this.vocabulary = vocabulary;
this.graphSchema = graphSchema;
this.collectedData = collectedData || new Map();
this.activeFilePath = activeFilePath || "";
}
async onOpen(): Promise<void> {
@ -468,13 +472,12 @@ export class SectionEdgesOverviewModal extends Modal {
private async buildNoteEdgeList(): Promise<void> {
this.noteEdges = [];
// Extrahiere alle Note-Links aus gesammelten Daten
const noteLinksBySection = new Map<string | null, Set<string>>(); // blockId -> Set<noteBasename>
// Extrahiere alle Note-Links aus gesammelten Daten (vollständiger Link: Note oder Note#Abschnitt)
const noteLinksBySection = new Map<string | null, Set<string>>(); // blockId -> Set<fullLink>
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,