Implement WP-26 features for Section-Types, Block-IDs, and Edge Suggestions
Some checks failed
Node.js build / build (20.x) (push) Has been cancelled
Node.js build / build (22.x) (push) Has been cancelled

- Enhanced the interview configuration parsing to support section_type, block_id, and generate_block_id properties.
- Updated the renderer to incorporate Section-Types and Block-IDs, allowing for automatic edge suggestions during markdown rendering.
- Introduced new RenderOptions for improved handling of graph schema and vocabulary in the rendering process.
- Implemented tracking of Section-Info during the wizard flow, including updates for loop items and nested steps.
- Enhanced the InterviewWizardModal to support Block-ID selection for intra-note links, improving user experience and functionality.
This commit is contained in:
Lars 2026-01-27 11:18:56 +01:00
parent 3be7d617fe
commit b99416b67d
6 changed files with 1379 additions and 122 deletions

View File

@ -320,6 +320,40 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
step.prompt = raw.prompt.trim(); step.prompt = raw.prompt.trim();
} }
// WP-26: Parse section_type
if (typeof raw.section_type === "string" && raw.section_type.trim()) {
step.section_type = raw.section_type.trim();
}
// WP-26: Parse block_id
if (typeof raw.block_id === "string" && raw.block_id.trim()) {
step.block_id = raw.block_id.trim();
}
// WP-26: Parse generate_block_id
if (typeof raw.generate_block_id === "boolean") {
step.generate_block_id = raw.generate_block_id;
}
// WP-26: Parse references
if (Array.isArray(raw.references)) {
step.references = [];
for (const refRaw of raw.references) {
if (refRaw && typeof refRaw === "object") {
const ref = refRaw as Record<string, unknown>;
if (typeof ref.block_id === "string" && ref.block_id.trim()) {
const reference: { block_id: string; edge_type?: string } = {
block_id: ref.block_id.trim(),
};
if (typeof ref.edge_type === "string" && ref.edge_type.trim()) {
reference.edge_type = ref.edge_type.trim();
}
step.references.push(reference);
}
}
}
}
// Parse output.template // Parse output.template
if (raw.output && typeof raw.output === "object") { if (raw.output && typeof raw.output === "object") {
const output = raw.output as Record<string, unknown>; const output = raw.output as Record<string, unknown>;
@ -363,6 +397,40 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
}; };
} }
// WP-26: Parse section_type
if (typeof raw.section_type === "string" && raw.section_type.trim()) {
step.section_type = raw.section_type.trim();
}
// WP-26: Parse block_id
if (typeof raw.block_id === "string" && raw.block_id.trim()) {
step.block_id = raw.block_id.trim();
}
// WP-26: Parse generate_block_id
if (typeof raw.generate_block_id === "boolean") {
step.generate_block_id = raw.generate_block_id;
}
// WP-26: Parse references
if (Array.isArray(raw.references)) {
step.references = [];
for (const refRaw of raw.references) {
if (refRaw && typeof refRaw === "object") {
const ref = refRaw as Record<string, unknown>;
if (typeof ref.block_id === "string" && ref.block_id.trim()) {
const reference: { block_id: string; edge_type?: string } = {
block_id: ref.block_id.trim(),
};
if (typeof ref.edge_type === "string" && ref.edge_type.trim()) {
reference.edge_type = ref.edge_type.trim();
}
step.references.push(reference);
}
}
}
}
// Parse output.template // Parse output.template
if (raw.output && typeof raw.output === "object") { if (raw.output && typeof raw.output === "object") {
const output = raw.output as Record<string, unknown>; const output = raw.output as Record<string, unknown>;

View File

@ -0,0 +1,305 @@
/**
* Tests für WP-26 Interview-Wizard Renderer
* Prüft Section-Types, Block-IDs, automatische Edge-Vorschläge und Selbstreferenz-Prüfung
*/
import { describe, it, expect } from "vitest";
import { renderProfileToMarkdown, type RenderAnswers, type RenderOptions } from "./renderer";
import type { InterviewProfile } from "./types";
import type { GraphSchema, EdgeTypeHints } from "../mapping/graphSchema";
import { Vocabulary } from "../vocab/Vocabulary";
import { parseEdgeVocabulary } from "../vocab/parseEdgeVocabulary";
describe("WP-26 Interview Renderer", () => {
// Mock Vocabulary
const mockVocabularyText = `| System-Typ (Canonical) | Inverser Typ | Erlaubte Aliasse (User) | Beschreibung |
|------------------------|--------------|-------------------------|-------------|
| resulted_in | resulted_from| führt zu, bewirkt | Forward edge |
| resulted_from | resulted_in | stammt aus, kommt von | Inverse edge |
| related_to | related_to | verbunden mit | Bidirectional |`;
const mockVocabulary = new Vocabulary(parseEdgeVocabulary(mockVocabularyText));
// Mock GraphSchema
const mockGraphSchema: GraphSchema = {
schema: new Map<string, Map<string, EdgeTypeHints>>([
[
"experience",
new Map<string, EdgeTypeHints>([
[
"insight",
{
typical: ["resulted_in"],
prohibited: [],
},
],
]),
],
]),
};
const mockOptions: RenderOptions = {
graphSchema: mockGraphSchema,
vocabulary: mockVocabulary,
noteType: "experience",
};
it("sollte keine Selbstreferenz bei automatischen Edges generieren", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "context",
section: "## Kontext",
section_type: "experience",
generate_block_id: true,
},
{
type: "capture_text",
key: "insight",
section: "## Einsicht",
section_type: "insight",
generate_block_id: true,
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["context", "Kontext-Text"],
["insight", "Einsicht-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Prüfe, dass keine Selbstreferenz vorhanden ist
const contextSection = result.match(/## Kontext.*?## Einsicht/s)?.[0];
expect(contextSection).toBeDefined();
// Prüfe, dass keine Edge auf sich selbst zeigt
const selfReference = contextSection?.match(/\[\[#\^context\]\]/);
expect(selfReference).toBeNull();
// Prüfe, dass Edges generiert wurden
const edges = result.match(/> \[!edge\]/g);
expect(edges?.length).toBeGreaterThan(0);
});
it("sollte Edge-Types aus graph_schema verwenden", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "experience",
section: "## Erfahrung",
section_type: "experience",
generate_block_id: true,
},
{
type: "capture_text",
key: "insight",
section: "## Einsicht",
section_type: "insight",
generate_block_id: true,
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["experience", "Erfahrungs-Text"],
["insight", "Einsicht-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Debug: Zeige das Ergebnis
console.log("Render-Ergebnis:", result);
// Prüfe, dass Edges generiert wurden
const edges = result.match(/> \[!edge\]/g);
expect(edges?.length).toBeGreaterThan(0);
// Prüfe, dass "resulted_in" oder "resulted_from" verwendet wird (aus graph_schema)
// Falls graph_schema nicht verfügbar ist, wird "related_to" verwendet (Fallback)
const hasResultedEdge = result.match(/resulted_(in|from)/);
const hasRelatedEdge = result.match(/related_to/);
// Mindestens eine Edge sollte vorhanden sein
expect(hasResultedEdge || hasRelatedEdge).toBeTruthy();
// Wenn graph_schema verfügbar ist, sollte "resulted_in" oder "resulted_from" verwendet werden
if (mockOptions.graphSchema) {
expect(hasResultedEdge).toBeTruthy();
}
});
it("sollte Backlinks automatisch generieren", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "context",
section: "## Kontext",
section_type: "experience",
generate_block_id: true,
},
{
type: "capture_text",
key: "insight",
section: "## Einsicht",
section_type: "insight",
generate_block_id: true,
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["context", "Kontext-Text"],
["insight", "Einsicht-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Prüfe, dass beide Edge-Types vorhanden sind (Forward und Backward)
const forwardEdge = result.match(/resulted_in/);
const backwardEdge = result.match(/resulted_from/);
expect(forwardEdge).toBeDefined();
expect(backwardEdge).toBeDefined();
});
it("sollte keine Selbstreferenz bei expliziten Referenzen erlauben", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "context",
section: "## Kontext",
section_type: "experience",
block_id: "context",
references: [
{
block_id: "context", // Selbstreferenz - sollte verhindert werden
edge_type: "related_to",
},
],
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["context", "Kontext-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Prüfe, dass keine Selbstreferenz generiert wurde
const selfReference = result.match(/\[\[#\^context\]\]/);
// Sollte null sein, da Selbstreferenz verhindert wird
expect(selfReference).toBeNull();
});
it("sollte Section-Type-Callout direkt nach Heading platzieren", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "context",
section: "## Kontext",
section_type: "experience",
generate_block_id: true,
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["context", "Kontext-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Prüfe, dass Section-Type-Callout direkt nach Heading steht
const sectionPattern = /## Kontext.*?\n> \[!section\] experience/s;
expect(result).toMatch(sectionPattern);
});
it("sollte Edges am Ende der Sektion platzieren", () => {
const profile: InterviewProfile = {
version: "2.0",
frontmatterWhitelist: [],
key: "test",
label: "Test",
note_type: "experience",
steps: [
{
type: "capture_text",
key: "context",
section: "## Kontext",
section_type: "experience",
generate_block_id: true,
},
{
type: "capture_text",
key: "insight",
section: "## Einsicht",
section_type: "insight",
generate_block_id: true,
},
],
};
const answers: RenderAnswers = {
collectedData: new Map([
["context", "Kontext-Text"],
["insight", "Einsicht-Text"],
]),
loopContexts: new Map(),
};
const result = renderProfileToMarkdown(profile, answers, mockOptions);
// Prüfe, dass Edges am Ende der Einsicht-Sektion stehen (nach dem Text)
const insightSection = result.match(/## Einsicht.*?Einsicht-Text\n\n(> \[!edge\].*)/s)?.[1];
expect(insightSection).toBeDefined();
expect(insightSection).toMatch(/> \[!edge\]/);
});
});

View File

@ -1,27 +1,68 @@
/** /**
* Markdown renderer for interview profiles. * Markdown renderer for interview profiles.
* Converts collected answers into markdown output based on profile configuration. * Converts collected answers into markdown output based on profile configuration.
* WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge.
*/ */
import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep, EntityPickerStep } from "./types"; import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep, EntityPickerStep } from "./types";
import type { SectionInfo } from "./wizardState";
import type { GraphSchema } from "../mapping/graphSchema";
import type { Vocabulary } from "../vocab/Vocabulary";
import { slugify } from "./slugify";
import { getHints } from "../mapping/graphSchema";
export interface RenderAnswers { export interface RenderAnswers {
collectedData: Map<string, unknown>; collectedData: Map<string, unknown>;
loopContexts: Map<string, unknown[]>; loopContexts: Map<string, unknown[]>;
// WP-26: Optional - Section-Sequenz für automatische Edge-Vorschläge
sectionSequence?: SectionInfo[];
}
// WP-26: Render-Optionen für Section-Types und Edge-Vorschläge
export interface RenderOptions {
graphSchema?: GraphSchema | null;
vocabulary?: Vocabulary | null;
noteType?: string; // Fallback für effective_type
} }
/** /**
* Render profile answers to markdown string. * Render profile answers to markdown string.
* Deterministic and testable. * Deterministic and testable.
* WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge.
*/ */
export function renderProfileToMarkdown( export function renderProfileToMarkdown(
profile: InterviewProfile, profile: InterviewProfile,
answers: RenderAnswers answers: RenderAnswers,
options?: RenderOptions
): string { ): string {
const output: string[] = []; const output: string[] = [];
// WP-26: Verwende übergebene Section-Sequenz oder erstelle neue
// Die Section-Sequenz wird während des interaktiven Wizard-Durchlaufs getrackt
const sectionSequence: SectionInfo[] = answers.sectionSequence ? [...answers.sectionSequence] : [];
const generatedBlockIds = new Map<string, SectionInfo>();
// WP-26: Wenn Section-Sequenz übergeben wurde, fülle generatedBlockIds aus der Sequenz
if (answers.sectionSequence && answers.sectionSequence.length > 0) {
for (const sectionInfo of answers.sectionSequence) {
if (sectionInfo.blockId) {
generatedBlockIds.set(sectionInfo.blockId, sectionInfo);
}
}
}
// WP-26: Track Section-Sequenz während des Renderns (für Steps, die noch nicht getrackt wurden)
for (const step of profile.steps) { for (const step of profile.steps) {
const stepOutput = renderStep(step, answers); const stepOutput = renderStep(
step,
answers,
{
profile,
sectionSequence,
generatedBlockIds,
options: options || {},
}
);
if (stepOutput) { if (stepOutput) {
output.push(stepOutput); output.push(stepOutput);
} }
@ -30,20 +71,33 @@ export function renderProfileToMarkdown(
return output.join("\n\n").trim(); return output.join("\n\n").trim();
} }
// WP-26: Render-Kontext für Section-Types und Block-IDs
interface RenderContext {
profile: InterviewProfile;
sectionSequence: SectionInfo[];
generatedBlockIds: Map<string, SectionInfo>;
options: RenderOptions;
}
/** /**
* Render a single step to markdown. * Render a single step to markdown.
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
*/ */
function renderStep(step: InterviewStep, answers: RenderAnswers): string | null { function renderStep(
step: InterviewStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
switch (step.type) { switch (step.type) {
case "capture_text": case "capture_text":
return renderCaptureText(step, answers); return renderCaptureText(step, answers, context);
case "capture_text_line": case "capture_text_line":
return renderCaptureTextLine(step, answers); return renderCaptureTextLine(step, answers, context);
case "capture_frontmatter": case "capture_frontmatter":
// Frontmatter is handled separately, skip here // Frontmatter is handled separately, skip here
return null; return null;
case "loop": case "loop":
return renderLoop(step, answers); return renderLoop(step, answers, context);
case "entity_picker": case "entity_picker":
return renderEntityPicker(step, answers); return renderEntityPicker(step, answers);
case "instruction": case "instruction":
@ -58,8 +112,13 @@ function renderStep(step: InterviewStep, answers: RenderAnswers): string | null
/** /**
* Render capture_text step. * Render capture_text step.
* WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge.
*/ */
function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): string | null { function renderCaptureText(
step: CaptureTextStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
const value = answers.collectedData.get(step.key); const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") { if (!value || String(value).trim() === "") {
return null; return null;
@ -67,18 +126,66 @@ function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): strin
const text = String(value); const text = String(value);
// WP-26: Block-ID-Generierung
let blockId: string | null = null;
if (step.block_id) {
blockId = step.block_id;
} else if (step.generate_block_id) {
blockId = slugify(step.key);
}
// WP-26: Heading mit Block-ID erweitern
let heading = step.section || "";
if (heading && blockId) {
// Füge Block-ID zu Heading hinzu: "## Heading" -> "## Heading ^block-id"
heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`);
}
// WP-26: Section-Type-Callout generieren (direkt nach Heading)
const sectionTypeCallout = step.section_type
? `> [!section] ${step.section_type}`
: "";
// WP-26: Section-Info für Tracking
const sectionInfo: SectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || step.key,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden)
if (step.section) {
context.sectionSequence.push(sectionInfo);
}
// WP-26: Referenzen generieren (am Ende der Sektion)
const references = renderReferences(step.references || [], context, sectionInfo);
// WP-26: Automatische Edge-Vorschläge generieren (am Ende der Sektion)
const autoEdges = renderAutomaticEdges(sectionInfo, context);
// Use template if provided // Use template if provided
if (step.output?.template) { if (step.output?.template) {
return renderTemplate(step.output.template, { const templateText = renderTemplate(step.output.template, {
text, text,
field: step.key, field: step.key,
value: text, value: text,
}); });
// WP-26: Template mit Heading, Section-Type und Edges kombinieren
return combineSectionParts(heading, sectionTypeCallout, templateText, references, autoEdges);
} }
// Default: use section if provided, otherwise just the text // Default: use section if provided, otherwise just the text
if (step.section) { if (step.section) {
return `${step.section}\n\n${text}`; return combineSectionParts(heading, sectionTypeCallout, text, references, autoEdges);
} }
return text; return text;
@ -86,8 +193,13 @@ function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): strin
/** /**
* Render capture_text_line step. * Render capture_text_line step.
* WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge.
*/ */
function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers): string | null { function renderCaptureTextLine(
step: CaptureTextLineStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
const value = answers.collectedData.get(step.key); const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") { if (!value || String(value).trim() === "") {
return null; return null;
@ -110,14 +222,98 @@ function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers
} }
} }
// WP-26: Block-ID-Generierung
let blockId: string | null = null;
if (step.block_id) {
blockId = step.block_id;
} else if (step.generate_block_id) {
blockId = slugify(step.key);
}
// WP-26: Heading mit Block-ID erweitern (wenn heading_prefix vorhanden)
let heading = "";
if (headingPrefix) {
heading = headingPrefix + text;
if (blockId) {
// Füge Block-ID zu Heading hinzu
heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`);
}
}
// WP-26: Section-Type-Callout generieren (direkt nach Heading)
const sectionTypeCallout = step.section_type
? `> [!section] ${step.section_type}`
: "";
// WP-26: Section-Info für Tracking (nur wenn Heading vorhanden)
if (headingPrefix) {
const sectionInfo: SectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || text,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren
context.sectionSequence.push(sectionInfo);
}
// WP-26: Section-Info für Tracking (nur wenn Heading vorhanden)
let sectionInfo: SectionInfo | null = null;
if (headingPrefix) {
sectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || text,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren
context.sectionSequence.push(sectionInfo);
}
// WP-26: Referenzen generieren (am Ende der Sektion)
const references = sectionInfo
? renderReferences(step.references || [], context, sectionInfo)
: "";
// WP-26: Automatische Edge-Vorschläge generieren (nur wenn Heading vorhanden)
const autoEdges = sectionInfo
? renderAutomaticEdges(sectionInfo, context)
: "";
// Use template if provided // Use template if provided
if (step.output?.template) { if (step.output?.template) {
return renderTemplate(step.output.template, { const templateText = renderTemplate(step.output.template, {
text: headingPrefix + text, text: headingPrefix + text,
field: step.key, field: step.key,
value: headingPrefix + text, value: headingPrefix + text,
heading_level: headingLevel ? String(headingLevel) : "", heading_level: headingLevel ? String(headingLevel) : "",
}); });
// WP-26: Template mit Heading, Section-Type und Edges kombinieren (wenn Heading vorhanden)
if (headingPrefix) {
return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges);
}
return templateText;
}
// WP-26: Wenn Heading vorhanden, mit Section-Type und Edges kombinieren
if (headingPrefix) {
return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges);
} }
// Default: text with heading prefix if configured // Default: text with heading prefix if configured
@ -175,16 +371,23 @@ function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderA
/** /**
* Render loop step (recursive, supports arbitrary nesting depth). * Render loop step (recursive, supports arbitrary nesting depth).
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
*/ */
function renderLoop(step: LoopStep, answers: RenderAnswers): string | null { function renderLoop(step: LoopStep, answers: RenderAnswers, context: RenderContext): string | null {
return renderLoopRecursive(step, answers, 0); return renderLoopRecursive(step, answers, context, 0);
} }
/** /**
* Recursive helper to render loop items with nested steps. * Recursive helper to render loop items with nested steps.
* Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size). * Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size).
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
*/ */
function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: number): string | null { function renderLoopRecursive(
step: LoopStep,
answers: RenderAnswers,
context: RenderContext,
depth: number
): string | null {
// Safety check: prevent infinite recursion (practical limit: 100 levels) // Safety check: prevent infinite recursion (practical limit: 100 levels)
if (depth > 100) { if (depth > 100) {
console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`); console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`);
@ -214,49 +417,39 @@ function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: numb
const text = String(fieldValue); const text = String(fieldValue);
const captureStep = nestedStep as CaptureTextStep; const captureStep = nestedStep as CaptureTextStep;
// Use template if provided // WP-26: Verwende erweiterte renderCaptureText Funktion
if (captureStep.output?.template) { const rendered = renderCaptureText(captureStep, {
itemParts.push(renderTemplate(captureStep.output.template, { collectedData: new Map([[nestedStep.key, fieldValue]]),
text, loopContexts: answers.loopContexts,
field: nestedStep.key, }, context);
value: text,
})); if (rendered) {
} else { itemParts.push(rendered);
// Default: just the text
itemParts.push(text);
} }
} }
} else if (nestedStep.type === "capture_text_line") { } else if (nestedStep.type === "capture_text_line") {
if (fieldValue && String(fieldValue).trim()) { if (fieldValue && String(fieldValue).trim()) {
const text = String(fieldValue);
const captureStep = nestedStep as CaptureTextLineStep; const captureStep = nestedStep as CaptureTextLineStep;
// Get heading level if configured (for nested loops, check in item data) // WP-26: Erstelle temporäres collectedData mit allen Item-Daten
const itemData = new Map<string, unknown>();
itemData.set(nestedStep.key, fieldValue);
// Füge heading_level hinzu falls vorhanden
const headingLevelKey = `${nestedStep.key}_heading_level`; const headingLevelKey = `${nestedStep.key}_heading_level`;
const headingLevel = (item as Record<string, unknown>)[headingLevelKey]; const headingLevel = (item as Record<string, unknown>)[headingLevelKey];
let headingPrefix = ""; if (headingLevel !== undefined) {
if (captureStep.heading_level?.enabled) { itemData.set(headingLevelKey, headingLevel);
if (typeof headingLevel === "number") {
const level = Math.max(1, Math.min(6, headingLevel));
headingPrefix = "#".repeat(level) + " ";
} else if (captureStep.heading_level.default) {
// Fallback to default if not set in item
const level = Math.max(1, Math.min(6, captureStep.heading_level.default));
headingPrefix = "#".repeat(level) + " ";
}
} }
// Use template if provided // WP-26: Verwende erweiterte renderCaptureTextLine Funktion
if (captureStep.output?.template) { const rendered = renderCaptureTextLine(captureStep, {
itemParts.push(renderTemplate(captureStep.output.template, { collectedData: itemData,
text: headingPrefix + text, loopContexts: answers.loopContexts,
field: nestedStep.key, }, context);
value: headingPrefix + text,
heading_level: headingLevel ? String(headingLevel) : (captureStep.heading_level?.default ? String(captureStep.heading_level.default) : ""), if (rendered) {
})); itemParts.push(rendered);
} else {
// Default: text with heading prefix if configured
itemParts.push(headingPrefix + text);
} }
} }
} else if (nestedStep.type === "capture_frontmatter") { } else if (nestedStep.type === "capture_frontmatter") {
@ -287,7 +480,7 @@ function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: numb
nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue); nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue);
// Recursively render the nested loop // Recursively render the nested loop
const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, depth + 1); const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, context, depth + 1);
if (nestedOutput) { if (nestedOutput) {
itemParts.push(nestedOutput); itemParts.push(nestedOutput);
} }
@ -339,3 +532,166 @@ function renderTemplate(template: string, tokens: { text: string; field: string;
return result; return result;
} }
// WP-26: Helper-Funktionen für Section-Types und Block-IDs
/**
* Kombiniert Heading, Section-Type-Callout, Content und Edges zu einer Section.
* Formatierungsregel: Section-Type direkt nach Heading, Edges am Ende.
*/
function combineSectionParts(
heading: string,
sectionTypeCallout: string,
content: string,
references: string,
autoEdges: string
): string {
const parts: string[] = [];
if (heading) {
parts.push(heading);
}
if (sectionTypeCallout) {
parts.push(sectionTypeCallout);
}
if (content) {
parts.push(content);
}
// WP-26: Edges am Ende der Sektion (Referenzen + automatische Edges)
const edges = [references, autoEdges].filter(e => e.trim()).join("\n");
if (edges) {
parts.push(edges);
}
return parts.join("\n\n");
}
/**
* Generiert Edge-Callouts für explizite Referenzen.
* Format: > [!edge] <edge_type>\n> [[#^block-id]]
*/
function renderReferences(
references: Array<{ block_id: string; edge_type?: string }>,
context: RenderContext,
currentSection: SectionInfo
): string {
if (!references || references.length === 0) {
return "";
}
const edgeCallouts: string[] = [];
for (const ref of references) {
// Prüfe, ob Block-ID existiert
if (!context.generatedBlockIds.has(ref.block_id)) {
console.warn(`[WP-26] Block-ID "${ref.block_id}" nicht gefunden für Referenz`);
continue;
}
// WP-26: Prüfe auf Selbstreferenz
if (ref.block_id === currentSection.blockId) {
console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${ref.block_id}" zeigt auf sich selbst`);
continue;
}
const edgeType = ref.edge_type || "related_to";
edgeCallouts.push(`> [!edge] ${edgeType}`);
edgeCallouts.push(`> [[#^${ref.block_id}]]`);
}
return edgeCallouts.join("\n");
}
/**
* Generiert automatische Edge-Vorschläge zwischen Sections.
* Verwendet graph_schema.md für typische Edge-Types und edge_vocabulary.md für inverse Edges.
*
* Formatierungsregel: Edges werden am Ende der aktuellen Section eingefügt.
* Forward-Edge: prevSection -> currentSection (in aktueller Section)
* Rückwärts-Edge: currentSection -> prevSection (in aktueller Section, inverser Edge-Type)
*/
function renderAutomaticEdges(
currentSection: SectionInfo,
context: RenderContext
): string {
// Nur wenn aktuelle Section eine Block-ID hat
if (!currentSection.blockId) {
return "";
}
// Nur wenn es vorherige Sections gibt
if (context.sectionSequence.length <= 1) {
return "";
}
// Aktuelle Section ist die letzte in der Sequenz
// Wir generieren Edges von allen vorherigen Sections zur aktuellen Section
const prevSections = context.sectionSequence.slice(0, -1);
const edgeCallouts: string[] = [];
const { graphSchema, vocabulary } = context.options;
for (const prevSection of prevSections) {
// Nur wenn vorherige Section auch eine Block-ID hat
if (!prevSection.blockId) {
continue;
}
// WP-26: Prüfe auf Selbstreferenz (sollte nicht vorkommen, aber sicherheitshalber prüfen)
if (prevSection.blockId === currentSection.blockId) {
console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${prevSection.blockId}" zeigt auf sich selbst`);
continue;
}
// Ermittle effective_types
const prevType = prevSection.sectionType || prevSection.noteType;
const currentType = currentSection.sectionType || currentSection.noteType;
// Lookup in graph_schema.md: prevType -> currentType
let forwardEdgeType: string | null = null;
if (graphSchema && prevType && currentType) {
const hints = getHints(graphSchema, prevType, currentType);
if (hints.typical.length > 0) {
forwardEdgeType = hints.typical[0]; // Erster typischer Edge-Type aus graph_schema
} else {
// Debug: Log wenn keine typischen Edges gefunden wurden
console.debug(`[WP-26] Keine typischen Edges gefunden für ${prevType} -> ${currentType}`);
}
}
// Fallback: related_to
if (!forwardEdgeType) {
forwardEdgeType = "related_to";
}
// WP-26: Automatische Edge-Vorschläge
// Wir generieren beide Richtungen:
// 1. Forward-Edge: prevSection -> currentSection (mit originalem Edge-Type)
// 2. Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type)
//
// Beide Edges werden in der aktuellen Section eingefügt und zeigen zur prevSection
// Der Backlink wird automatisch mitgesetzt
// Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type)
let inverseEdgeType: string | null = null;
if (vocabulary) {
inverseEdgeType = vocabulary.getInverse(forwardEdgeType);
}
// Generiere Rückwärts-Edge (currentSection -> prevSection)
if (inverseEdgeType) {
edgeCallouts.push(`> [!edge] ${inverseEdgeType}`);
edgeCallouts.push(`> [[#^${prevSection.blockId}]]`);
}
// Generiere Forward-Edge als Backlink (prevSection -> currentSection)
// Diese Edge beschreibt die Beziehung von prevSection zu currentSection
edgeCallouts.push(`> [!edge] ${forwardEdgeType}`);
edgeCallouts.push(`> [[#^${prevSection.blockId}]]`);
}
return edgeCallouts.join("\n");
}

View File

@ -76,6 +76,14 @@ export interface CaptureTextStep {
required?: boolean; required?: boolean;
section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse") section?: string; // Markdown section header (e.g., "## 🧩 Erlebnisse")
prompt?: string; // Optional prompt text prompt?: string; // Optional prompt text
// WP-26: Section-Type und Block-ID Support
section_type?: string; // Optional: Section-Type (z.B. "experience", "insight")
block_id?: string; // Optional: Explizite Block-ID (z.B. "sit")
generate_block_id?: boolean; // Optional: Automatische Block-ID-Generierung aus Step-Key
references?: Array<{ // Optional: Referenzen zu vorherigen Sections
block_id: string; // Block-ID der referenzierten Section
edge_type?: string; // Optional: Vorgeschlagener Edge-Type
}>;
output?: { output?: {
template?: string; // Template with tokens: {text}, {field}, {value} template?: string; // Template with tokens: {text}, {field}, {value}
}; };
@ -91,6 +99,14 @@ export interface CaptureTextLineStep {
enabled?: boolean; // Show heading level selector (default: false) enabled?: boolean; // Show heading level selector (default: false)
default?: number; // Default heading level 1-6 (default: 2) default?: number; // Default heading level 1-6 (default: 2)
}; };
// WP-26: Section-Type und Block-ID Support
section_type?: string; // Optional: Section-Type (z.B. "experience", "insight")
block_id?: string; // Optional: Explizite Block-ID (z.B. "sit")
generate_block_id?: boolean; // Optional: Automatische Block-ID-Generierung aus Step-Key
references?: Array<{ // Optional: Referenzen zu vorherigen Sections
block_id: string; // Block-ID der referenzierten Section
edge_type?: string; // Optional: Vorgeschlagener Edge-Type
}>;
output?: { output?: {
template?: string; // Template with tokens: {text}, {field}, {value}, {heading_level} template?: string; // Template with tokens: {text}, {field}, {value}, {heading_level}
}; };

View File

@ -50,6 +50,9 @@ export function createWizardState(profile: InterviewProfile): WizardState {
patches: [], patches: [],
activeLoopPath: [], // Start at top level activeLoopPath: [], // Start at top level
pendingEdgeAssignments: [], // Start with empty pending assignments pendingEdgeAssignments: [], // Start with empty pending assignments
// WP-26: Initialize Section-Type und Block-ID Tracking
generatedBlockIds: new Map(),
sectionSequence: [],
}; };
} }

View File

@ -26,7 +26,11 @@ import {
createMarkdownToolbar, createMarkdownToolbar,
} from "./markdownToolbar"; } from "./markdownToolbar";
import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "../mapping/edgeTypeSelector"; import { detectEdgeSelectorContext, changeEdgeTypeForLinks } from "../mapping/edgeTypeSelector";
import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; import { renderProfileToMarkdown, type RenderAnswers, type RenderOptions } from "../interview/renderer";
import type { GraphSchema } from "../mapping/graphSchema";
import { Vocabulary } from "../vocab/Vocabulary";
import type { SectionInfo } from "../interview/wizardState";
import { slugify } from "../interview/slugify";
import { NoteIndex } from "../entityPicker/noteIndex"; import { NoteIndex } from "../entityPicker/noteIndex";
import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal"; import { EntityPickerModal, type EntityPickerResult } from "./EntityPickerModal";
import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink"; import { insertWikilinkIntoTextarea } from "../entityPicker/wikilink";
@ -176,8 +180,18 @@ export class InterviewWizardModal extends Modal {
stepKey: step?.key || "null", stepKey: step?.key || "null",
stepLabel: step?.label || "null", stepLabel: step?.label || "null",
totalSteps: flattenSteps(this.state.profile.steps).length, totalSteps: flattenSteps(this.state.profile.steps).length,
sectionSequenceLength: this.state.sectionSequence.length,
generatedBlockIdsCount: this.state.generatedBlockIds.size,
}); });
// WP-26: Track Section-Info während des Wizard-Durchlaufs
if (step) {
console.log(`[WP-26] trackSectionInfo wird aufgerufen für Step ${step.key}, type: ${step.type}`);
this.trackSectionInfo(step);
} else {
console.log(`[WP-26] Kein Step gefunden, trackSectionInfo wird nicht aufgerufen`);
}
if (!step) { if (!step) {
// Check if we're at the end legitimately or if there's an error // Check if we're at the end legitimately or if there's an error
const steps = flattenSteps(this.state.profile.steps); const steps = flattenSteps(this.state.profile.steps);
@ -415,10 +429,30 @@ export class InterviewWizardModal extends Modal {
valuePreview: value.substring(0, 50) + (value.length > 50 ? "..." : ""), valuePreview: value.substring(0, 50) + (value.length > 50 ? "..." : ""),
}); });
// WP-26: Speichere Fokus-Info vor State-Update, um Fokus-Verlust zu vermeiden
let hadFocus = false;
let selectionStart = 0;
let selectionEnd = 0;
if (document.activeElement === textareaRef) {
hadFocus = true;
selectionStart = textareaRef.selectionStart;
selectionEnd = textareaRef.selectionEnd;
}
// Update stored value // Update stored value
this.currentInputValues.set(step.key, value); this.currentInputValues.set(step.key, value);
this.state.collectedData.set(step.key, value); this.state.collectedData.set(step.key, value);
// WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war
if (hadFocus && textareaRef) {
setTimeout(() => {
if (textareaRef && document.body.contains(textareaRef)) {
textareaRef.focus();
textareaRef.setSelectionRange(selectionStart, selectionEnd);
}
}, 0);
}
// Update preview if in preview mode // Update preview if in preview mode
if (isPreviewMode) { if (isPreviewMode) {
this.updatePreview(previewContainer, value); this.updatePreview(previewContainer, value);
@ -469,44 +503,158 @@ export class InterviewWizardModal extends Modal {
backToEditWrapper.style.display = "none"; backToEditWrapper.style.display = "none";
} }
}, },
(app: App) => { async (app: App) => {
// Open entity picker modal // WP-26: Erweitere Entity-Picker um Block-ID-Vorschläge für Intra-Note-Links
if (!this.noteIndex) { // Zeige zuerst Block-ID-Auswahl für Intra-Note-Links, dann normale Note-Auswahl
new Notice("Note index not available"); const blockIds = Array.from(this.state.generatedBlockIds.keys());
return;
} if (blockIds.length > 0) {
new EntityPickerModal( // Zeige Block-ID-Auswahl-Modal
app, const blockIdModal = new Modal(app);
this.noteIndex, blockIdModal.titleEl.textContent = "Block-ID oder Note auswählen";
async (result: EntityPickerResult) => { blockIdModal.contentEl.createEl("p", {
// Check if inline micro edging is enabled (also for toolbar) text: "Wähle eine Block-ID für Intra-Note-Link oder eine Note:",
// Support: inline_micro, both (inline_micro + post_run) });
const edgingMode = this.profile.edging?.mode;
const shouldRunInlineMicro = // Block-ID-Liste
(edgingMode === "inline_micro" || edgingMode === "both") && const blockIdList = blockIdModal.contentEl.createEl("div");
this.settings?.inlineMicroEnabled !== false; blockIdList.style.display = "flex";
blockIdList.style.flexDirection = "column";
let linkText = `[[${result.basename}]]`; blockIdList.style.gap = "0.5em";
blockIdList.style.marginBottom = "1em";
if (shouldRunInlineMicro) {
// Get current step for section key resolution for (const blockId of blockIds) {
const currentStep = getCurrentStep(this.state); const sectionInfo = this.state.generatedBlockIds.get(blockId);
if (currentStep) { const btn = blockIdList.createEl("button", {
console.log("[Mindnet] Starting inline micro edging from toolbar"); text: `#^${blockId} - ${sectionInfo?.heading || blockId}`,
const edgeType = await this.handleInlineMicroEdging(currentStep, result.basename, result.path); });
if (edgeType && typeof edgeType === "string") { btn.style.width = "100%";
// Use [[rel:type|link]] format btn.style.textAlign = "left";
linkText = `[[rel:${edgeType}|${result.basename}]]`; btn.style.padding = "0.5em";
btn.onclick = async () => {
blockIdModal.close();
// Erstelle Block-ID-Link
const blockIdLink = `#^${blockId}`;
// Prüfe, ob inline micro edging aktiviert ist
const edgingMode = this.profile.edging?.mode;
const shouldRunInlineMicro =
(edgingMode === "inline_micro" || edgingMode === "both") &&
this.settings?.inlineMicroEnabled !== false;
let linkText = `[[${blockIdLink}]]`;
if (shouldRunInlineMicro) {
// Get current step for section key resolution
const currentStep = getCurrentStep(this.state);
if (currentStep) {
console.log("[WP-26] Starting inline micro edging for Block-ID:", blockId);
const edgeType = await this.handleInlineMicroEdging(currentStep, blockIdLink, "");
if (edgeType && typeof edgeType === "string") {
// Use [[rel:type|#^block-id]] format
linkText = `[[rel:${edgeType}|${blockIdLink}]]`;
}
} }
} }
}
// Insert link
// Insert link with rel: prefix if edge type was selected const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, "");
// Extract inner part (without [[ and ]]) insertWikilinkIntoTextarea(textarea, innerLink);
const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, ""); };
insertWikilinkIntoTextarea(textarea, innerLink);
} }
).open();
// Separator
blockIdModal.contentEl.createEl("hr");
// Button für normale Note-Auswahl
const noteBtn = blockIdModal.contentEl.createEl("button", {
text: "📄 Note auswählen...",
cls: "mod-cta",
});
noteBtn.style.width = "100%";
noteBtn.style.marginTop = "1em";
noteBtn.onclick = () => {
blockIdModal.close();
// Öffne normalen Entity-Picker
if (!this.noteIndex) {
new Notice("Note index not available");
return;
}
new EntityPickerModal(
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 =
(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}]]`;
}
}
}
// Insert link with rel: prefix if edge type was selected
// Extract inner part (without [[ and ]])
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;
}
new EntityPickerModal(
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 =
(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}]]`;
}
}
}
// Insert link with rel: prefix if edge type was selected
// Extract inner part (without [[ and ]])
const innerLink = linkText.replace(/^\[\[/, "").replace(/\]\]$/, "");
insertWikilinkIntoTextarea(textarea, innerLink);
}
).open();
}
}, },
async (app: App, textarea: HTMLTextAreaElement) => { async (app: App, textarea: HTMLTextAreaElement) => {
// Edge-Type-Selektor für Interview-Eingabefeld // Edge-Type-Selektor für Interview-Eingabefeld
@ -973,15 +1121,30 @@ export class InterviewWizardModal extends Modal {
if (typeof item === "object" && item !== null) { if (typeof item === "object" && item !== null) {
const itemEntries = Object.entries(item as Record<string, unknown>); const itemEntries = Object.entries(item as Record<string, unknown>);
if (itemEntries.length > 0) { if (itemEntries.length > 0) {
const previewText = itemEntries const previewParts: string[] = [];
.map(([key, value]) => { for (const [key, value] of itemEntries) {
const nestedStep = step.items.find(s => s.key === key); const nestedStep = step.items.find(s => s.key === key);
const label = nestedStep?.label || key; const label = nestedStep?.label || key;
const strValue = String(value); const strValue = String(value);
return `${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}`;
}) // WP-26: Zeige Block-ID wenn vorhanden
.join(", "); let blockIdDisplay = "";
preview.createSpan({ text: previewText }); if (nestedStep && (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line")) {
const captureStep = nestedStep as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep;
let blockId: string | null = null;
if (captureStep.block_id) {
blockId = captureStep.block_id;
} else if (captureStep.generate_block_id) {
blockId = slugify(`${step.key}-${captureStep.key}`);
}
if (blockId) {
blockIdDisplay = ` ^${blockId}`;
}
}
previewParts.push(`${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}${blockIdDisplay}`);
}
preview.createSpan({ text: previewParts.join(", ") });
} else { } else {
preview.createSpan({ text: "Empty item" }); preview.createSpan({ text: "Empty item" });
} }
@ -1093,6 +1256,8 @@ export class InterviewWizardModal extends Modal {
if (currentState) { if (currentState) {
const newState = setDraftField(currentState, fieldId, value); const newState = setDraftField(currentState, fieldId, value);
this.state.loopRuntimeStates.set(step.key, newState); this.state.loopRuntimeStates.set(step.key, newState);
// WP-26: NICHT renderStep() hier aufrufen, um Fokus-Verlust zu vermeiden
// Das Re-Rendering wird nur bei expliziten Navigationen durchgeführt
} }
}); });
@ -1134,6 +1299,12 @@ export class InterviewWizardModal extends Modal {
itemNextBtn.onclick = () => { itemNextBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(step.key); const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) { if (currentState) {
// WP-26: Track Section-Info für aktuelle nested Steps bevor Navigation
const activeNestedStep = step.items[activeStepIndex];
if (activeNestedStep) {
this.trackSectionInfoForLoopItem(activeNestedStep, step.key, currentState.draft);
}
const newState = itemNextStep(currentState, step.items.length); const newState = itemNextStep(currentState, step.items.length);
this.state.loopRuntimeStates.set(step.key, newState); this.state.loopRuntimeStates.set(step.key, newState);
this.renderStep(); this.renderStep();
@ -1180,6 +1351,14 @@ export class InterviewWizardModal extends Modal {
if (isDraftDirty(currentState.draft)) { if (isDraftDirty(currentState.draft)) {
const wasNewItem = currentState.editIndex === null; const wasNewItem = currentState.editIndex === null;
// WP-26: Track Section-Info für alle nested Steps bevor Commit
for (const nestedStep of step.items) {
if (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line") {
this.trackSectionInfoForLoopItem(nestedStep, step.key, currentState.draft);
}
}
const newState = commitDraft(currentState); const newState = commitDraft(currentState);
this.state.loopRuntimeStates.set(step.key, newState); this.state.loopRuntimeStates.set(step.key, newState);
// Update answers // Update answers
@ -1208,6 +1387,8 @@ export class InterviewWizardModal extends Modal {
} }
} }
// WP-26: Nach dem Speichern bleibt der Loop-Step aktiv (kein automatischer Wechsel zum nächsten Step)
// Der Benutzer kann weitere Items hinzufügen oder mit "Next" zum nächsten Step navigieren
this.renderStep(); this.renderStep();
} else { } else {
new Notice("Please enter at least one field"); new Notice("Please enter at least one field");
@ -1267,6 +1448,28 @@ export class InterviewWizardModal extends Modal {
}); });
} }
// WP-26: Block-ID-Anzeige für capture_text mit section
const captureStep = nestedStep as import("../interview/types").CaptureTextStep;
if (captureStep.section) {
let blockId: string | null = null;
if (captureStep.block_id) {
blockId = captureStep.block_id;
} else if (captureStep.generate_block_id) {
blockId = slugify(`${loopKey}-${captureStep.key}`);
}
if (blockId) {
const blockIdDisplay = fieldContainer.createEl("div", {
cls: "block-id-display",
text: `Block-ID: ^${blockId}`,
});
blockIdDisplay.style.fontSize = "0.85em";
blockIdDisplay.style.color = "var(--text-muted)";
blockIdDisplay.style.marginBottom = "0.5em";
blockIdDisplay.style.fontStyle = "italic";
}
}
// Container for editor/preview // Container for editor/preview
const editorContainer = fieldContainer.createEl("div", { const editorContainer = fieldContainer.createEl("div", {
cls: "markdown-editor-container", cls: "markdown-editor-container",
@ -1338,8 +1541,32 @@ export class InterviewWizardModal extends Modal {
textSetting.addTextArea((text) => { textSetting.addTextArea((text) => {
textareaRef = text.inputEl; textareaRef = text.inputEl;
text.setValue(existingValue); text.setValue(existingValue);
// WP-26: Speichere Fokus-Info vor onChange, um Fokus-Verlust zu vermeiden
let hadFocus = false;
let selectionStart = 0;
let selectionEnd = 0;
text.onChange((value) => { text.onChange((value) => {
// WP-26: Speichere Fokus-Info vor State-Update
if (document.activeElement === textareaRef) {
hadFocus = true;
selectionStart = textareaRef.selectionStart;
selectionEnd = textareaRef.selectionEnd;
}
onFieldChange(nestedStep.key, value); onFieldChange(nestedStep.key, value);
// WP-26: Stelle Fokus wieder her, wenn er vorher vorhanden war
if (hadFocus && textareaRef) {
setTimeout(() => {
if (textareaRef && document.body.contains(textareaRef)) {
textareaRef.focus();
textareaRef.setSelectionRange(selectionStart, selectionEnd);
}
}, 0);
}
// Update preview if in preview mode // Update preview if in preview mode
const currentPreviewMode = this.previewMode.get(previewKey) || false; const currentPreviewMode = this.previewMode.get(previewKey) || false;
if (currentPreviewMode) { if (currentPreviewMode) {
@ -1515,6 +1742,29 @@ export class InterviewWizardModal extends Modal {
settingNameEl.style.display = "none"; settingNameEl.style.display = "none";
} }
// WP-26: Block-ID-Anzeige für capture_text_line mit heading_level
let blockIdDisplay: HTMLElement | null = null;
if (nestedStep.heading_level?.enabled) {
const captureStep = nestedStep as import("../interview/types").CaptureTextLineStep;
let blockId: string | null = null;
if (captureStep.block_id) {
blockId = captureStep.block_id;
} else if (captureStep.generate_block_id) {
blockId = slugify(`${loopKey}-${captureStep.key}`);
}
if (blockId) {
blockIdDisplay = fieldSetting.settingEl.createEl("div", {
cls: "block-id-display",
text: `Block-ID: ^${blockId}`,
});
blockIdDisplay.style.fontSize = "0.85em";
blockIdDisplay.style.color = "var(--text-muted)";
blockIdDisplay.style.marginTop = "0.25em";
blockIdDisplay.style.fontStyle = "italic";
}
}
fieldSetting.addText((text) => { fieldSetting.addText((text) => {
text.setValue(existingValue); text.setValue(existingValue);
text.onChange((value) => { text.onChange((value) => {
@ -2416,38 +2666,86 @@ export class InterviewWizardModal extends Modal {
graphSchema = await this.plugin.ensureGraphSchemaLoaded(); graphSchema = await this.plugin.ensureGraphSchemaLoaded();
} }
// Get source note ID and type // WP-26: Prüfe, ob es eine Block-ID-Referenz ist ([[#^block-id]])
const sourceContent = this.fileContent;
const { extractFrontmatterId } = await import("../parser/parseFrontmatter");
const sourceNoteId = extractFrontmatterId(sourceContent) || undefined;
const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/);
let sourceType: string | undefined; let sourceType: string | undefined;
if (sourceFrontmatter && sourceFrontmatter[1]) {
const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m);
if (typeMatch && typeMatch[1]) {
sourceType = typeMatch[1].trim();
}
}
// Get target note ID and type
let targetNoteId: string | undefined;
let targetType: string | undefined; let targetType: string | undefined;
try { let sourceNoteId: string | undefined;
const targetFile = this.app.vault.getAbstractFileByPath(linkPath); let targetNoteId: string | undefined;
if (targetFile && targetFile instanceof TFile) {
const targetContent = await this.app.vault.read(targetFile); const blockIdMatch = linkBasename.match(/^#\^(.+)$/);
targetNoteId = extractFrontmatterId(targetContent) || undefined; if (blockIdMatch && blockIdMatch[1]) {
const targetFrontmatter = targetContent.match(/^---\n([\s\S]*?)\n---/); // Intra-Note-Edge: Block-ID-Referenz
if (targetFrontmatter && targetFrontmatter[1]) { const blockId = blockIdMatch[1];
const typeMatch = targetFrontmatter[1].match(/^type:\s*(.+)$/m); console.log(`[WP-26] Block-ID-Referenz erkannt: ${blockId}`);
if (typeMatch && typeMatch[1]) {
targetType = typeMatch[1].trim(); // Finde Source-Section (aktuelle Section aus Step)
} const currentStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep;
let currentBlockId: string | null = null;
if (currentStep.block_id) {
currentBlockId = currentStep.block_id;
} else if (currentStep.generate_block_id) {
currentBlockId = slugify(currentStep.key);
}
if (currentBlockId) {
const sourceSection = this.state.generatedBlockIds.get(currentBlockId);
if (sourceSection) {
sourceType = sourceSection.sectionType || sourceSection.noteType;
console.log(`[WP-26] Source-Type aus Section: ${sourceType}`);
}
} else {
// Fallback: Verwende Note-Type
sourceType = this.state.profile.note_type;
}
// Finde Target-Section (Block-ID aus generatedBlockIds)
const targetSection = this.state.generatedBlockIds.get(blockId);
if (targetSection) {
targetType = targetSection.sectionType || targetSection.noteType;
console.log(`[WP-26] Target-Type aus Section: ${targetType}`);
} else {
console.warn(`[WP-26] Block-ID "${blockId}" nicht in generatedBlockIds gefunden`);
// Fallback: Verwende Note-Type
targetType = this.state.profile.note_type;
}
// Für Intra-Note-Edges: sourceNoteId = targetNoteId (gleiche Note)
const sourceContent = this.fileContent;
const { extractFrontmatterId } = await import("../parser/parseFrontmatter");
sourceNoteId = extractFrontmatterId(sourceContent) || undefined;
targetNoteId = sourceNoteId; // Gleiche Note
} else {
// Inter-Note-Edge: Normale Wikilink-Referenz
// Get source note ID and type
const sourceContent = this.fileContent;
const { extractFrontmatterId } = await import("../parser/parseFrontmatter");
sourceNoteId = extractFrontmatterId(sourceContent) || undefined;
const sourceFrontmatter = sourceContent.match(/^---\n([\s\S]*?)\n---/);
if (sourceFrontmatter && sourceFrontmatter[1]) {
const typeMatch = sourceFrontmatter[1].match(/^type:\s*(.+)$/m);
if (typeMatch && typeMatch[1]) {
sourceType = typeMatch[1].trim();
} }
} }
} catch (e) {
// Target note might not exist yet, that's OK // Get target note ID and type
console.debug("[Mindnet] Could not read target note for inline micro:", e); try {
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);
}
} }
// Show inline edge type modal // Show inline edge type modal
@ -2794,13 +3092,47 @@ export class InterviewWizardModal extends Modal {
this.state.loopContexts.set(loopKey, loopState.items); this.state.loopContexts.set(loopKey, loopState.items);
} }
// WP-26: Lade Vocabulary und GraphSchema für automatische Edge-Vorschläge
let vocabulary: Vocabulary | null = null;
let graphSchema: GraphSchema | null = null;
try {
// Lade Vocabulary
if (!this.vocabulary && this.settings) {
const vocabText = await VocabularyLoader.loadText(
this.app,
this.settings.edgeVocabularyPath
);
this.vocabulary = parseEdgeVocabulary(vocabText);
}
if (this.vocabulary) {
vocabulary = new Vocabulary(this.vocabulary);
}
// Lade GraphSchema
if (this.plugin?.ensureGraphSchemaLoaded) {
graphSchema = await this.plugin.ensureGraphSchemaLoaded();
}
} catch (e) {
console.warn("[WP-26] Fehler beim Laden von Vocabulary/GraphSchema für Renderer:", e);
// Continue without vocabulary/schema - Renderer funktioniert auch ohne
}
// Use renderer to generate markdown from collected data // Use renderer to generate markdown from collected data
const answers: RenderAnswers = { const answers: RenderAnswers = {
collectedData: this.state.collectedData, collectedData: this.state.collectedData,
loopContexts: this.state.loopContexts, loopContexts: this.state.loopContexts,
sectionSequence: Array.from(this.state.sectionSequence), // WP-26: Section-Sequenz übergeben
}; };
const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers); // WP-26: Render-Optionen für Section-Types und Edge-Vorschläge
const renderOptions: RenderOptions = {
graphSchema: graphSchema,
vocabulary: vocabulary,
noteType: this.state.profile.note_type,
};
const renderedMarkdown = renderProfileToMarkdown(this.state.profile, answers, renderOptions);
if (renderedMarkdown.trim()) { if (renderedMarkdown.trim()) {
// Append rendered markdown to file // Append rendered markdown to file
@ -2878,6 +3210,183 @@ export class InterviewWizardModal extends Modal {
return JSON.stringify(value); return JSON.stringify(value);
} }
/**
* WP-26: Track Section-Info für Loop-Items.
* Wird aufgerufen, wenn ein Loop-Item gespeichert wird oder zwischen Steps navigiert wird.
*/
private trackSectionInfoForLoopItem(
nestedStep: InterviewStep,
loopKey: string,
draft: Record<string, unknown>
): void {
// Nur für capture_text und capture_text_line Steps
if (nestedStep.type !== "capture_text" && nestedStep.type !== "capture_text_line") {
return;
}
const captureStep = nestedStep as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep;
// Für capture_text: Nur wenn Section vorhanden ist
if (nestedStep.type === "capture_text" && !captureStep.section) {
return;
}
// Für capture_text_line: Nur wenn heading_level enabled ist
if (nestedStep.type === "capture_text_line" && !captureStep.heading_level?.enabled) {
return;
}
// WP-26: Block-ID ermitteln
let blockId: string | null = null;
if (captureStep.block_id) {
blockId = captureStep.block_id;
} else if (captureStep.generate_block_id) {
// Für Loop-Items: Verwende Loop-Key + Step-Key für eindeutige Block-ID
blockId = slugify(`${loopKey}-${captureStep.key}`);
}
// WP-26: Section-Type ermitteln
const sectionType = captureStep.section_type || null;
const noteType = this.state.profile.note_type;
// WP-26: Heading-Text ermitteln
let heading = "";
if (nestedStep.type === "capture_text" && captureStep.section) {
// Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext")
heading = captureStep.section.replace(/^#+\s+/, "");
} else if (nestedStep.type === "capture_text_line") {
// Für capture_text_line: Heading aus Draft-Wert
const draftValue = draft[captureStep.key];
if (draftValue && typeof draftValue === "string") {
heading = draftValue;
} else {
heading = captureStep.key;
}
}
// WP-26: Section-Info erstellen
const sectionInfo: SectionInfo = {
stepKey: `${loopKey}.${captureStep.key}`, // Eindeutiger Key für Loop-Items
sectionType: sectionType,
heading: heading,
blockId: blockId,
noteType: noteType,
};
// WP-26: Block-ID tracken (nur wenn vorhanden)
if (blockId) {
this.state.generatedBlockIds.set(blockId, sectionInfo);
console.log(`[WP-26] Block-ID für Loop-Item getrackt: ${blockId} für Step ${loopKey}.${captureStep.key}`);
}
// WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden)
// Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate)
const existingIndex = this.state.sectionSequence.findIndex(
s => s.stepKey === sectionInfo.stepKey && s.blockId === blockId
);
if (existingIndex === -1) {
// Neue Section hinzufügen
this.state.sectionSequence.push(sectionInfo);
console.log(`[WP-26] Section für Loop-Item zur Sequenz hinzugefügt: ${sectionInfo.stepKey} (Block-ID: ${blockId || "keine"})`);
} else {
// Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat)
this.state.sectionSequence[existingIndex] = sectionInfo;
console.log(`[WP-26] Section für Loop-Item aktualisiert: ${sectionInfo.stepKey}`);
}
}
/**
* WP-26: Track Section-Info während des Wizard-Durchlaufs.
* Wird aufgerufen, wenn ein Step mit section/section_type gerendert wird.
*/
private trackSectionInfo(step: InterviewStep): void {
// Nur für capture_text und capture_text_line Steps mit section
if (step.type !== "capture_text" && step.type !== "capture_text_line") {
return;
}
const captureStep = step as import("../interview/types").CaptureTextStep | import("../interview/types").CaptureTextLineStep;
console.log(`[WP-26] trackSectionInfo aufgerufen für Step ${step.key}`, {
type: step.type,
hasSection: !!captureStep.section,
section: captureStep.section,
hasSectionType: !!captureStep.section_type,
sectionType: captureStep.section_type,
hasBlockId: !!captureStep.block_id,
blockId: captureStep.block_id,
generateBlockId: captureStep.generate_block_id,
hasHeadingLevel: !!captureStep.heading_level?.enabled,
});
// Nur wenn Section vorhanden ist
if (!captureStep.section && step.type === "capture_text") {
console.log(`[WP-26] Step ${step.key} hat keine section, überspringe`);
return;
}
// Für capture_text_line: Nur wenn heading_level enabled ist
if (step.type === "capture_text_line" && !captureStep.heading_level?.enabled) {
console.log(`[WP-26] Step ${step.key} hat kein heading_level enabled, überspringe`);
return;
}
// WP-26: Block-ID ermitteln
let blockId: string | null = null;
if (captureStep.block_id) {
blockId = captureStep.block_id;
} else if (captureStep.generate_block_id) {
blockId = slugify(captureStep.key);
}
// WP-26: Section-Type ermitteln
const sectionType = captureStep.section_type || null;
const noteType = this.state.profile.note_type;
// WP-26: Heading-Text ermitteln
let heading = "";
if (step.type === "capture_text" && captureStep.section) {
// Extrahiere Heading-Text aus Section (z.B. "## 📖 Kontext" -> "📖 Kontext")
heading = captureStep.section.replace(/^#+\s+/, "");
} else if (step.type === "capture_text_line") {
// Für capture_text_line: Heading wird später aus dem eingegebenen Text generiert
// Verwende Step-Key als Platzhalter
heading = captureStep.key;
}
// WP-26: Section-Info erstellen
const sectionInfo: SectionInfo = {
stepKey: captureStep.key,
sectionType: sectionType,
heading: heading,
blockId: blockId,
noteType: noteType,
};
// WP-26: Block-ID tracken (nur wenn vorhanden)
if (blockId) {
this.state.generatedBlockIds.set(blockId, sectionInfo);
console.log(`[WP-26] Block-ID getrackt: ${blockId} für Step ${captureStep.key}`);
}
// WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden)
// Prüfe, ob diese Section bereits in der Sequenz ist (vermeide Duplikate)
const existingIndex = this.state.sectionSequence.findIndex(
s => s.stepKey === captureStep.key && s.blockId === blockId
);
if (existingIndex === -1) {
// Neue Section hinzufügen
this.state.sectionSequence.push(sectionInfo);
console.log(`[WP-26] Section zur Sequenz hinzugefügt: ${captureStep.key} (Block-ID: ${blockId || "keine"})`);
} else {
// Section aktualisieren (falls sich Block-ID oder Section-Type geändert hat)
this.state.sectionSequence[existingIndex] = sectionInfo;
console.log(`[WP-26] Section aktualisiert: ${captureStep.key}`);
}
}
onClose(): void { onClose(): void {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();